Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback

- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
  pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching

40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-09-16 18:44:30 +00:00
commit 0ed20bb838
90 changed files with 8545 additions and 0 deletions

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
.git
.env
**/__pycache__
**/*.pyc
frontend/node_modules
frontend/dist
backend/.venv
tests
README.md

24
.env.example Normal file
View file

@ -0,0 +1,24 @@
APP_ENV=production
APP_BASE_URL=https://testmyyoutube.vrubel.xyz
APP_PORT=8080
APP_SECRET_KEY=CHANGE_ME
TOKEN_ENCRYPTION_KEY=CHANGE_ME
POSTGRES_PASSWORD=CHANGE_ME
DATABASE_URL=postgresql+psycopg://youtube_app:CHANGE_ME@postgres:5432/youtube_app
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://testmyyoutube.vrubel.xyz/api/auth/google/callback
ALLOWED_GOOGLE_EMAIL=
METUBE_API_BASE_URL=http://192.168.8.177:8081
METUBE_PUBLIC_BASE_URL=http://192.168.8.177:8081
METUBE_CONTAINER_DOWNLOAD_DIR=/downloads
METUBE_REQUEST_TIMEOUT_SECONDS=30
SUBSCRIPTIONS_SYNC_INTERVAL_HOURS=6
VIDEOS_SYNC_INTERVAL_MINUTES=60
VIDEOS_PER_CHANNEL_SYNC=10
LOG_LEVEL=INFO

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
.env
.env.local
__pycache__/
*.pyc
backend/.venv/
.venv/
frontend/node_modules/
frontend/dist/
*.log
.DS_Store

43
AGENTS.md Normal file
View file

@ -0,0 +1,43 @@
# AGENTS.md
Полное ТЗ: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md). Перед любой существенной работой сверяйся с ним — этот файл лишь выжимка ограничений.
## Жёсткие ограничения (раздел 34 ТЗ)
1. Не форкать/копировать Tube Archivist.
2. Не встраивать yt-dlp в этот сервис — скачивание только через существующий MeTube (`mediaVM`).
3. Не дублировать функции скачивания MeTube.
4. Не монтировать media storage MeTube на `hermesVM` — видео не хранится локально, только проксируется/линкуется.
5. Не реализовывать YouTube Recommendations/Home.
6. Не делать multi-user — приложение строго single-user (`ALLOWED_GOOGLE_EMAIL`).
7. Frontend не должен напрямую вызывать Google API.
8. Frontend не должен напрямую вызывать MeTube write API (`POST /add` и т.п.) — только через наш backend.
9. Не восстанавливать filename скачанного видео из YouTube title — использовать только точное имя, пришедшее от MeTube по событию `completed`.
10. Не удалять существующие файлы MeTube.
11. Не менять исходный код MeTube.
12. Все внешние base URL (MeTube, Google) — через env/config, не хардкодить.
13. REST API должен оставаться пригодным для будущего mobile/PWA клиента.
## Порядок фаз (раздел 33 ТЗ)
Не реализовывать следующую фазу, пока не работает предыдущая:
1. Skeleton (текущая фаза) — FastAPI, PostgreSQL, Alembic, React, Docker Compose, healthcheck.
2. Google OAuth + subscriptions sync + channels UI.
3. Categories CRUD + many-to-many + фильтр по категориям.
4. Video sync/feed (uploads playlists, playlistItems, videos, background scheduler).
5. YouTube playback (embed).
6. MeTube integration (`/add`, download_jobs, Socket.IO consumer, media URL).
7. Hardening (retry/recovery, logging, quota handling, tests, README).
## Структура и соглашения
- `migrations/` — Alembic, живёт в корне репозитория (не внутри `backend/`), импортирует модели из `backend/app`.
- Схема БД добавляется миграциями инкрементально по фазам (см. раздел 11 ТЗ), а не одним махом в Phase 1.
- MeTube-специфичные HTTP/Socket.IO вызовы должны быть инкапсулированы в отдельный класс `MeTubeClient` (раздел 36 ТЗ) — не размазывать по backend.
- Секреты только через `.env` (см. `.env.example`), никогда не коммитить `.env`, refresh token, client secret.
- Backend тесты — `pytest`, лежат в `tests/` в корне. Integration-тесты против Google/MeTube — mocked по умолчанию; реальные вызовы к `http://192.168.8.177:8081` — только opt-in, не в обычном CI.
## Запуск/проверка
См. `README.md`. Быстрая проверка: `docker compose up -d --build` и `curl http://localhost:8080/api/health`.

30
Dockerfile Normal file
View file

@ -0,0 +1,30 @@
FROM node:22-slim AS frontend-build
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.13-slim AS backend
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt ./backend/requirements.txt
RUN pip install --no-cache-dir -r backend/requirements.txt
COPY backend/ ./backend/
COPY migrations/ ./migrations/
COPY alembic.ini ./alembic.ini
COPY --from=frontend-build /frontend/dist ./backend/static
ENV PYTHONPATH=/app/backend
WORKDIR /app
COPY entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["./entrypoint.sh"]

89
README.md Normal file
View file

@ -0,0 +1,89 @@
# MyYouTube
Персональный YouTube-клиент с категориями подписок и интеграцией с существующим MeTube.
Полное техническое задание: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md).
Текущий статус: **Phase 1 — skeleton** (FastAPI backend, PostgreSQL, Alembic, React frontend, Docker Compose, healthcheck).
## Стек
- Backend: Python 3.13, FastAPI, SQLAlchemy 2.x, Alembic, Pydantic Settings
- Frontend: React, TypeScript, Vite, React Router, TanStack Query
- DB: PostgreSQL 16
- Deployment: Docker Compose
## Запуск в production (Docker Compose)
1. Скопируй `.env.example` в `.env` и заполни значения (Google OAuth credentials, секреты, MeTube URLs).
2. Собери и запусти:
```bash
docker compose up -d --build
```
3. Проверь health:
```bash
curl http://localhost:8080/api/health
```
Миграции Alembic применяются автоматически при старте контейнера `app` (см. `entrypoint.sh`).
## Локальная разработка
### Backend
```bash
cd backend
uv venv .venv
uv pip install -p .venv -r requirements-dev.txt
source .venv/bin/activate
cd ..
DATABASE_URL=postgresql+psycopg://youtube_app:password@localhost:5432/youtube_app \
APP_SECRET_KEY=dev APP_SECRET_KEY=dev TOKEN_ENCRYPTION_KEY=dev \
alembic upgrade head
uvicorn app.main:app --reload --app-dir backend --port 8080
```
Тесты:
```bash
backend/.venv/bin/python -m pytest tests/ -v
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
Dev-сервер Vite проксирует `/api/*` на `http://127.0.0.1:8080` (см. `vite.config.ts`).
## Миграции
```bash
alembic revision -m "описание"
alembic upgrade head
```
`alembic.ini` и папка `migrations/` находятся в корне проекта и подключают модели из `backend/app`.
## Структура проекта
```text
myyoutube/
├── backend/ # FastAPI приложение
├── frontend/ # React SPA
├── migrations/ # Alembic migrations
├── tests/ # backend tests (pytest)
├── Dockerfile
├── compose.yml
├── .env / .env.example
└── youtube_categories_metube_TZ.md
```
## Секреты
`.env` в `.gitignore`, не коммитится. Обязательные секреты: `APP_SECRET_KEY`, `TOKEN_ENCRYPTION_KEY`, `GOOGLE_CLIENT_SECRET`, `POSTGRES_PASSWORD`.

1018
UI_UX_spec.md Normal file

File diff suppressed because it is too large Load diff

40
alembic.ini Normal file
View file

@ -0,0 +1,40 @@
[alembic]
script_location = migrations
prepend_sys_path = .
sqlalchemy.url =
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

0
backend/app/__init__.py Normal file
View file

View file

96
backend/app/api/auth.py Normal file
View file

@ -0,0 +1,96 @@
import logging
import secrets
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.config import settings
from app.core.auth_dependency import require_session
from app.db import SessionLocal, get_db
from app.services import google_oauth, sync
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/auth/status")
def auth_status(request: Request, db: Session = Depends(get_db)) -> dict:
connected = google_oauth.is_connected(db)
return {
"authenticated": bool(request.session.get("authenticated")),
"connected": connected,
"email": google_oauth.get_connected_email(db) if connected else None,
}
@router.get("/auth/google/start")
def google_start(request: Request):
auth_url, state = google_oauth.build_authorization_url()
request.session["oauth_state"] = state
return RedirectResponse(auth_url)
def _run_initial_sync() -> None:
db = SessionLocal()
try:
sync.sync_subscriptions(db)
sync.sync_videos(db)
except Exception:
logger.exception("Initial sync after OAuth failed")
finally:
db.close()
@router.get("/auth/google/callback")
def google_callback(
request: Request,
background_tasks: BackgroundTasks,
code: str | None = None,
state: str | None = None,
error: str | None = None,
db: Session = Depends(get_db),
):
expected_state = request.session.pop("oauth_state", None)
if error:
logger.warning("Google OAuth returned error: %s", error)
return RedirectResponse(f"{settings.app_base_url}/?auth_error=google_error")
if not code or not state or not expected_state or not secrets.compare_digest(state, expected_state):
logger.warning("Google OAuth callback with invalid/missing state")
return RedirectResponse(f"{settings.app_base_url}/?auth_error=invalid_state")
try:
credentials = google_oauth.exchange_code(code, state)
except Exception:
logger.exception("Failed to exchange Google OAuth code")
return RedirectResponse(f"{settings.app_base_url}/?auth_error=exchange_failed")
try:
userinfo = google_oauth.fetch_userinfo(credentials.token)
except Exception:
logger.exception("Failed to fetch Google userinfo")
return RedirectResponse(f"{settings.app_base_url}/?auth_error=userinfo_failed")
email = (userinfo.get("email") or "").lower()
allowed_email = settings.allowed_google_email.lower()
if not allowed_email or email != allowed_email:
logger.warning("Rejected Google OAuth login for disallowed account")
google_oauth.revoke_token(credentials.refresh_token or credentials.token)
return RedirectResponse(f"{settings.app_base_url}/?auth_error=account_not_allowed")
google_oauth.store_credentials(db, email, credentials)
request.session["authenticated"] = True
background_tasks.add_task(_run_initial_sync)
return RedirectResponse(f"{settings.app_base_url}/")
@router.post("/auth/logout")
def logout(request: Request, _: None = Depends(require_session)):
request.session.clear()
return {"ok": True}

View file

@ -0,0 +1,134 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.auth_dependency import require_session
from app.core.slugify import unique_slugify
from app.db import get_db
from app.models.category import Category
from app.models.channel_category import channel_categories
router = APIRouter(dependencies=[Depends(require_session)])
class CategoryCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
class CategoryUpdate(BaseModel):
name: str = Field(min_length=1, max_length=255)
class CategoryReorder(BaseModel):
category_ids: list[int]
def _existing_slugs(db: Session, exclude_id: int | None = None) -> set[str]:
query = db.query(Category.slug)
if exclude_id is not None:
query = query.filter(Category.id != exclude_id)
return {row[0] for row in query.all()}
def _name_taken(db: Session, name: str, exclude_id: int | None = None) -> bool:
query = db.query(Category.name)
if exclude_id is not None:
query = query.filter(Category.id != exclude_id)
target = name.casefold()
return any(row[0].casefold() == target for row in query.all())
def _serialize(db: Session, category: Category, counts: dict[int, int]) -> dict:
return {
"id": category.id,
"name": category.name,
"slug": category.slug,
"sort_order": category.sort_order,
"channel_count": counts.get(category.id, 0),
}
@router.get("/categories")
def list_categories(db: Session = Depends(get_db)) -> list[dict]:
categories = db.query(Category).order_by(Category.sort_order.asc(), Category.id.asc()).all()
count_rows = (
db.query(channel_categories.c.category_id, func.count(channel_categories.c.channel_id))
.group_by(channel_categories.c.category_id)
.all()
)
counts = dict(count_rows)
return [_serialize(db, c, counts) for c in categories]
@router.post("/categories", status_code=201)
def create_category(payload: CategoryCreate, db: Session = Depends(get_db)) -> dict:
name = payload.name.strip()
if not name:
raise HTTPException(status_code=400, detail="Category name must not be empty")
if _name_taken(db, name):
raise HTTPException(status_code=409, detail="Category with this name already exists")
slug = unique_slugify(name, _existing_slugs(db))
max_sort_order = db.query(func.max(Category.sort_order)).scalar() or 0
category = Category(name=name, slug=slug, sort_order=max_sort_order + 1)
db.add(category)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="Category with this name already exists")
return _serialize(db, category, {})
@router.patch("/categories/{category_id}")
def update_category(category_id: int, payload: CategoryUpdate, db: Session = Depends(get_db)) -> dict:
category = db.get(Category, category_id)
if category is None:
raise HTTPException(status_code=404, detail="Category not found")
name = payload.name.strip()
if not name:
raise HTTPException(status_code=400, detail="Category name must not be empty")
if _name_taken(db, name, exclude_id=category_id):
raise HTTPException(status_code=409, detail="Category with this name already exists")
category.name = name
category.slug = unique_slugify(name, _existing_slugs(db, exclude_id=category_id))
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="Category with this name already exists")
return _serialize(db, category, {})
@router.delete("/categories/{category_id}", status_code=204)
def delete_category(category_id: int, db: Session = Depends(get_db)) -> None:
category = db.get(Category, category_id)
if category is None:
raise HTTPException(status_code=404, detail="Category not found")
db.delete(category)
db.commit()
@router.post("/categories/reorder")
def reorder_categories(payload: CategoryReorder, db: Session = Depends(get_db)) -> list[dict]:
categories = {c.id: c for c in db.query(Category).all()}
if set(payload.category_ids) != set(categories.keys()):
raise HTTPException(status_code=400, detail="category_ids must contain exactly all existing category ids")
for index, category_id in enumerate(payload.category_ids):
categories[category_id].sort_order = index
db.commit()
ordered = db.query(Category).order_by(Category.sort_order.asc(), Category.id.asc()).all()
return [_serialize(db, c, {}) for c in ordered]

106
backend/app/api/channels.py Normal file
View file

@ -0,0 +1,106 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.auth_dependency import require_session
from app.db import get_db
from app.models.category import Category
from app.models.channel import Channel
from app.models.channel_category import channel_categories
router = APIRouter(dependencies=[Depends(require_session)])
class ChannelCategoriesUpdate(BaseModel):
category_ids: list[int]
def _category_ids_by_channel(db: Session, channel_ids: list[int]) -> dict[int, list[int]]:
if not channel_ids:
return {}
rows = db.execute(
select(channel_categories.c.channel_id, channel_categories.c.category_id).where(
channel_categories.c.channel_id.in_(channel_ids)
)
).all()
result: dict[int, list[int]] = {}
for channel_id, category_id in rows:
result.setdefault(channel_id, []).append(category_id)
return result
def _serialize(channel: Channel, category_ids: list[int]) -> dict:
return {
"id": channel.id,
"youtube_channel_id": channel.youtube_channel_id,
"title": channel.title,
"description": channel.description,
"thumbnail_url": channel.thumbnail_url,
"uploads_playlist_id": channel.uploads_playlist_id,
"subscribed": channel.subscribed,
"last_synced_at": channel.last_synced_at,
"category_ids": category_ids,
}
@router.get("/channels")
def list_channels(
subscribed: bool | None = None,
search: str | None = None,
category_id: int | None = None,
uncategorized: bool = False,
db: Session = Depends(get_db),
) -> list[dict]:
query = db.query(Channel)
if subscribed is not None:
query = query.filter(Channel.subscribed == subscribed)
if search:
query = query.filter(Channel.title.ilike(f"%{search}%"))
if uncategorized:
categorized_ids = select(channel_categories.c.channel_id)
query = query.filter(~Channel.id.in_(categorized_ids))
elif category_id is not None:
channel_ids_in_category = select(channel_categories.c.channel_id).where(
channel_categories.c.category_id == category_id
)
query = query.filter(Channel.id.in_(channel_ids_in_category))
channels = query.order_by(Channel.title.asc()).all()
category_map = _category_ids_by_channel(db, [c.id for c in channels])
return [_serialize(c, category_map.get(c.id, [])) for c in channels]
@router.get("/channels/{channel_id}")
def get_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
channel = db.get(Channel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Channel not found")
category_map = _category_ids_by_channel(db, [channel_id])
return _serialize(channel, category_map.get(channel_id, []))
@router.put("/channels/{channel_id}/categories")
def set_channel_categories(channel_id: int, payload: ChannelCategoriesUpdate, db: Session = Depends(get_db)) -> dict:
channel = db.get(Channel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Channel not found")
unique_ids = set(payload.category_ids)
if unique_ids:
found = db.query(Category.id).filter(Category.id.in_(unique_ids)).all()
found_ids = {row[0] for row in found}
missing = unique_ids - found_ids
if missing:
raise HTTPException(status_code=400, detail=f"Unknown category ids: {sorted(missing)}")
db.execute(channel_categories.delete().where(channel_categories.c.channel_id == channel_id))
if unique_ids:
db.execute(
channel_categories.insert(),
[{"channel_id": channel_id, "category_id": cid} for cid in unique_ids],
)
db.commit()
return _serialize(channel, sorted(unique_ids))

87
backend/app/api/feed.py Normal file
View file

@ -0,0 +1,87 @@
import base64
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.auth_dependency import require_session
from app.db import get_db
from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.models.video import Video
from app.services.video_presentation import channel_categories_map, serialize_video
router = APIRouter(dependencies=[Depends(require_session)])
DEFAULT_LIMIT = 30
MAX_LIMIT = 100
def _encode_cursor(published_at: datetime, video_id: int) -> str:
raw = f"{published_at.isoformat()}|{video_id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def _decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
published_at_raw, video_id_raw = raw.rsplit("|", 1)
published_at = datetime.fromisoformat(published_at_raw)
if published_at.tzinfo is None:
published_at = published_at.replace(tzinfo=timezone.utc)
return published_at, int(video_id_raw)
except Exception:
raise HTTPException(status_code=400, detail="Invalid cursor")
@router.get("/feed")
def get_feed(
category_id: int | None = None,
uncategorized: bool = False,
channel_id: int | None = None,
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
cursor: str | None = None,
db: Session = Depends(get_db),
) -> dict:
query = db.query(Video)
if channel_id is not None:
query = query.filter(Video.channel_id == channel_id)
elif uncategorized:
categorized_channel_ids = select(channel_categories.c.channel_id)
query = query.filter(~Video.channel_id.in_(categorized_channel_ids))
elif category_id is not None:
channel_ids_in_category = select(channel_categories.c.channel_id).where(
channel_categories.c.category_id == category_id
)
query = query.filter(Video.channel_id.in_(channel_ids_in_category))
if cursor:
cursor_published_at, cursor_id = _decode_cursor(cursor)
query = query.filter(
(Video.published_at < cursor_published_at)
| ((Video.published_at == cursor_published_at) & (Video.id < cursor_id))
)
query = query.order_by(Video.published_at.desc(), Video.id.desc())
rows = query.limit(limit + 1).all()
next_cursor = None
if len(rows) > limit:
last_kept = rows[limit - 1]
next_cursor = _encode_cursor(last_kept.published_at, last_kept.id)
rows = rows[:limit]
channel_ids = list({v.channel_id for v in rows})
channels = {c.id: c for c in db.query(Channel).filter(Channel.id.in_(channel_ids)).all()}
categories_map = channel_categories_map(db, channel_ids)
items = []
for video in rows:
channel = channels.get(video.channel_id)
if channel is None:
continue
items.append(serialize_video(video, channel, categories_map.get(channel.id, [])))
return {"items": items, "next_cursor": next_cursor}

42
backend/app/api/health.py Normal file
View file

@ -0,0 +1,42 @@
import logging
import httpx
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import settings
from app.db import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
def _check_database(db: Session) -> str:
try:
db.execute(text("SELECT 1"))
return "ok"
except Exception:
logger.exception("Database healthcheck failed")
return "error"
def _check_metube() -> str:
try:
response = httpx.get(settings.metube_api_base_url, timeout=5)
if response.status_code < 500:
return "ok"
return "error"
except Exception:
logger.warning("MeTube healthcheck failed", exc_info=True)
return "error"
@router.get("/health")
def health(db: Session = Depends(get_db)) -> dict:
return {
"status": "ok",
"database": _check_database(db),
"metube": _check_metube(),
}

50
backend/app/api/sync.py Normal file
View file

@ -0,0 +1,50 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.auth_dependency import require_session
from app.db import get_db
from app.services import sync
from app.services.google_oauth import OAuthNotConnected
from app.services.youtube_client import YouTubeAPIError, YouTubeQuotaExceeded
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_session)])
@router.post("/sync/subscriptions")
def sync_subscriptions(db: Session = Depends(get_db)) -> dict:
try:
return sync.sync_subscriptions(db)
except sync.SyncInProgress:
raise HTTPException(status_code=409, detail="Subscriptions sync already in progress")
except OAuthNotConnected:
raise HTTPException(status_code=400, detail="Google account is not connected")
except YouTubeQuotaExceeded:
raise HTTPException(status_code=503, detail="YouTube API quota exhausted")
except YouTubeAPIError as exc:
raise HTTPException(status_code=502, detail=f"YouTube API error: {exc}")
@router.post("/sync/videos")
def sync_videos(db: Session = Depends(get_db)) -> dict:
try:
return sync.sync_videos(db)
except sync.SyncInProgress:
raise HTTPException(status_code=409, detail="Videos sync already in progress")
except OAuthNotConnected:
raise HTTPException(status_code=400, detail="Google account is not connected")
except YouTubeQuotaExceeded:
raise HTTPException(status_code=503, detail="YouTube API quota exhausted")
except YouTubeAPIError as exc:
raise HTTPException(status_code=502, detail=f"YouTube API error: {exc}")
@router.get("/sync/status")
def sync_status(db: Session = Depends(get_db)) -> dict:
return {
"subscriptions": sync.get_subscriptions_sync_status(db),
"videos": sync.get_videos_sync_status(db),
}

24
backend/app/api/videos.py Normal file
View file

@ -0,0 +1,24 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.auth_dependency import require_session
from app.db import get_db
from app.models.channel import Channel
from app.models.video import Video
from app.services.video_presentation import channel_categories_map, serialize_video
router = APIRouter(dependencies=[Depends(require_session)])
@router.get("/videos/{youtube_video_id}")
def get_video(youtube_video_id: str, db: Session = Depends(get_db)) -> dict:
video = db.query(Video).filter(Video.youtube_video_id == youtube_video_id).one_or_none()
if video is None:
raise HTTPException(status_code=404, detail="Video not found")
channel = db.get(Channel, video.channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Channel not found")
categories = channel_categories_map(db, [channel.id]).get(channel.id, [])
return serialize_video(video, channel, categories)

32
backend/app/config.py Normal file
View file

@ -0,0 +1,32 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_env: str = "production"
app_base_url: str = "http://localhost:8080"
app_port: int = 8080
app_secret_key: str
token_encryption_key: str
database_url: str
google_client_id: str = ""
google_client_secret: str = ""
google_redirect_uri: str = ""
allowed_google_email: str = ""
metube_api_base_url: str = "http://127.0.0.1:8081"
metube_public_base_url: str = "http://127.0.0.1:8081"
metube_container_download_dir: str = "/downloads"
metube_request_timeout_seconds: int = 30
subscriptions_sync_interval_hours: int = 6
videos_sync_interval_minutes: int = 60
videos_per_channel_sync: int = 10
log_level: str = "INFO"
settings = Settings()

View file

View file

@ -0,0 +1,6 @@
from fastapi import HTTPException, Request
def require_session(request: Request) -> None:
if not request.session.get("authenticated"):
raise HTTPException(status_code=401, detail="Not authenticated")

View file

@ -0,0 +1,18 @@
from functools import lru_cache
from cryptography.fernet import Fernet
from app.config import settings
@lru_cache
def _fernet() -> Fernet:
return Fernet(settings.token_encryption_key.encode())
def encrypt_token(plaintext: str) -> str:
return _fernet().encrypt(plaintext.encode()).decode()
def decrypt_token(ciphertext: str) -> str:
return _fernet().decrypt(ciphertext.encode()).decode()

View file

@ -0,0 +1,22 @@
import re
_ISO8601_DURATION_RE = re.compile(
r"P(?:(?P<days>\d+)D)?"
r"(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?"
)
def parse_iso8601_duration(value: str | None) -> int | None:
if not value:
return None
match = _ISO8601_DURATION_RE.fullmatch(value)
if not match:
return None
parts = match.groupdict()
if not any(parts.values()):
return None
days = int(parts["days"] or 0)
hours = int(parts["hours"] or 0)
minutes = int(parts["minutes"] or 0)
seconds = int(parts["seconds"] or 0)
return days * 86400 + hours * 3600 + minutes * 60 + seconds

View file

@ -0,0 +1,29 @@
import re
_CYRILLIC_MAP = {
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e",
"ж": "zh", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m",
"н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u",
"ф": "f", "х": "h", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "sch", "ъ": "",
"ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya",
}
def _transliterate(text: str) -> str:
return "".join(_CYRILLIC_MAP.get(ch, ch) for ch in text)
def slugify(text: str) -> str:
text = _transliterate(text.lower())
text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
return text or "category"
def unique_slugify(text: str, existing_slugs: set[str]) -> str:
base = slugify(text)
if base not in existing_slugs:
return base
suffix = 2
while f"{base}-{suffix}" in existing_slugs:
suffix += 1
return f"{base}-{suffix}"

19
backend/app/db.py Normal file
View file

@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db() -> Session:
db = SessionLocal()
try:
yield db
finally:
db.close()

69
backend/app/main.py Normal file
View file

@ -0,0 +1,69 @@
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.sessions import SessionMiddleware
from starlette.types import Scope
class SPAStaticFiles(StaticFiles):
"""Falls back to index.html for unknown paths so client-side routes
(e.g. /categories) work on direct navigation/refresh, not just /."""
async def get_response(self, path: str, scope: Scope):
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code == 404:
return await super().get_response("index.html", scope)
raise
from app.api.auth import router as auth_router
from app.api.categories import router as categories_router
from app.api.channels import router as channels_router
from app.api.feed import router as feed_router
from app.api.health import router as health_router
from app.api.sync import router as sync_router
from app.api.videos import router as videos_router
from app.config import settings
from app.services.scheduler import create_scheduler
logging.basicConfig(level=settings.log_level)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI):
logger.info("Application startup")
scheduler = create_scheduler()
scheduler.start()
yield
scheduler.shutdown(wait=False)
logger.info("Application shutdown")
app = FastAPI(title="MyYouTube", lifespan=lifespan)
app.add_middleware(
SessionMiddleware,
secret_key=settings.app_secret_key,
session_cookie="myyoutube_session",
same_site="lax",
https_only=settings.app_base_url.startswith("https"),
max_age=60 * 60 * 24 * 30,
)
app.include_router(health_router, prefix="/api")
app.include_router(auth_router, prefix="/api")
app.include_router(sync_router, prefix="/api")
app.include_router(channels_router, prefix="/api")
app.include_router(categories_router, prefix="/api")
app.include_router(feed_router, prefix="/api")
app.include_router(videos_router, prefix="/api")
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
if STATIC_DIR.is_dir():
app.mount("/", SPAStaticFiles(directory=STATIC_DIR, html=True), name="static")

View file

@ -0,0 +1,8 @@
from app.models.app_settings import AppSetting
from app.models.category import Category
from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.models.oauth_credentials import OAuthCredentials
from app.models.video import Video
__all__ = ["AppSetting", "Category", "Channel", "channel_categories", "OAuthCredentials", "Video"]

View file

@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class AppSetting(Base):
__tablename__ = "app_settings"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
value: Mapped[str | None] = mapped_column(String, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View file

@ -0,0 +1,19 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View file

@ -0,0 +1,23 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Channel(Base):
__tablename__ = "channels"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
youtube_channel_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
thumbnail_url: Mapped[str | None] = mapped_column(String, nullable=True)
uploads_playlist_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
subscribed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View file

@ -0,0 +1,10 @@
from sqlalchemy import Column, ForeignKey, Integer, Table
from app.db import Base
channel_categories = Table(
"channel_categories",
Base.metadata,
Column("channel_id", Integer, ForeignKey("channels.id"), primary_key=True),
Column("category_id", Integer, ForeignKey("categories.id", ondelete="CASCADE"), primary_key=True),
)

View file

@ -0,0 +1,21 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
SINGLETON_ID = 1
class OAuthCredentials(Base):
__tablename__ = "oauth_credentials"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
google_email: Mapped[str] = mapped_column(String(255), nullable=False)
encrypted_refresh_token: Mapped[str] = mapped_column(String, nullable=False)
access_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View file

@ -0,0 +1,24 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Video(Base):
__tablename__ = "videos"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
youtube_video_id: Mapped[str] = mapped_column(String(32), unique=True, nullable=False, index=True)
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id"), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
thumbnail_url: Mapped[str | None] = mapped_column(String, nullable=True)
published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
youtube_url: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View file

View file

@ -0,0 +1,142 @@
import logging
from datetime import datetime, timezone
import httpx
from google.auth.transport.requests import Request as GoogleAuthRequest
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from sqlalchemy.orm import Session
from app.config import settings
from app.core.crypto import decrypt_token, encrypt_token
from app.models.oauth_credentials import SINGLETON_ID, OAuthCredentials
logger = logging.getLogger(__name__)
SCOPES = [
"https://www.googleapis.com/auth/youtube.readonly",
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
]
AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
TOKEN_URI = "https://oauth2.googleapis.com/token"
USERINFO_URI = "https://www.googleapis.com/oauth2/v3/userinfo"
REVOKE_URI = "https://oauth2.googleapis.com/revoke"
class OAuthNotConnected(Exception):
pass
def _client_config() -> dict:
return {
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": AUTH_URI,
"token_uri": TOKEN_URI,
"redirect_uris": [settings.google_redirect_uri],
}
}
def _build_flow(state: str | None = None) -> Flow:
return Flow.from_client_config(
_client_config(),
scopes=SCOPES,
state=state,
redirect_uri=settings.google_redirect_uri,
)
def build_authorization_url() -> tuple[str, str]:
flow = _build_flow()
auth_url, state = flow.authorization_url(
access_type="offline",
prompt="consent",
include_granted_scopes="true",
)
return auth_url, state
def exchange_code(code: str, state: str) -> Credentials:
flow = _build_flow(state=state)
flow.fetch_token(code=code)
return flow.credentials
def fetch_userinfo(access_token: str) -> dict:
response = httpx.get(
USERINFO_URI,
headers={"Authorization": f"Bearer {access_token}"},
timeout=settings.metube_request_timeout_seconds,
)
response.raise_for_status()
return response.json()
def revoke_token(token: str) -> None:
try:
httpx.post(REVOKE_URI, params={"token": token}, timeout=10)
except Exception:
logger.warning("Failed to revoke Google token", exc_info=True)
def store_credentials(db: Session, google_email: str, credentials: Credentials) -> None:
encrypted = encrypt_token(credentials.refresh_token)
expires_at = credentials.expiry
if expires_at is not None and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
row = db.get(OAuthCredentials, SINGLETON_ID)
if row is None:
row = OAuthCredentials(id=SINGLETON_ID, google_email=google_email, encrypted_refresh_token=encrypted)
db.add(row)
else:
row.google_email = google_email
row.encrypted_refresh_token = encrypted
row.access_token_expires_at = expires_at
db.commit()
def clear_credentials(db: Session) -> None:
row = db.get(OAuthCredentials, SINGLETON_ID)
if row is not None:
db.delete(row)
db.commit()
def is_connected(db: Session) -> bool:
return db.get(OAuthCredentials, SINGLETON_ID) is not None
def get_connected_email(db: Session) -> str | None:
row = db.get(OAuthCredentials, SINGLETON_ID)
return row.google_email if row else None
def get_credentials(db: Session) -> Credentials:
row = db.get(OAuthCredentials, SINGLETON_ID)
if row is None:
raise OAuthNotConnected("Google account is not connected")
refresh_token = decrypt_token(row.encrypted_refresh_token)
credentials = Credentials(
token=None,
refresh_token=refresh_token,
token_uri=TOKEN_URI,
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
scopes=SCOPES,
)
credentials.refresh(GoogleAuthRequest())
expires_at = credentials.expiry
if expires_at is not None and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
row.access_token_expires_at = expires_at
db.commit()
return credentials

View file

@ -0,0 +1,50 @@
import logging
from apscheduler.schedulers.background import BackgroundScheduler
from app.config import settings
from app.db import SessionLocal
from app.services import sync
logger = logging.getLogger(__name__)
def _run_subscriptions_sync() -> None:
db = SessionLocal()
try:
sync.sync_subscriptions(db)
except sync.SyncInProgress:
logger.info("Scheduled subscriptions sync skipped: already running")
except Exception:
logger.exception("Scheduled subscriptions sync failed")
finally:
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:
scheduler = BackgroundScheduler(timezone="UTC")
scheduler.add_job(
_run_subscriptions_sync,
"interval",
hours=settings.subscriptions_sync_interval_hours,
id="subscriptions_sync",
)
scheduler.add_job(
_run_videos_sync,
"interval",
minutes=settings.videos_sync_interval_minutes,
id="videos_sync",
)
return scheduler

View file

@ -0,0 +1,18 @@
from sqlalchemy.orm import Session
from app.models.app_settings import AppSetting
def get_setting(db: Session, key: str) -> str | None:
row = db.query(AppSetting).filter(AppSetting.key == key).one_or_none()
return row.value if row else None
def set_setting(db: Session, key: str, value: str) -> None:
row = db.query(AppSetting).filter(AppSetting.key == key).one_or_none()
if row is None:
row = AppSetting(key=key, value=value)
db.add(row)
else:
row.value = value
db.commit()

View file

@ -0,0 +1,251 @@
import json
import logging
import threading
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.config import settings
from app.core.duration import parse_iso8601_duration
from app.models.channel import Channel
from app.models.video import Video
from app.services import google_oauth, youtube_client
from app.services.state import get_setting, set_setting
logger = logging.getLogger(__name__)
SUBSCRIPTIONS_SYNC_STATUS_KEY = "sync_subscriptions_status"
VIDEOS_SYNC_STATUS_KEY = "sync_videos_status"
_subscriptions_lock = threading.Lock()
_videos_lock = threading.Lock()
class SyncInProgress(Exception):
pass
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_youtube_datetime(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _get_status(db: Session, key: str, lock: threading.Lock) -> dict:
raw = get_setting(db, key)
status = json.loads(raw) if raw else {"status": "never_run"}
status["running"] = lock.locked()
return status
def _save_status(db: Session, key: str, status: dict) -> None:
set_setting(db, key, json.dumps(status))
def get_subscriptions_sync_status(db: Session) -> dict:
return _get_status(db, SUBSCRIPTIONS_SYNC_STATUS_KEY, _subscriptions_lock)
def get_videos_sync_status(db: Session) -> dict:
return _get_status(db, VIDEOS_SYNC_STATUS_KEY, _videos_lock)
def sync_subscriptions(db: Session) -> dict:
if not _subscriptions_lock.acquire(blocking=False):
raise SyncInProgress("Subscriptions sync already in progress")
started_at = _now_iso()
try:
_save_status(
db, SUBSCRIPTIONS_SYNC_STATUS_KEY,
{"status": "running", "started_at": started_at, "finished_at": None, "error": None},
)
credentials = google_oauth.get_credentials(db)
subscriptions = youtube_client.fetch_subscriptions(credentials)
seen_channel_ids = {sub["youtube_channel_id"] for sub in subscriptions}
existing = {c.youtube_channel_id: c for c in db.query(Channel).all()}
added = 0
updated = 0
now = datetime.now(timezone.utc)
for sub in subscriptions:
channel = existing.get(sub["youtube_channel_id"])
if channel is None:
channel = Channel(
youtube_channel_id=sub["youtube_channel_id"],
title=sub["title"],
description=sub["description"],
thumbnail_url=sub["thumbnail_url"],
subscribed=True,
last_synced_at=now,
)
db.add(channel)
existing[sub["youtube_channel_id"]] = channel
added += 1
else:
channel.title = sub["title"]
channel.description = sub["description"]
channel.thumbnail_url = sub["thumbnail_url"]
channel.subscribed = True
channel.last_synced_at = now
updated += 1
unsubscribed = 0
for channel_id, channel in existing.items():
if channel_id not in seen_channel_ids and channel.subscribed:
channel.subscribed = False
unsubscribed += 1
db.commit()
subscribed_ids = [c.youtube_channel_id for c in existing.values() if c.subscribed]
try:
uploads = youtube_client.fetch_uploads_playlists(credentials, subscribed_ids)
for channel_id, uploads_playlist_id in uploads.items():
channel = existing.get(channel_id)
if channel is not None:
channel.uploads_playlist_id = uploads_playlist_id
db.commit()
except Exception:
logger.exception("Failed to fetch uploads playlists during subscriptions sync")
result = {
"status": "completed",
"started_at": started_at,
"finished_at": _now_iso(),
"error": None,
"channels_added": added,
"channels_updated": updated,
"channels_unsubscribed": unsubscribed,
}
_save_status(db, SUBSCRIPTIONS_SYNC_STATUS_KEY, result)
logger.info(
"Subscriptions sync completed: added=%d updated=%d unsubscribed=%d", added, updated, unsubscribed
)
return result
except Exception as exc:
logger.exception("Subscriptions sync failed")
db.rollback()
result = {
"status": "failed",
"started_at": started_at,
"finished_at": _now_iso(),
"error": str(exc),
}
_save_status(db, SUBSCRIPTIONS_SYNC_STATUS_KEY, result)
raise
finally:
_subscriptions_lock.release()
def sync_videos(db: Session) -> dict:
if not _videos_lock.acquire(blocking=False):
raise SyncInProgress("Videos sync already in progress")
started_at = _now_iso()
try:
_save_status(
db, VIDEOS_SYNC_STATUS_KEY,
{"status": "running", "started_at": started_at, "finished_at": None, "error": None},
)
credentials = google_oauth.get_credentials(db)
channels = (
db.query(Channel)
.filter(Channel.subscribed.is_(True), Channel.uploads_playlist_id.isnot(None))
.all()
)
channel_by_youtube_id = {c.youtube_channel_id: c for c in channels}
candidate_video_ids: set[str] = set()
for channel in channels:
try:
video_ids = youtube_client.fetch_playlist_video_ids(
credentials, channel.uploads_playlist_id, settings.videos_per_channel_sync
)
candidate_video_ids.update(video_ids)
except Exception:
logger.exception("Failed to fetch playlist items for channel %s", channel.youtube_channel_id)
details = youtube_client.fetch_videos_details(credentials, list(candidate_video_ids))
existing = {v.youtube_video_id: v for v in db.query(Video).all()}
added = 0
updated = 0
skipped = 0
for item in details:
channel = channel_by_youtube_id.get(item["youtube_channel_id"])
if channel is None:
skipped += 1
continue
published_at = _parse_youtube_datetime(item["published_at"]) if item["published_at"] else None
if published_at is None:
skipped += 1
continue
duration_seconds = parse_iso8601_duration(item["duration_iso8601"])
youtube_url = f"https://www.youtube.com/watch?v={item['youtube_video_id']}"
video = existing.get(item["youtube_video_id"])
if video is None:
video = Video(
youtube_video_id=item["youtube_video_id"],
channel_id=channel.id,
title=item["title"],
description=item["description"],
thumbnail_url=item["thumbnail_url"],
published_at=published_at,
duration_seconds=duration_seconds,
youtube_url=youtube_url,
)
db.add(video)
existing[item["youtube_video_id"]] = video
added += 1
else:
video.channel_id = channel.id
video.title = item["title"]
video.description = item["description"]
video.thumbnail_url = item["thumbnail_url"]
video.published_at = published_at
video.duration_seconds = duration_seconds
video.youtube_url = youtube_url
updated += 1
db.commit()
result = {
"status": "completed",
"started_at": started_at,
"finished_at": _now_iso(),
"error": None,
"videos_added": added,
"videos_updated": updated,
"videos_skipped": skipped,
"channels_checked": len(channels),
}
_save_status(db, VIDEOS_SYNC_STATUS_KEY, result)
logger.info("Videos sync completed: added=%d updated=%d skipped=%d", added, updated, skipped)
return result
except Exception as exc:
logger.exception("Videos sync failed")
db.rollback()
result = {
"status": "failed",
"started_at": started_at,
"finished_at": _now_iso(),
"error": str(exc),
}
_save_status(db, VIDEOS_SYNC_STATUS_KEY, result)
raise
finally:
_videos_lock.release()

View file

@ -0,0 +1,46 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.category import Category
from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.models.video import Video
def channel_categories_map(db: Session, channel_ids: list[int]) -> dict[int, list[dict]]:
if not channel_ids:
return {}
rows = db.execute(
select(channel_categories.c.channel_id, Category.id, Category.name)
.join(Category, Category.id == channel_categories.c.category_id)
.where(channel_categories.c.channel_id.in_(channel_ids))
).all()
result: dict[int, list[dict]] = {}
for channel_id, category_id, category_name in rows:
result.setdefault(channel_id, []).append({"id": category_id, "name": category_name})
return result
def serialize_video(video: Video, channel: Channel, categories: list[dict]) -> dict:
return {
"youtube_video_id": video.youtube_video_id,
"title": video.title,
"description": video.description,
"channel": {
"id": channel.id,
"youtube_channel_id": channel.youtube_channel_id,
"title": channel.title,
"thumbnail_url": channel.thumbnail_url,
},
"thumbnail_url": video.thumbnail_url,
"published_at": video.published_at,
"duration_seconds": video.duration_seconds,
"youtube_url": video.youtube_url,
"categories": categories,
"local": {
"available": False,
"status": "not_downloaded",
"progress_percent": None,
"media_url": None,
},
}

View file

@ -0,0 +1,168 @@
import logging
import httpx
from google.oauth2.credentials import Credentials
from app.config import settings
logger = logging.getLogger(__name__)
API_BASE = "https://www.googleapis.com/youtube/v3"
BATCH_SIZE = 50
class YouTubeQuotaExceeded(Exception):
pass
class YouTubeAPIError(Exception):
pass
def _headers(credentials: Credentials) -> dict:
return {"Authorization": f"Bearer {credentials.token}"}
def _raise_for_status(response: httpx.Response) -> None:
if response.status_code == 200:
return
try:
payload = response.json()
reason = payload.get("error", {}).get("errors", [{}])[0].get("reason", "")
message = payload.get("error", {}).get("message", response.text)
except Exception:
reason = ""
message = response.text
if response.status_code == 403 and reason in ("quotaExceeded", "dailyLimitExceeded", "rateLimitExceeded"):
raise YouTubeQuotaExceeded(message)
logger.error("YouTube API error %s: %s", response.status_code, message)
raise YouTubeAPIError(f"{response.status_code}: {message}")
def fetch_subscriptions(credentials: Credentials) -> list[dict]:
subscriptions: list[dict] = []
page_token: str | None = None
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
while True:
params = {
"part": "snippet,contentDetails",
"mine": "true",
"maxResults": 50,
}
if page_token:
params["pageToken"] = page_token
response = client.get(f"{API_BASE}/subscriptions", params=params, headers=_headers(credentials))
_raise_for_status(response)
data = response.json()
for item in data.get("items", []):
snippet = item.get("snippet", {})
resource_id = snippet.get("resourceId", {})
channel_id = resource_id.get("channelId")
if not channel_id:
continue
thumbnails = snippet.get("thumbnails", {})
thumbnail = (
thumbnails.get("high") or thumbnails.get("medium") or thumbnails.get("default") or {}
).get("url")
subscriptions.append(
{
"youtube_channel_id": channel_id,
"title": snippet.get("title", ""),
"description": snippet.get("description", ""),
"thumbnail_url": thumbnail,
}
)
page_token = data.get("nextPageToken")
if not page_token:
break
return subscriptions
def fetch_playlist_video_ids(credentials: Credentials, playlist_id: str, max_results: int) -> list[str]:
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
params = {
"part": "contentDetails",
"playlistId": playlist_id,
"maxResults": min(max_results, 50),
}
response = client.get(f"{API_BASE}/playlistItems", params=params, headers=_headers(credentials))
if response.status_code == 404:
return []
_raise_for_status(response)
data = response.json()
video_ids = []
for item in data.get("items", []):
video_id = item.get("contentDetails", {}).get("videoId")
if video_id:
video_ids.append(video_id)
return video_ids
def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list[dict]:
results: list[dict] = []
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
for i in range(0, len(video_ids), BATCH_SIZE):
batch = video_ids[i : i + BATCH_SIZE]
params = {
"part": "snippet,contentDetails,status",
"id": ",".join(batch),
"maxResults": BATCH_SIZE,
}
response = client.get(f"{API_BASE}/videos", params=params, headers=_headers(credentials))
_raise_for_status(response)
data = response.json()
for item in data.get("items", []):
snippet = item.get("snippet", {})
thumbnails = snippet.get("thumbnails", {})
thumbnail = (
thumbnails.get("high") or thumbnails.get("medium") or thumbnails.get("default") or {}
).get("url")
results.append(
{
"youtube_video_id": item.get("id"),
"youtube_channel_id": snippet.get("channelId"),
"title": snippet.get("title", ""),
"description": snippet.get("description", ""),
"thumbnail_url": thumbnail,
"published_at": snippet.get("publishedAt"),
"duration_iso8601": item.get("contentDetails", {}).get("duration"),
}
)
return results
def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) -> dict[str, str]:
result: dict[str, str] = {}
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
for i in range(0, len(channel_ids), BATCH_SIZE):
batch = channel_ids[i : i + BATCH_SIZE]
params = {
"part": "snippet,contentDetails",
"id": ",".join(batch),
"maxResults": BATCH_SIZE,
}
response = client.get(f"{API_BASE}/channels", params=params, headers=_headers(credentials))
_raise_for_status(response)
data = response.json()
for item in data.get("items", []):
channel_id = item.get("id")
uploads = (
item.get("contentDetails", {}).get("relatedPlaylists", {}).get("uploads")
)
if channel_id and uploads:
result[channel_id] = uploads
return result

View file

@ -0,0 +1,2 @@
-r requirements.txt
pytest==8.3.4

12
backend/requirements.txt Normal file
View file

@ -0,0 +1,12 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
alembic==1.14.0
psycopg[binary]==3.2.3
pydantic-settings==2.7.0
httpx==0.28.1
itsdangerous==2.2.0
cryptography==44.0.0
google-auth==2.37.0
google-auth-oauthlib==1.2.1
apscheduler==3.11.0

35
compose.yml Normal file
View file

@ -0,0 +1,35 @@
services:
app:
build: .
restart: unless-stopped
env_file:
- .env
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: youtube_app
POSTGRES_USER: youtube_app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U youtube_app -d youtube_app"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:

6
entrypoint.sh Normal file
View file

@ -0,0 +1,6 @@
#!/bin/sh
set -e
alembic upgrade head
exec uvicorn app.main:app --host 0.0.0.0 --port "${APP_PORT:-8080}" --proxy-headers --forwarded-allow-ips="*"

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
frontend/README.md Normal file
View file

@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1380
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
frontend/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.103.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 4.9 KiB

404
frontend/src/App.css Normal file
View file

@ -0,0 +1,404 @@
.app {
max-width: 960px;
margin: 0 auto;
padding: 24px;
text-align: left;
}
.error {
color: #d33;
}
.connect-screen {
text-align: center;
padding-top: 80px;
}
.connect-screen button {
font-size: 16px;
padding: 10px 24px;
cursor: pointer;
}
.channels-screen header,
.feed-screen header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.channels-screen .actions,
.feed-screen .actions {
display: flex;
gap: 8px;
align-items: center;
}
.channels-screen .actions a,
.feed-screen .actions a {
align-self: center;
}
.channels-screen input[type='text'] {
width: 100%;
padding: 8px;
margin-bottom: 16px;
box-sizing: border-box;
}
.layout {
display: flex;
gap: 20px;
align-items: flex-start;
}
.category-nav {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 160px;
flex-shrink: 0;
}
.category-nav button {
text-align: left;
padding: 6px 10px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 6px;
}
.category-nav button.active {
background: var(--accent-bg, #eee);
font-weight: 600;
}
.content {
flex: 1;
min-width: 0;
}
.channel-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.channel-card {
display: flex;
align-items: flex-start;
gap: 10px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 8px;
}
.channel-card img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.channel-info {
flex: 1;
min-width: 0;
}
.channel-title {
font-weight: 500;
color: inherit;
text-decoration: none;
display: block;
}
.channel-title:hover {
text-decoration: underline;
}
.badge {
font-size: 12px;
color: #999;
}
.channel-categories {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
align-items: center;
}
.chip {
font-size: 12px;
background: #eee;
border-radius: 12px;
padding: 2px 8px;
}
.chip-edit {
font-size: 12px;
padding: 2px 8px;
border-radius: 12px;
border: 1px dashed #aaa;
background: transparent;
cursor: pointer;
}
.category-popover-wrapper {
position: relative;
display: inline-block;
}
.category-popover {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 10;
background: #fff;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
min-width: 160px;
max-height: 240px;
overflow-y: auto;
}
.category-popover-empty {
color: #999;
white-space: nowrap;
}
.category-checkbox {
display: flex;
align-items: center;
gap: 6px;
}
.categories-screen {
max-width: 600px;
}
.back-link {
display: inline-block;
margin-bottom: 8px;
}
.category-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.category-form input {
flex: 1;
padding: 8px;
}
.category-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.category-row {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 8px 12px;
}
.reorder-buttons {
display: flex;
flex-direction: column;
}
.reorder-buttons button {
border: none;
background: transparent;
cursor: pointer;
line-height: 1;
}
.category-name {
flex: 1;
font-weight: 500;
}
.category-count {
font-size: 12px;
color: #999;
}
.category-edit-form {
flex: 1;
display: flex;
gap: 8px;
}
.video-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.video-card {
display: flex;
flex-direction: column;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.video-thumbnail {
position: relative;
display: block;
aspect-ratio: 16 / 9;
background: #eee;
}
.video-thumbnail img,
.video-thumbnail-placeholder {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.video-thumbnail-placeholder {
background: #ddd;
}
.duration-badge {
position: absolute;
right: 6px;
bottom: 6px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
font-size: 12px;
padding: 1px 5px;
border-radius: 3px;
}
.video-info {
padding: 8px 10px;
}
.video-title {
font-weight: 500;
font-size: 14px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
color: inherit;
text-decoration: none;
}
.video-title:hover {
text-decoration: underline;
}
.video-meta {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.video-actions {
margin-top: 8px;
}
.button-link {
display: inline-block;
font-size: 13px;
padding: 4px 10px;
border: 1px solid #ccc;
border-radius: 6px;
text-decoration: none;
color: inherit;
}
.load-more {
display: block;
margin: 16px auto 0;
padding: 8px 20px;
cursor: pointer;
}
.video-page {
max-width: 900px;
}
.player-wrapper {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;
}
.player-wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
.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;
}

55
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query'
import { Route, Routes } from 'react-router-dom'
import { getAuthStatus } from './api/client'
import Connect from './pages/Connect'
import Feed from './pages/Feed'
import Channels from './pages/Channels'
import ChannelVideos from './pages/ChannelVideos'
import VideoPage from './pages/VideoPage'
import Categories from './pages/Categories'
import './App.css'
function App() {
const { data, isLoading, isError } = useQuery({
queryKey: ['auth-status'],
queryFn: getAuthStatus,
})
if (isLoading) {
return (
<div className="app">
<p>Загрузка...</p>
</div>
)
}
if (isError) {
return (
<div className="app">
<p className="error">Backend недоступен</p>
</div>
)
}
if (!data?.authenticated) {
return (
<div className="app">
<Connect />
</div>
)
}
return (
<div className="app">
<Routes>
<Route path="/" element={<Feed />} />
<Route path="/channels" element={<Channels />} />
<Route path="/channels/:channelId/videos" element={<ChannelVideos />} />
<Route path="/video/:youtubeVideoId" element={<VideoPage />} />
<Route path="/categories" element={<Categories />} />
</Routes>
</div>
)
}
export default App

181
frontend/src/api/client.ts Normal file
View file

@ -0,0 +1,181 @@
const AUTH_ERROR_MESSAGES: Record<string, string> = {
google_error: 'Google вернул ошибку авторизации',
invalid_state: 'Сессия авторизации устарела, попробуйте снова',
exchange_failed: 'Не удалось обменять код авторизации',
userinfo_failed: 'Не удалось получить данные аккаунта Google',
account_not_allowed: 'Этот Google-аккаунт не разрешён для входа',
}
export function authErrorMessage(code: string): string {
return AUTH_ERROR_MESSAGES[code] ?? 'Неизвестная ошибка авторизации'
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) },
...options,
})
if (!res.ok) {
let detail = `${res.status}`
try {
const body = await res.json()
detail = body.detail ?? detail
} catch {
// ignore
}
throw new Error(detail)
}
return res.json()
}
export interface AuthStatus {
authenticated: boolean
connected: boolean
email: string | null
}
export function getAuthStatus() {
return request<AuthStatus>('/api/auth/status')
}
export function startGoogleAuth() {
window.location.href = '/api/auth/google/start'
}
export function logout() {
return request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' })
}
export interface ChannelDto {
id: number
youtube_channel_id: string
title: string
description: string | null
thumbnail_url: string | null
uploads_playlist_id: string | null
subscribed: boolean
last_synced_at: string | null
category_ids: number[]
}
export function getChannel(channelId: number) {
return request<ChannelDto>(`/api/channels/${channelId}`)
}
export function listChannels(
params: { search?: string; categoryId?: number; uncategorized?: boolean } = {},
) {
const qs = new URLSearchParams()
if (params.search) qs.set('search', params.search)
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
if (params.uncategorized) qs.set('uncategorized', 'true')
const suffix = qs.toString() ? `?${qs.toString()}` : ''
return request<ChannelDto[]>(`/api/channels${suffix}`)
}
export function setChannelCategories(channelId: number, categoryIds: number[]) {
return request<ChannelDto>(`/api/channels/${channelId}/categories`, {
method: 'PUT',
body: JSON.stringify({ category_ids: categoryIds }),
})
}
export interface CategoryDto {
id: number
name: string
slug: string
sort_order: number
channel_count: number
}
export function listCategories() {
return request<CategoryDto[]>('/api/categories')
}
export function createCategory(name: string) {
return request<CategoryDto>('/api/categories', {
method: 'POST',
body: JSON.stringify({ name }),
})
}
export function updateCategory(id: number, name: string) {
return request<CategoryDto>(`/api/categories/${id}`, {
method: 'PATCH',
body: JSON.stringify({ name }),
})
}
export function deleteCategory(id: number) {
return fetch(`/api/categories/${id}`, { method: 'DELETE', credentials: 'include' }).then((res) => {
if (!res.ok) throw new Error(`${res.status}`)
})
}
export function reorderCategories(categoryIds: number[]) {
return request<CategoryDto[]>('/api/categories/reorder', {
method: 'POST',
body: JSON.stringify({ category_ids: categoryIds }),
})
}
export interface SyncStatus {
status: string
started_at?: string
finished_at?: string | null
error?: string | null
running: boolean
[key: string]: unknown
}
export function getSyncStatus() {
return request<{ subscriptions: SyncStatus; videos: SyncStatus }>('/api/sync/status')
}
export function syncSubscriptions() {
return request<SyncStatus>('/api/sync/subscriptions', { method: 'POST' })
}
export function syncVideos() {
return request<SyncStatus>('/api/sync/videos', { method: 'POST' })
}
export interface FeedVideoDto {
youtube_video_id: string
title: string
description: string | null
channel: {
id: number
youtube_channel_id: string
title: string
thumbnail_url: string | null
}
thumbnail_url: string | null
published_at: string
duration_seconds: number | null
youtube_url: string
categories: { id: number; name: string }[]
local: {
available: boolean
status: string
progress_percent: number | null
media_url: string | null
}
}
export function getVideo(youtubeVideoId: string) {
return request<FeedVideoDto>(`/api/videos/${youtubeVideoId}`)
}
export function getFeed(
params: { categoryId?: number; uncategorized?: boolean; channelId?: number; cursor?: string } = {},
) {
const qs = new URLSearchParams()
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
if (params.uncategorized) qs.set('uncategorized', 'true')
if (params.channelId != null) qs.set('channel_id', String(params.channelId))
if (params.cursor) qs.set('cursor', params.cursor)
const suffix = qs.toString() ? `?${qs.toString()}` : ''
return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

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

After

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,33 @@
import type { CategoryDto } from '../api/client'
export type CategoryFilter = 'all' | 'uncategorized' | number
interface Props {
categories: CategoryDto[]
value: CategoryFilter
onChange: (filter: CategoryFilter) => void
}
function CategoryNav({ categories, value, onChange }: Props) {
return (
<nav className="category-nav">
<button className={value === 'all' ? 'active' : ''} onClick={() => onChange('all')}>
Все
</button>
<button className={value === 'uncategorized' ? 'active' : ''} onClick={() => onChange('uncategorized')}>
Без категории
</button>
{categories.map((category) => (
<button
key={category.id}
className={value === category.id ? 'active' : ''}
onClick={() => onChange(category.id)}
>
{category.name}
</button>
))}
</nav>
)
}
export default CategoryNav

View file

@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import type { CategoryDto, ChannelDto } from '../api/client'
import { setChannelCategories } from '../api/client'
interface Props {
channel: ChannelDto
categories: CategoryDto[]
}
function ChannelCard({ channel, categories }: Props) {
const [open, setOpen] = useState(false)
const popoverRef = useRef<HTMLDivElement>(null)
const queryClient = useQueryClient()
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
const mutation = useMutation({
mutationFn: (categoryIds: number[]) => setChannelCategories(channel.id, categoryIds),
onMutate: async (categoryIds: number[]) => {
await queryClient.cancelQueries({ queryKey: ['channels'] })
const previous = queryClient.getQueriesData<ChannelDto[]>({ queryKey: ['channels'] })
queryClient.setQueriesData<ChannelDto[]>({ queryKey: ['channels'] }, (old) =>
old?.map((c) => (c.id === channel.id ? { ...c, category_ids: categoryIds } : c)),
)
return { previous }
},
onError: (_err, _vars, context) => {
context?.previous.forEach(([key, data]) => queryClient.setQueryData(key, data))
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['categories'] })
},
})
function toggleCategory(categoryId: number) {
const current = new Set(channel.category_ids)
if (current.has(categoryId)) {
current.delete(categoryId)
} else {
current.add(categoryId)
}
mutation.mutate(Array.from(current))
}
const assignedNames = categories.filter((c) => channel.category_ids.includes(c.id)).map((c) => c.name)
return (
<li className="channel-card">
{channel.thumbnail_url && <img src={channel.thumbnail_url} alt={channel.title} />}
<div className="channel-info">
<Link to={`/channels/${channel.id}/videos`} className="channel-title">
{channel.title}
</Link>
{!channel.subscribed && <span className="badge">отписан</span>}
<div className="channel-categories">
{assignedNames.map((name) => (
<span key={name} className="chip">
{name}
</span>
))}
<div className="category-popover-wrapper" ref={popoverRef}>
<button className="chip-edit" onClick={() => setOpen((v) => !v)}>
+ Категория
</button>
{open && (
<div className="category-popover">
{categories.length === 0 && <div className="category-popover-empty">Сначала создай категорию</div>}
{categories.map((category) => (
<label key={category.id} className="category-checkbox">
<input
type="checkbox"
checked={channel.category_ids.includes(category.id)}
onChange={() => toggleCategory(category.id)}
/>
{category.name}
</label>
))}
</div>
)}
</div>
</div>
</div>
</li>
)
}
export default ChannelCard

View file

@ -0,0 +1,40 @@
import { Link } from 'react-router-dom'
import type { FeedVideoDto } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format'
interface Props {
video: FeedVideoDto
}
function VideoCard({ video }: Props) {
const duration = formatDuration(video.duration_seconds)
const href = `/video/${video.youtube_video_id}`
return (
<li className="video-card">
<Link to={href} className="video-thumbnail">
{video.thumbnail_url ? (
<img src={video.thumbnail_url} alt={video.title} />
) : (
<div className="video-thumbnail-placeholder" />
)}
{duration && <span className="duration-badge">{duration}</span>}
</Link>
<div className="video-info">
<Link to={href} className="video-title" title={video.title}>
{video.title}
</Link>
<div className="video-meta">
{video.channel.title} · {formatRelativeTime(video.published_at)}
</div>
<div className="video-actions">
<Link to={href} className="button-link">
Смотреть
</Link>
</div>
</div>
</li>
)
}
export default VideoCard

111
frontend/src/index.css Normal file
View file

@ -0,0 +1,111 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}

26
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,26 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)

View file

@ -0,0 +1,145 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
createCategory,
deleteCategory,
listCategories,
reorderCategories,
updateCategory,
} from '../api/client'
function Categories() {
const queryClient = useQueryClient()
const [newName, setNewName] = useState('')
const [editingId, setEditingId] = useState<number | null>(null)
const [editingName, setEditingName] = useState('')
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['categories'] })
queryClient.invalidateQueries({ queryKey: ['channels'] })
}
const createMutation = useMutation({
mutationFn: (name: string) => createCategory(name),
onSuccess: () => {
setNewName('')
invalidate()
},
})
const updateMutation = useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name),
onSuccess: () => {
setEditingId(null)
invalidate()
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) => deleteCategory(id),
onSuccess: invalidate,
})
const reorderMutation = useMutation({
mutationFn: (ids: number[]) => reorderCategories(ids),
onSuccess: invalidate,
})
const categories = categoriesQuery.data ?? []
function move(index: number, direction: -1 | 1) {
const target = index + direction
if (target < 0 || target >= categories.length) return
const ids = categories.map((c) => c.id)
;[ids[index], ids[target]] = [ids[target], ids[index]]
reorderMutation.mutate(ids)
}
return (
<div className="categories-screen">
<Link to="/" className="back-link">
← Каналы
</Link>
<h1>Категории</h1>
<form
onSubmit={(e) => {
e.preventDefault()
if (newName.trim()) createMutation.mutate(newName.trim())
}}
className="category-form"
>
<input
type="text"
placeholder="Новая категория"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<button type="submit" disabled={!newName.trim() || createMutation.isPending}>
Создать
</button>
</form>
{createMutation.isError && <p className="error">{(createMutation.error as Error).message}</p>}
<ul className="category-list">
{categories.map((category, index) => (
<li key={category.id} className="category-row">
<div className="reorder-buttons">
<button onClick={() => move(index, -1)} disabled={index === 0} aria-label="Выше">
↑
</button>
<button onClick={() => move(index, 1)} disabled={index === categories.length - 1} aria-label="Ниже">
↓
</button>
</div>
{editingId === category.id ? (
<form
className="category-edit-form"
onSubmit={(e) => {
e.preventDefault()
if (editingName.trim()) updateMutation.mutate({ id: category.id, name: editingName.trim() })
}}
>
<input value={editingName} onChange={(e) => setEditingName(e.target.value)} autoFocus />
<button type="submit">Сохранить</button>
<button type="button" onClick={() => setEditingId(null)}>
Отмена
</button>
</form>
) : (
<>
<span className="category-name">{category.name}</span>
<span className="category-count">{category.channel_count} канал(ов)</span>
<button
onClick={() => {
setEditingId(category.id)
setEditingName(category.name)
}}
>
Переименовать
</button>
<button
onClick={() => {
if (confirm(`Удалить категорию "${category.name}"? Каналы останутся.`)) {
deleteMutation.mutate(category.id)
}
}}
>
Удалить
</button>
</>
)}
</li>
))}
</ul>
{categories.length === 0 && !categoriesQuery.isLoading && <p>Категорий пока нет.</p>}
</div>
)
}
export default Categories

View file

@ -0,0 +1,56 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { Link, useParams } from 'react-router-dom'
import { getChannel, getFeed } from '../api/client'
import VideoCard from '../components/VideoCard'
function ChannelVideos() {
const { channelId } = useParams<{ channelId: string }>()
const id = Number(channelId)
const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id) })
const feedQuery = useInfiniteQuery({
queryKey: ['feed', 'channel', id],
queryFn: ({ pageParam }) => getFeed({ channelId: id, cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
})
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
return (
<div className="feed-screen">
<header>
<div>
<Link to="/channels" className="back-link">
← Каналы
</Link>
<h1>{channelQuery.data?.title ?? 'Канал'}</h1>
</div>
</header>
{feedQuery.isLoading && <p>Загрузка...</p>}
{feedQuery.isError && <p className="error">Не удалось загрузить видео</p>}
<ul className="video-grid">
{items.map((video) => (
<VideoCard key={video.youtube_video_id} video={video} />
))}
</ul>
{!feedQuery.isLoading && items.length === 0 && <p>У этого канала пока нет синхронизированных видео.</p>}
{feedQuery.hasNextPage && (
<button
className="load-more"
onClick={() => feedQuery.fetchNextPage()}
disabled={feedQuery.isFetchingNextPage}
>
{feedQuery.isFetchingNextPage ? 'Загрузка...' : 'Показать ещё'}
</button>
)}
</div>
)
}
export default ChannelVideos

View file

@ -0,0 +1,106 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { getSyncStatus, listCategories, listChannels, logout, syncSubscriptions } from '../api/client'
import ChannelCard from '../components/ChannelCard'
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
function Channels() {
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<CategoryFilter>('all')
const queryClient = useQueryClient()
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const channelsQuery = useQuery({
queryKey: ['channels', filter],
queryFn: () =>
listChannels({
categoryId: typeof filter === 'number' ? filter : undefined,
uncategorized: filter === 'uncategorized',
}),
})
const filteredChannels = useMemo(() => {
const data = channelsQuery.data ?? []
if (!search.trim()) return data
const needle = search.trim().toLowerCase()
return data.filter((c) => c.title.toLowerCase().includes(needle))
}, [channelsQuery.data, search])
const syncStatusQuery = useQuery({
queryKey: ['sync-status'],
queryFn: getSyncStatus,
refetchInterval: (query) => (query.state.data?.subscriptions.running ? 1500 : false),
})
const syncMutation = useMutation({
mutationFn: syncSubscriptions,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['sync-status'] })
},
})
const logoutMutation = useMutation({
mutationFn: logout,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
},
})
const running = syncStatusQuery.data?.subscriptions.running
const categories = categoriesQuery.data ?? []
return (
<div className="channels-screen">
<header>
<h1>Каналы</h1>
<div className="actions">
<Link to="/">Лента</Link>
<Link to="/categories">Категории</Link>
<button onClick={() => syncMutation.mutate()} disabled={running}>
{running ? 'Синхронизация...' : 'Обновить сейчас'}
</button>
<button onClick={() => logoutMutation.mutate()}>Выйти</button>
</div>
</header>
{syncMutation.isError && <p className="error">{(syncMutation.error as Error).message}</p>}
{syncStatusQuery.data?.subscriptions.status === 'failed' && (
<p className="error">Последняя синхронизация завершилась ошибкой: {syncStatusQuery.data.subscriptions.error}</p>
)}
<div className="layout">
<CategoryNav categories={categories} value={filter} onChange={setFilter} />
<div className="content">
<input
type="text"
placeholder="Поиск по названию канала"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{channelsQuery.isLoading && <p>Загрузка...</p>}
{channelsQuery.isError && <p className="error">Не удалось загрузить каналы</p>}
<ul className="channel-list">
{filteredChannels.map((channel) => (
<ChannelCard key={channel.id} channel={channel} categories={categories} />
))}
</ul>
{channelsQuery.data && channelsQuery.data.length === 0 && (
<p>Каналы не найдены{filter === 'all' ? '. Нажми "Обновить сейчас".' : ' в этой категории.'}</p>
)}
{channelsQuery.data && channelsQuery.data.length > 0 && filteredChannels.length === 0 && (
<p>Ничего не найдено по запросу "{search}".</p>
)}
</div>
</div>
</div>
)
}
export default Channels

View file

@ -0,0 +1,18 @@
import { useSearchParams } from 'react-router-dom'
import { authErrorMessage, startGoogleAuth } from '../api/client'
function Connect() {
const [searchParams] = useSearchParams()
const authError = searchParams.get('auth_error')
return (
<div className="connect-screen">
<h1>MyYouTube</h1>
<p>Персональная лента YouTube-подписок с категориями.</p>
{authError && <p className="error">{authErrorMessage(authError)}</p>}
<button onClick={startGoogleAuth}>Подключить YouTube</button>
</div>
)
}
export default Connect

102
frontend/src/pages/Feed.tsx Normal file
View file

@ -0,0 +1,102 @@
import { useMutation, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { getFeed, getSyncStatus, listCategories, logout, syncVideos } from '../api/client'
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
import VideoCard from '../components/VideoCard'
import { useState } from 'react'
function Feed() {
const [filter, setFilter] = useState<CategoryFilter>('all')
const queryClient = useQueryClient()
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const categories = categoriesQuery.data ?? []
const feedQuery = useInfiniteQuery({
queryKey: ['feed', filter],
queryFn: ({ pageParam }) =>
getFeed({
categoryId: typeof filter === 'number' ? filter : undefined,
uncategorized: filter === 'uncategorized',
cursor: pageParam,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
})
const syncStatusQuery = useQuery({
queryKey: ['sync-status'],
queryFn: getSyncStatus,
refetchInterval: (query) => (query.state.data?.videos.running ? 1500 : false),
})
const syncMutation = useMutation({
mutationFn: syncVideos,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feed'] })
queryClient.invalidateQueries({ queryKey: ['sync-status'] })
},
})
const logoutMutation = useMutation({
mutationFn: logout,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
},
})
const running = syncStatusQuery.data?.videos.running
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
return (
<div className="feed-screen">
<header>
<h1>Лента</h1>
<div className="actions">
<Link to="/channels">Каналы</Link>
<Link to="/categories">Категории</Link>
<button onClick={() => syncMutation.mutate()} disabled={running}>
{running ? 'Синхронизация...' : 'Обновить сейчас'}
</button>
<button onClick={() => logoutMutation.mutate()}>Выйти</button>
</div>
</header>
{syncMutation.isError && <p className="error">{(syncMutation.error as Error).message}</p>}
{syncStatusQuery.data?.videos.status === 'failed' && (
<p className="error">Последняя синхронизация видео завершилась ошибкой: {syncStatusQuery.data.videos.error}</p>
)}
<div className="layout">
<CategoryNav categories={categories} value={filter} onChange={setFilter} />
<div className="content">
{feedQuery.isLoading && <p>Загрузка...</p>}
{feedQuery.isError && <p className="error">Не удалось загрузить ленту</p>}
<ul className="video-grid">
{items.map((video) => (
<VideoCard key={video.youtube_video_id} video={video} />
))}
</ul>
{!feedQuery.isLoading && items.length === 0 && (
<p>Видео пока нет. Нажми "Обновить сейчас" или проверь, есть ли каналы в этой категории.</p>
)}
{feedQuery.hasNextPage && (
<button
className="load-more"
onClick={() => feedQuery.fetchNextPage()}
disabled={feedQuery.isFetchingNextPage}
>
{feedQuery.isFetchingNextPage ? 'Загрузка...' : 'Показать ещё'}
</button>
)}
</div>
</div>
</div>
)
}
export default Feed

View file

@ -0,0 +1,78 @@
import { useQuery } from '@tanstack/react-query'
import { Link, useParams } from 'react-router-dom'
import { getVideo } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format'
function VideoPage() {
const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>()
const videoQuery = useQuery({
queryKey: ['video', youtubeVideoId],
queryFn: () => getVideo(youtubeVideoId!),
enabled: !!youtubeVideoId,
})
if (videoQuery.isLoading) {
return (
<div className="video-page">
<p>Загрузка...</p>
</div>
)
}
if (videoQuery.isError || !videoQuery.data) {
return (
<div className="video-page">
<Link to="/" className="back-link">
← Лента
</Link>
<p className="error">Видео не найдено</p>
</div>
)
}
const video = videoQuery.data
const duration = formatDuration(video.duration_seconds)
return (
<div className="video-page">
<Link to="/" className="back-link">
← Лента
</Link>
<div className="player-wrapper">
<iframe
src={`https://www.youtube-nocookie.com/embed/${video.youtube_video_id}`}
title={video.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
<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}`}
</div>
<div className="video-page-source">Источник: YouTube</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

View file

@ -0,0 +1,35 @@
export function formatDuration(seconds: number | null): string | null {
if (seconds == null) return null
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
return `${m}:${String(s).padStart(2, '0')}`
}
const RTF = new Intl.RelativeTimeFormat('ru', { numeric: 'auto' })
export function formatRelativeTime(iso: string): string {
const date = new Date(iso)
const diffMs = date.getTime() - Date.now()
const diffSeconds = Math.round(diffMs / 1000)
const absSeconds = Math.abs(diffSeconds)
const units: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 365 * 24 * 3600],
['month', 30 * 24 * 3600],
['week', 7 * 24 * 3600],
['day', 24 * 3600],
['hour', 3600],
['minute', 60],
]
for (const [unit, secondsInUnit] of units) {
if (absSeconds >= secondsInUnit) {
return RTF.format(Math.round(diffSeconds / secondsInUnit), unit)
}
}
return RTF.format(diffSeconds, 'second')
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

15
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,15 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})

50
migrations/env.py Normal file
View file

@ -0,0 +1,50 @@
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import engine_from_config, pool
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend"))
from app.config import settings # noqa: E402
from app.db import Base # noqa: E402
from app import models # noqa: E402,F401
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

26
migrations/script.py.mako Normal file
View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,36 @@
"""app_settings baseline
Revision ID: 0001_app_settings
Revises:
Create Date: 2026-09-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0001_app_settings"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"app_settings",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("key", sa.String(length=255), nullable=False),
sa.Column("value", sa.String(), nullable=True),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint("key", name="uq_app_settings_key"),
)
def downgrade() -> None:
op.drop_table("app_settings")

View file

@ -0,0 +1,50 @@
"""oauth_credentials and channels
Revision ID: 0002_oauth_channels
Revises: 0001_app_settings
Create Date: 2026-09-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0002_oauth_channels"
down_revision: Union[str, None] = "0001_app_settings"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"oauth_credentials",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("google_email", sa.String(length=255), nullable=False),
sa.Column("encrypted_refresh_token", sa.String(), nullable=False),
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"channels",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("youtube_channel_id", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("thumbnail_url", sa.String(), nullable=True),
sa.Column("uploads_playlist_id", sa.String(length=64), nullable=True),
sa.Column("subscribed", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("youtube_channel_id", name="uq_channels_youtube_channel_id"),
)
op.create_index("ix_channels_youtube_channel_id", "channels", ["youtube_channel_id"])
def downgrade() -> None:
op.drop_index("ix_channels_youtube_channel_id", table_name="channels")
op.drop_table("channels")
op.drop_table("oauth_credentials")

View file

@ -0,0 +1,46 @@
"""categories and channel_categories
Revision ID: 0003_categories
Revises: 0002_oauth_channels
Create Date: 2026-09-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0003_categories"
down_revision: Union[str, None] = "0002_oauth_channels"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"categories",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("slug", sa.String(length=255), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("name", name="uq_categories_name"),
sa.UniqueConstraint("slug", name="uq_categories_slug"),
)
op.create_table(
"channel_categories",
sa.Column("channel_id", sa.Integer(), sa.ForeignKey("channels.id"), primary_key=True),
sa.Column(
"category_id",
sa.Integer(),
sa.ForeignKey("categories.id", ondelete="CASCADE"),
primary_key=True,
),
)
def downgrade() -> None:
op.drop_table("channel_categories")
op.drop_table("categories")

View file

@ -0,0 +1,44 @@
"""videos
Revision ID: 0004_videos
Revises: 0003_categories
Create Date: 2026-09-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0004_videos"
down_revision: Union[str, None] = "0003_categories"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"videos",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("youtube_video_id", sa.String(length=32), nullable=False),
sa.Column("channel_id", sa.Integer(), sa.ForeignKey("channels.id"), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("thumbnail_url", sa.String(), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("duration_seconds", sa.Integer(), nullable=True),
sa.Column("youtube_url", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("youtube_video_id", name="uq_videos_youtube_video_id"),
)
op.create_index("ix_videos_youtube_video_id", "videos", ["youtube_video_id"])
op.create_index("ix_videos_channel_id", "videos", ["channel_id"])
op.create_index("ix_videos_published_at", "videos", ["published_at"])
def downgrade() -> None:
op.drop_index("ix_videos_published_at", table_name="videos")
op.drop_index("ix_videos_channel_id", table_name="videos")
op.drop_index("ix_videos_youtube_video_id", table_name="videos")
op.drop_table("videos")

3
pyproject.toml Normal file
View file

@ -0,0 +1,3 @@
[tool.pytest.ini_options]
pythonpath = ["backend"]
testpaths = ["tests"]

39
tests/conftest.py Normal file
View file

@ -0,0 +1,39 @@
import os
os.environ.setdefault("APP_BASE_URL", "http://testserver")
os.environ.setdefault("APP_SECRET_KEY", "test-secret")
os.environ.setdefault("TOKEN_ENCRYPTION_KEY", "y-KaYJS2O-CziGFYfjtMObHX73xMI9FwFipMnFpHKAY=")
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test")
os.environ.setdefault("ALLOWED_GOOGLE_EMAIL", "owner@example.com")
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.db import Base
from app import models # noqa: F401
@pytest.fixture
def db_session():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
@event.listens_for(engine, "connect")
def _enable_sqlite_fk(dbapi_connection, _):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
session = session_factory()
try:
yield session
finally:
session.close()
engine.dispose()

79
tests/test_auth.py Normal file
View file

@ -0,0 +1,79 @@
import pytest
from fastapi.testclient import TestClient
from app.api import auth as auth_module
from app.db import get_db
from app.main import app
from app.services import google_oauth
@pytest.fixture
def client(monkeypatch, db_session):
def _get_db_override():
yield db_session
app.dependency_overrides[get_db] = _get_db_override
monkeypatch.setattr(auth_module, "_run_initial_sync", lambda: None)
monkeypatch.setattr(
google_oauth, "build_authorization_url", lambda: ("https://accounts.google.com/fake", "fixed-state")
)
with TestClient(app) as test_client:
yield test_client
del app.dependency_overrides[get_db]
class FakeCredentials:
token = "fake-access-token"
refresh_token = "fake-refresh-token"
expiry = None
def _start_oauth(client):
response = client.get("/api/auth/google/start", follow_redirects=False)
assert response.status_code in (302, 307)
return "fixed-state"
def test_callback_rejects_disallowed_email(client, monkeypatch):
state = _start_oauth(client)
monkeypatch.setattr(google_oauth, "exchange_code", lambda code, state: FakeCredentials())
monkeypatch.setattr(google_oauth, "fetch_userinfo", lambda token: {"email": "stranger@example.com"})
revoked = {}
monkeypatch.setattr(google_oauth, "revoke_token", lambda token: revoked.setdefault("called", True))
response = client.get(f"/api/auth/google/callback?code=abc&state={state}", follow_redirects=False)
assert response.status_code in (302, 307)
assert "account_not_allowed" in response.headers["location"]
assert revoked.get("called") is True
status = client.get("/api/auth/status").json()
assert status["authenticated"] is False
assert status["connected"] is False
def test_callback_accepts_allowed_email(client, monkeypatch):
state = _start_oauth(client)
monkeypatch.setattr(google_oauth, "exchange_code", lambda code, state: FakeCredentials())
monkeypatch.setattr(google_oauth, "fetch_userinfo", lambda token: {"email": "owner@example.com"})
response = client.get(f"/api/auth/google/callback?code=abc&state={state}", follow_redirects=False)
assert response.status_code in (302, 307)
assert "auth_error" not in response.headers["location"]
status = client.get("/api/auth/status").json()
assert status["authenticated"] is True
assert status["connected"] is True
assert status["email"] == "owner@example.com"
def test_callback_rejects_invalid_state(client, monkeypatch):
_start_oauth(client)
response = client.get("/api/auth/google/callback?code=abc&state=wrong-state", follow_redirects=False)
assert response.status_code in (302, 307)
assert "invalid_state" in response.headers["location"]

119
tests/test_categories.py Normal file
View file

@ -0,0 +1,119 @@
import pytest
from fastapi.testclient import TestClient
from app.core.auth_dependency import require_session
from app.db import get_db
from app.main import app
from app.models.channel import Channel
@pytest.fixture
def client(db_session):
def _get_db_override():
yield db_session
app.dependency_overrides[get_db] = _get_db_override
app.dependency_overrides[require_session] = lambda: None
with TestClient(app) as test_client:
yield test_client
del app.dependency_overrides[get_db]
del app.dependency_overrides[require_session]
def _create_channel(db_session, youtube_channel_id="chanA", title="Channel A"):
channel = Channel(youtube_channel_id=youtube_channel_id, title=title, subscribed=True)
db_session.add(channel)
db_session.commit()
db_session.refresh(channel)
return channel
def test_category_crud(client):
created = client.post("/api/categories", json={"name": "Linux"}).json()
assert created["name"] == "Linux"
assert created["slug"] == "linux"
assert created["channel_count"] == 0
listed = client.get("/api/categories").json()
assert len(listed) == 1
updated = client.patch(f"/api/categories/{created['id']}", json={"name": "Linux/BSD"}).json()
assert updated["name"] == "Linux/BSD"
resp = client.delete(f"/api/categories/{created['id']}")
assert resp.status_code == 204
assert client.get("/api/categories").json() == []
def test_category_name_must_be_unique(client):
client.post("/api/categories", json={"name": "Шахматы"})
resp = client.post("/api/categories", json={"name": "шахматы"})
assert resp.status_code == 409
def test_cyrillic_slug_transliterated(client):
created = client.post("/api/categories", json={"name": "Научпоп"}).json()
assert created["slug"] == "nauchpop"
def test_delete_category_does_not_delete_channel(client, db_session):
channel = _create_channel(db_session)
category = client.post("/api/categories", json={"name": "IT"}).json()
client.put(f"/api/channels/{channel.id}/categories", json={"category_ids": [category["id"]]})
client.delete(f"/api/categories/{category['id']}")
resp = client.get(f"/api/channels/{channel.id}").json()
assert resp["id"] == channel.id
assert resp["category_ids"] == []
def test_channel_can_belong_to_multiple_categories(client, db_session):
channel = _create_channel(db_session)
cat1 = client.post("/api/categories", json={"name": "Linux"}).json()
cat2 = client.post("/api/categories", json={"name": "IT"}).json()
resp = client.put(
f"/api/channels/{channel.id}/categories",
json={"category_ids": [cat1["id"], cat2["id"]]},
).json()
assert sorted(resp["category_ids"]) == sorted([cat1["id"], cat2["id"]])
def test_assign_unknown_category_rejected(client, db_session):
channel = _create_channel(db_session)
resp = client.put(f"/api/channels/{channel.id}/categories", json={"category_ids": [9999]})
assert resp.status_code == 400
def test_channels_filter_by_category_and_uncategorized(client, db_session):
channel_a = _create_channel(db_session, "chanA", "Channel A")
channel_b = _create_channel(db_session, "chanB", "Channel B")
category = client.post("/api/categories", json={"name": "Linux"}).json()
client.put(f"/api/channels/{channel_a.id}/categories", json={"category_ids": [category["id"]]})
in_category = client.get(f"/api/channels?category_id={category['id']}").json()
assert [c["id"] for c in in_category] == [channel_a.id]
uncategorized = client.get("/api/channels?uncategorized=true").json()
assert [c["id"] for c in uncategorized] == [channel_b.id]
def test_reorder_categories(client):
cat1 = client.post("/api/categories", json={"name": "A"}).json()
cat2 = client.post("/api/categories", json={"name": "B"}).json()
cat3 = client.post("/api/categories", json={"name": "C"}).json()
reordered = client.post(
"/api/categories/reorder",
json={"category_ids": [cat3["id"], cat1["id"], cat2["id"]]},
).json()
assert [c["id"] for c in reordered] == [cat3["id"], cat1["id"], cat2["id"]]
def test_reorder_rejects_mismatched_ids(client):
client.post("/api/categories", json={"name": "A"})
resp = client.post("/api/categories/reorder", json={"category_ids": [9999]})
assert resp.status_code == 400

20
tests/test_duration.py Normal file
View file

@ -0,0 +1,20 @@
import pytest
from app.core.duration import parse_iso8601_duration
@pytest.mark.parametrize(
"value,expected",
[
("PT18M42S", 18 * 60 + 42),
("PT1H2M3S", 3600 + 120 + 3),
("PT45S", 45),
("PT5M", 300),
("P1DT2H", 86400 + 7200),
(None, None),
("", None),
("garbage", None),
],
)
def test_parse_iso8601_duration(value, expected):
assert parse_iso8601_duration(value) == expected

125
tests/test_feed.py Normal file
View file

@ -0,0 +1,125 @@
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from app.core.auth_dependency import require_session
from app.db import get_db
from app.main import app
from app.models.category import Category
from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.models.video import Video
@pytest.fixture
def client(db_session):
def _get_db_override():
yield db_session
app.dependency_overrides[get_db] = _get_db_override
app.dependency_overrides[require_session] = lambda: None
with TestClient(app) as test_client:
yield test_client
del app.dependency_overrides[get_db]
del app.dependency_overrides[require_session]
def _seed(db_session):
channel_a = Channel(youtube_channel_id="chanA", title="Channel A", subscribed=True)
channel_b = Channel(youtube_channel_id="chanB", title="Channel B", subscribed=True)
db_session.add_all([channel_a, channel_b])
db_session.commit()
category = Category(name="Linux", slug="linux", 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()
base = datetime(2026, 9, 10, tzinfo=timezone.utc)
videos = []
for i in range(5):
video = Video(
youtube_video_id=f"vid{i}",
channel_id=channel_a.id if i % 2 == 0 else channel_b.id,
title=f"Video {i}",
published_at=base + timedelta(hours=i),
youtube_url=f"https://www.youtube.com/watch?v=vid{i}",
)
videos.append(video)
db_session.add_all(videos)
db_session.commit()
return channel_a, channel_b, category, videos
def test_feed_orders_by_published_at_desc(client, db_session):
_seed(db_session)
resp = client.get("/api/feed").json()
ids = [item["youtube_video_id"] for item in resp["items"]]
assert ids == ["vid4", "vid3", "vid2", "vid1", "vid0"]
assert resp["next_cursor"] is None
def test_feed_pagination_cursor(client, db_session):
_seed(db_session)
page1 = client.get("/api/feed?limit=2").json()
assert [i["youtube_video_id"] for i in page1["items"]] == ["vid4", "vid3"]
assert page1["next_cursor"] is not None
page2 = client.get(f"/api/feed?limit=2&cursor={page1['next_cursor']}").json()
assert [i["youtube_video_id"] for i in page2["items"]] == ["vid2", "vid1"]
assert page2["next_cursor"] is not None
page3 = client.get(f"/api/feed?limit=2&cursor={page2['next_cursor']}").json()
assert [i["youtube_video_id"] for i in page3["items"]] == ["vid0"]
assert page3["next_cursor"] is None
def test_feed_filters_by_category(client, db_session):
_, _, category, _ = _seed(db_session)
resp = client.get(f"/api/feed?category_id={category.id}").json()
ids = {i["youtube_video_id"] for i in resp["items"]}
assert ids == {"vid0", "vid2", "vid4"}
def test_feed_filters_by_channel(client, db_session):
channel_a, channel_b, _, _ = _seed(db_session)
resp = client.get(f"/api/feed?channel_id={channel_b.id}").json()
ids = {i["youtube_video_id"] for i in resp["items"]}
assert ids == {"vid1", "vid3"}
def test_feed_filters_uncategorized(client, db_session):
_seed(db_session)
resp = client.get("/api/feed?uncategorized=true").json()
ids = {i["youtube_video_id"] for i in resp["items"]}
assert ids == {"vid1", "vid3"}
def test_feed_item_shape(client, db_session):
channel_a, _, category, _ = _seed(db_session)
resp = client.get("/api/feed?limit=1").json()
item = resp["items"][0]
assert item["channel"]["id"] == item["channel"]["id"]
assert set(item["channel"].keys()) == {"id", "youtube_channel_id", "title", "thumbnail_url"}
assert item["local"] == {
"available": False,
"status": "not_downloaded",
"progress_percent": None,
"media_url": None,
}
assert isinstance(item["categories"], list)

43
tests/test_health.py Normal file
View file

@ -0,0 +1,43 @@
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.db import get_db
from app.main import app
def _fake_db():
session = MagicMock()
session.execute.return_value = None
yield session
@pytest.fixture(autouse=True)
def override_get_db():
app.dependency_overrides[get_db] = _fake_db
yield
del app.dependency_overrides[get_db]
def test_health_ok():
with patch("app.api.health.httpx.get") as mock_get:
mock_get.return_value = MagicMock(status_code=200)
client = TestClient(app)
response = client.get("/api/health")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
assert body["database"] == "ok"
assert body["metube"] == "ok"
def test_health_metube_unreachable():
with patch("app.api.health.httpx.get", side_effect=ConnectionError):
client = TestClient(app)
response = client.get("/api/health")
assert response.status_code == 200
body = response.json()
assert body["metube"] == "error"

167
tests/test_sync.py Normal file
View file

@ -0,0 +1,167 @@
from app.models.channel import Channel
from app.models.video import Video
from app.services import sync
def _fake_credentials():
return object()
def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session):
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: _fake_credentials())
monkeypatch.setattr(
sync.youtube_client,
"fetch_subscriptions",
lambda creds: [
{"youtube_channel_id": "chanA", "title": "Channel A", "description": "d", "thumbnail_url": "t"},
{"youtube_channel_id": "chanB", "title": "Channel B", "description": "d", "thumbnail_url": "t"},
],
)
monkeypatch.setattr(
sync.youtube_client,
"fetch_uploads_playlists",
lambda creds, ids: {cid: f"UU{cid}" for cid in ids},
)
result = sync.sync_subscriptions(db_session)
assert result["status"] == "completed"
assert result["channels_added"] == 2
assert result["channels_unsubscribed"] == 0
channels = db_session.query(Channel).order_by(Channel.youtube_channel_id).all()
assert [c.youtube_channel_id for c in channels] == ["chanA", "chanB"]
assert all(c.subscribed for c in channels)
assert channels[0].uploads_playlist_id == "UUchanA"
# Second sync: chanA disappears from subscriptions, chanC appears.
monkeypatch.setattr(
sync.youtube_client,
"fetch_subscriptions",
lambda creds: [
{"youtube_channel_id": "chanB", "title": "Channel B", "description": "d", "thumbnail_url": "t"},
{"youtube_channel_id": "chanC", "title": "Channel C", "description": "d", "thumbnail_url": "t"},
],
)
result2 = sync.sync_subscriptions(db_session)
assert result2["channels_added"] == 1
assert result2["channels_updated"] == 1
assert result2["channels_unsubscribed"] == 1
channels = {c.youtube_channel_id: c for c in db_session.query(Channel).all()}
assert len(channels) == 3
assert channels["chanA"].subscribed is False
assert channels["chanB"].subscribed is True
assert channels["chanC"].subscribed is True
def test_sync_in_progress_raises(monkeypatch, db_session):
sync._subscriptions_lock.acquire()
try:
try:
sync.sync_subscriptions(db_session)
assert False, "expected SyncInProgress"
except sync.SyncInProgress:
pass
finally:
sync._subscriptions_lock.release()
def _seed_channel(db_session, youtube_channel_id="chanA", uploads_playlist_id="UUchanA"):
channel = Channel(
youtube_channel_id=youtube_channel_id,
title="Channel",
subscribed=True,
uploads_playlist_id=uploads_playlist_id,
)
db_session.add(channel)
db_session.commit()
db_session.refresh(channel)
return channel
def test_sync_videos_adds_and_updates(monkeypatch, db_session):
channel = _seed_channel(db_session)
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
monkeypatch.setattr(
sync.youtube_client, "fetch_playlist_video_ids", lambda creds, playlist_id, max_results: ["vid1"]
)
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["status"] == "completed"
assert result["videos_added"] == 1
video = db_session.query(Video).filter_by(youtube_video_id="vid1").one()
assert video.title == "Video One"
assert video.duration_seconds == 300
assert video.youtube_url == "https://www.youtube.com/watch?v=vid1"
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 Updated",
"description": "d2",
"thumbnail_url": "t2",
"published_at": "2026-09-10T12:00:00Z",
"duration_iso8601": "PT6M",
}
],
)
result2 = sync.sync_videos(db_session)
assert result2["videos_added"] == 0
assert result2["videos_updated"] == 1
videos = db_session.query(Video).all()
assert len(videos) == 1
assert videos[0].title == "Video One Updated"
def test_sync_videos_skips_unknown_channel(monkeypatch, db_session):
_seed_channel(db_session, "chanA")
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
monkeypatch.setattr(sync.youtube_client, "fetch_playlist_video_ids", lambda creds, playlist_id, max_results: [])
monkeypatch.setattr(
sync.youtube_client,
"fetch_videos_details",
lambda creds, ids: [
{
"youtube_video_id": "vidX",
"youtube_channel_id": "unknown-channel",
"title": "Orphan",
"description": "",
"thumbnail_url": None,
"published_at": "2026-09-10T12:00:00Z",
"duration_iso8601": None,
}
],
)
result = sync.sync_videos(db_session)
assert result["videos_skipped"] == 1
assert db_session.query(Video).count() == 0

62
tests/test_videos.py Normal file
View file

@ -0,0 +1,62 @@
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
from app.core.auth_dependency import require_session
from app.db import get_db
from app.main import app
from app.models.category import Category
from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.models.video import Video
@pytest.fixture
def client(db_session):
def _get_db_override():
yield db_session
app.dependency_overrides[get_db] = _get_db_override
app.dependency_overrides[require_session] = lambda: None
with TestClient(app) as test_client:
yield test_client
del app.dependency_overrides[get_db]
del app.dependency_overrides[require_session]
def test_get_video_by_youtube_id(client, db_session):
channel = Channel(youtube_channel_id="chanA", title="Channel A", subscribed=True)
db_session.add(channel)
db_session.commit()
category = Category(name="Linux", slug="linux", sort_order=0)
db_session.add(category)
db_session.commit()
db_session.execute(channel_categories.insert().values(channel_id=channel.id, category_id=category.id))
db_session.commit()
video = Video(
youtube_video_id="vid1",
channel_id=channel.id,
title="Video One",
description="desc",
published_at=datetime(2026, 9, 10, tzinfo=timezone.utc),
duration_seconds=125,
youtube_url="https://www.youtube.com/watch?v=vid1",
)
db_session.add(video)
db_session.commit()
resp = client.get("/api/videos/vid1")
assert resp.status_code == 200
body = resp.json()
assert body["youtube_video_id"] == "vid1"
assert body["channel"]["id"] == channel.id
assert body["categories"] == [{"id": category.id, "name": "Linux"}]
assert body["local"]["available"] is False
def test_get_video_not_found(client):
resp = client.get("/api/videos/missing")
assert resp.status_code == 404

View file

@ -0,0 +1,160 @@
import pytest
from app.services import youtube_client
class FakeResponse:
def __init__(self, status_code, payload):
self.status_code = status_code
self._payload = payload
self.text = str(payload)
def json(self):
return self._payload
class FakeClient:
def __init__(self, responses):
self._responses = list(responses)
def __enter__(self):
return self
def __exit__(self, *args):
return False
def get(self, url, params=None, headers=None):
return self._responses.pop(0)
def _credentials():
class Cred:
token = "fake-token"
return Cred()
def test_fetch_subscriptions_paginates(monkeypatch):
page1 = FakeResponse(
200,
{
"items": [
{
"snippet": {
"title": "Channel A",
"description": "desc a",
"resourceId": {"channelId": "chanA"},
"thumbnails": {"high": {"url": "http://thumb/a.jpg"}},
}
}
],
"nextPageToken": "page2",
},
)
page2 = FakeResponse(
200,
{
"items": [
{
"snippet": {
"title": "Channel B",
"description": "desc b",
"resourceId": {"channelId": "chanB"},
"thumbnails": {"default": {"url": "http://thumb/b.jpg"}},
}
}
]
},
)
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([page1, page2]))
result = youtube_client.fetch_subscriptions(_credentials())
assert [r["youtube_channel_id"] for r in result] == ["chanA", "chanB"]
assert result[0]["thumbnail_url"] == "http://thumb/a.jpg"
assert result[1]["thumbnail_url"] == "http://thumb/b.jpg"
def test_fetch_subscriptions_raises_on_quota_exceeded(monkeypatch):
response = FakeResponse(
403,
{"error": {"message": "Quota exceeded", "errors": [{"reason": "quotaExceeded"}]}},
)
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
with pytest.raises(youtube_client.YouTubeQuotaExceeded):
youtube_client.fetch_subscriptions(_credentials())
def test_fetch_playlist_video_ids(monkeypatch):
response = FakeResponse(
200,
{
"items": [
{"contentDetails": {"videoId": "vid1"}},
{"contentDetails": {"videoId": "vid2"}},
]
},
)
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
result = youtube_client.fetch_playlist_video_ids(_credentials(), "UUplaylist", 10)
assert result == ["vid1", "vid2"]
def test_fetch_playlist_video_ids_returns_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(_credentials(), "UUplaylist", 10)
assert result == []
def test_fetch_videos_details(monkeypatch):
response = FakeResponse(
200,
{
"items": [
{
"id": "vid1",
"snippet": {
"channelId": "chanA",
"title": "Title 1",
"description": "desc",
"publishedAt": "2026-09-10T12:00:00Z",
"thumbnails": {"high": {"url": "http://thumb/1.jpg"}},
},
"contentDetails": {"duration": "PT10M5S"},
}
]
},
)
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
result = youtube_client.fetch_videos_details(_credentials(), ["vid1"])
assert len(result) == 1
assert result[0]["youtube_video_id"] == "vid1"
assert result[0]["youtube_channel_id"] == "chanA"
assert result[0]["duration_iso8601"] == "PT10M5S"
assert result[0]["thumbnail_url"] == "http://thumb/1.jpg"
def test_fetch_uploads_playlists_batches(monkeypatch):
response = FakeResponse(
200,
{
"items": [
{"id": "chanA", "contentDetails": {"relatedPlaylists": {"uploads": "UUchanA"}}},
{"id": "chanB", "contentDetails": {"relatedPlaylists": {"uploads": "UUchanB"}}},
]
},
)
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
result = youtube_client.fetch_uploads_playlists(_credentials(), ["chanA", "chanB"])
assert result == {"chanA": "UUchanA", "chanB": "UUchanB"}

File diff suppressed because it is too large Load diff