Fix review findings and remove Uncategorized from sidebar

Backend: cache Google access tokens (drop dead access_token_expires_at,
migration 0007), handle MeTube cleared/canceled events by URL, return
email from /auth/status only when authenticated, move Google base URLs
into settings, run container as non-root.

Frontend: include local feed filters in the query key, remove dead
Saved page and unused assets, drop stale CategoryNav props and classes,
send Content-Type only with a body, remove Uncategorized from sidebar.
This commit is contained in:
vrubelroman 2026-09-17 17:22:22 +00:00
parent 6c704cac97
commit fde9a439df
25 changed files with 420 additions and 174 deletions

View file

@ -12,6 +12,14 @@ GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://testmyyoutube.vrubel.xyz/api/auth/google/callback
ALLOWED_GOOGLE_EMAIL=
# Google/YouTube endpoints (необязательно, есть дефолты)
GOOGLE_AUTH_URI=https://accounts.google.com/o/oauth2/auth
GOOGLE_TOKEN_URI=https://oauth2.googleapis.com/token
GOOGLE_USERINFO_URI=https://www.googleapis.com/oauth2/v3/userinfo
GOOGLE_REVOKE_URI=https://oauth2.googleapis.com/revoke
YOUTUBE_API_BASE_URL=https://www.googleapis.com/youtube/v3
YOUTUBE_WATCH_URL_TEMPLATE=https://www.youtube.com/watch?v={video_id}
METUBE_API_BASE_URL=http://192.168.8.177:8081
METUBE_PUBLIC_BASE_URL=http://192.168.8.177:8081
METUBE_CONTAINER_DOWNLOAD_DIR=/downloads

View file

@ -25,6 +25,11 @@ WORKDIR /app
COPY entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
# Run as an unprivileged user: the app only reads the baked-in code/static
# assets and talks to Postgres/MeTube over the network, it never needs root.
RUN useradd --uid 10001 app
USER app
EXPOSE 8080
ENTRYPOINT ["./entrypoint.sh"]

View file

@ -17,11 +17,16 @@ router = APIRouter()
@router.get("/auth/status")
def auth_status(request: Request, db: Session = Depends(get_db)) -> dict:
# This endpoint is public (the Connect page needs `authenticated` before
# logging in), so the connected account's email must only be revealed to a
# request holding a valid session.
authenticated = bool(request.session.get("authenticated"))
connected = google_oauth.is_connected(db)
email = google_oauth.get_connected_email(db) if authenticated and connected else None
return {
"authenticated": bool(request.session.get("authenticated")),
"authenticated": authenticated,
"connected": connected,
"email": google_oauth.get_connected_email(db) if connected else None,
"email": email,
}

View file

@ -17,6 +17,15 @@ class Settings(BaseSettings):
google_redirect_uri: str = ""
allowed_google_email: str = ""
# Google/YouTube endpoints (AGENTS.md rule 12): configurable via env,
# defaults keep the current behaviour without touching .env.
google_auth_uri: str = "https://accounts.google.com/o/oauth2/auth"
google_token_uri: str = "https://oauth2.googleapis.com/token"
google_userinfo_uri: str = "https://www.googleapis.com/oauth2/v3/userinfo"
google_revoke_uri: str = "https://oauth2.googleapis.com/revoke"
youtube_api_base_url: str = "https://www.googleapis.com/youtube/v3"
youtube_watch_url_template: str = "https://www.youtube.com/watch?v={video_id}"
metube_api_base_url: str = "http://127.0.0.1:8081"
metube_public_base_url: str = "http://127.0.0.1:8081"
metube_container_download_dir: str = "/downloads"

View file

@ -14,7 +14,6 @@ class OAuthCredentials(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
google_email: Mapped[str] = mapped_column(String(255), nullable=False)
encrypted_refresh_token: Mapped[str] = mapped_column(String, nullable=False)
access_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False

View file

@ -117,6 +117,26 @@ def _find_job_by_payload(db: Session, payload: dict) -> DownloadJob | None:
return None
def _find_job_by_key(db: Session, key: str) -> DownloadJob | None:
"""Match a bare MeTube store key (its 'canceled'/'cleared' events carry
just one key, JSON-string-encoded) to our latest job for it. The key is
the URL the download was enqueued under; matching the metube job id first
keeps us tolerant of both shapes."""
job = (
db.query(DownloadJob)
.filter(DownloadJob.metube_job_id == key)
.order_by(DownloadJob.id.desc())
.first()
)
if job is not None:
return job
video = db.query(Video).filter(Video.youtube_url == key).one_or_none()
if video is not None:
return get_latest_job(db, video.id)
return None
async def handle_metube_event(db: Session, event_name: str, raw_payload) -> None:
try:
payload = json.loads(raw_payload) if isinstance(raw_payload, str) else raw_payload
@ -124,21 +144,34 @@ async def handle_metube_event(db: Session, event_name: str, raw_payload) -> None
logger.warning("Could not parse MeTube event payload for %s: %r", event_name, raw_payload)
return
if event_name in ("canceled", "cleared"):
if event_name == "canceled":
if not isinstance(payload, str):
return
job = (
db.query(DownloadJob)
.filter(DownloadJob.metube_job_id == payload)
.order_by(DownloadJob.id.desc())
.first()
)
if job is not None and event_name == "canceled" and job.status in ACTIVE_STATUSES:
job = _find_job_by_key(db, payload)
if job is not None and job.status in ACTIVE_STATUSES:
job.status = "failed"
job.error_message = "Отменено в MeTube"
db.commit()
return
if event_name == "cleared":
# MeTube emits 'cleared' ONLY when a *done* entry is deleted (trash
# via /delete?where=done, or CLEAR_COMPLETED_AFTER): the payload is
# that entry's key (its URL) as a JSON string, one event per item.
# Clearing the queue emits 'canceled' instead. The removed entry is
# usually a download MeTube had before we existed (AGENTS.md rule 10)
# or one we already completed, so an unmatched/empty payload must not
# touch anything: failing all active jobs here would wrongly mark
# downloads MeTube is still running.
if not isinstance(payload, str) or not payload:
return
job = _find_job_by_key(db, payload)
if job is not None and job.status in ACTIVE_STATUSES:
job.status = "failed"
job.error_message = "Очищено в MeTube"
db.commit()
return
if not isinstance(payload, dict):
return

View file

@ -1,6 +1,7 @@
import logging
import os
from datetime import datetime, timezone
import threading
from datetime import datetime, timedelta, timezone
# Google frequently echoes back a scope string that's a superset/reordering
# of what we requested (e.g. we ask for "youtube", Google's token response
@ -34,10 +35,15 @@ SCOPES = [
"https://www.googleapis.com/auth/userinfo.profile",
]
AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
TOKEN_URI = "https://oauth2.googleapis.com/token"
USERINFO_URI = "https://www.googleapis.com/oauth2/v3/userinfo"
REVOKE_URI = "https://oauth2.googleapis.com/revoke"
# Refresh proactively: never hand out a token that could die mid-request.
_ACCESS_TOKEN_REFRESH_BUFFER_SECONDS = 60
# Module-level access-token cache. Google access tokens live ~1h; previously
# every get_credentials() call (sync, unsubscribe, ...) paid for a full token
# exchange with Google. The lock keeps concurrent callers sharing one refresh
# instead of racing each other to the token endpoint.
_credentials_lock = threading.Lock()
_cached_credentials: Credentials | None = None
class OAuthNotConnected(Exception):
@ -49,8 +55,8 @@ def _client_config() -> dict:
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": AUTH_URI,
"token_uri": TOKEN_URI,
"auth_uri": settings.google_auth_uri,
"token_uri": settings.google_token_uri,
"redirect_uris": [settings.google_redirect_uri],
}
}
@ -83,7 +89,7 @@ def exchange_code(code: str, state: str) -> Credentials:
def fetch_userinfo(access_token: str) -> dict:
response = httpx.get(
USERINFO_URI,
settings.google_userinfo_uri,
headers={"Authorization": f"Bearer {access_token}"},
timeout=settings.metube_request_timeout_seconds,
)
@ -93,16 +99,14 @@ def fetch_userinfo(access_token: str) -> dict:
def revoke_token(token: str) -> None:
try:
httpx.post(REVOKE_URI, params={"token": token}, timeout=10)
httpx.post(settings.google_revoke_uri, params={"token": token}, timeout=10)
except Exception:
logger.warning("Failed to revoke Google token", exc_info=True)
def store_credentials(db: Session, google_email: str, credentials: Credentials) -> None:
global _cached_credentials
encrypted = encrypt_token(credentials.refresh_token)
expires_at = credentials.expiry
if expires_at is not None and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
row = db.get(OAuthCredentials, SINGLETON_ID)
if row is None:
@ -111,15 +115,22 @@ def store_credentials(db: Session, google_email: str, credentials: Credentials)
else:
row.google_email = google_email
row.encrypted_refresh_token = encrypted
row.access_token_expires_at = expires_at
db.commit()
# The exchanged credentials carry a fresh access token -- reuse them so
# the immediately following syncs don't pay for a second Google request.
with _credentials_lock:
_cached_credentials = credentials
def clear_credentials(db: Session) -> None:
global _cached_credentials
row = db.get(OAuthCredentials, SINGLETON_ID)
if row is not None:
db.delete(row)
db.commit()
with _credentials_lock:
_cached_credentials = None
def is_connected(db: Session) -> bool:
@ -131,26 +142,48 @@ def get_connected_email(db: Session) -> str | None:
return row.google_email if row else None
def _token_missing_or_expiring(credentials: Credentials) -> bool:
if not credentials.token:
return True
expiry = credentials.expiry
if expiry is None:
# Unknown expiry -- be conservative and refresh.
return True
if expiry.tzinfo is None:
# google-auth reports expiry as a naive UTC datetime.
expiry = expiry.replace(tzinfo=timezone.utc)
return expiry <= datetime.now(timezone.utc) + timedelta(seconds=_ACCESS_TOKEN_REFRESH_BUFFER_SECONDS)
def get_credentials(db: Session) -> Credentials:
global _cached_credentials
row = db.get(OAuthCredentials, SINGLETON_ID)
if row is None:
raise OAuthNotConnected("Google account is not connected")
refresh_token = decrypt_token(row.encrypted_refresh_token)
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()
with _credentials_lock:
credentials = _cached_credentials
if credentials is None or credentials.refresh_token != refresh_token:
# No cached token yet, or the account was reconnected with a new
# refresh token. The refresh below does the initial exchange.
credentials = Credentials(
token=None,
refresh_token=refresh_token,
token_uri=settings.google_token_uri,
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
scopes=SCOPES,
)
_cached_credentials = credentials
return credentials
if _token_missing_or_expiring(credentials):
try:
credentials.refresh(GoogleAuthRequest())
except Exception:
# Don't keep a broken cached object behind.
_cached_credentials = None
raise
return credentials

View file

@ -10,7 +10,10 @@ Verified against the real MeTube source (alexta69/metube, app/main.py + app/ytdl
JSON-*string*-encoded (json.JSONEncoder().encode(...)), not a raw object — must
json.loads() the payload. Relevant keys: id, title, url, status, msg, percent
(float 0-100 or None), filename (already relative to DOWNLOAD_DIR), error.
'canceled'/'cleared' carry just an id (also JSON-string-encoded).
'canceled'/'cleared' carry the download's URL key (JSON-string-encoded),
one event per item; 'cleared' fires only when a done entry is deleted
(trash via /delete?where=done or CLEAR_COMPLETED_AFTER) — clearing the
queue emits 'canceled'.
- MeTube status vocabulary: pending/preparing/scheduled/downloading/postprocessing/
finished/error — mapped to our own vocabulary in sync with download_jobs.
- GET /history returns {"queue": [...], "pending": [...], "done": [...]} of the

View file

@ -195,7 +195,7 @@ def sync_videos(db: Session) -> dict:
continue
duration_seconds = parse_iso8601_duration(item["duration_iso8601"])
youtube_url = f"https://www.youtube.com/watch?v={item['youtube_video_id']}"
youtube_url = settings.youtube_watch_url_template.format(video_id=item["youtube_video_id"])
video = existing.get(item["youtube_video_id"])
if video is None:

View file

@ -7,7 +7,6 @@ from app.config import settings
logger = logging.getLogger(__name__)
API_BASE = "https://www.googleapis.com/youtube/v3"
BATCH_SIZE = 50
@ -68,7 +67,7 @@ def fetch_subscriptions(credentials: Credentials) -> list[dict]:
if page_token:
params["pageToken"] = page_token
response = client.get(f"{API_BASE}/subscriptions", params=params, headers=_headers(credentials))
response = client.get(f"{settings.youtube_api_base_url}/subscriptions", params=params, headers=_headers(credentials))
_raise_for_status(response)
data = response.json()
@ -109,7 +108,7 @@ def fetch_playlist_video_ids(credentials: Credentials, playlist_id: str, max_res
"playlistId": playlist_id,
"maxResults": min(max_results, 50),
}
response = client.get(f"{API_BASE}/playlistItems", params=params, headers=_headers(credentials))
response = client.get(f"{settings.youtube_api_base_url}/playlistItems", params=params, headers=_headers(credentials))
if response.status_code == 404:
return []
_raise_for_status(response)
@ -134,7 +133,7 @@ def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list
"id": ",".join(batch),
"maxResults": BATCH_SIZE,
}
response = client.get(f"{API_BASE}/videos", params=params, headers=_headers(credentials))
response = client.get(f"{settings.youtube_api_base_url}/videos", params=params, headers=_headers(credentials))
_raise_for_status(response)
data = response.json()
@ -162,7 +161,7 @@ def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list
def unsubscribe(credentials: Credentials, youtube_subscription_id: str) -> None:
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
response = client.delete(
f"{API_BASE}/subscriptions",
f"{settings.youtube_api_base_url}/subscriptions",
params={"id": youtube_subscription_id},
headers=_headers(credentials),
)
@ -183,7 +182,7 @@ def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) ->
"id": ",".join(batch),
"maxResults": BATCH_SIZE,
}
response = client.get(f"{API_BASE}/channels", params=params, headers=_headers(credentials))
response = client.get(f"{settings.youtube_api_base_url}/channels", params=params, headers=_headers(credentials))
_raise_for_status(response)
data = response.json()

View file

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

Before

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -13,7 +13,7 @@ export function authErrorMessage(code: string): string {
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) },
headers: options.body != null ? { 'Content-Type': 'application/json', ...(options.headers ?? {}) } : options.headers,
...options,
})
if (!res.ok) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

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

Before

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -41,7 +41,6 @@ function Sidebar({ close }: { close: () => void }) {
const categories = categoriesQuery.data ?? []
const links = [
{ to: '/', icon: 'home' as const, text: 'Все видео', end: true },
{ to: '/uncategorized', icon: 'folder' as const, text: 'Без категории' },
{ to: '/local', icon: 'server' as const, text: 'На сервере' },
]
return <nav className="sidebar-nav" aria-label="Основная навигация">

View file

@ -8,12 +8,9 @@ interface Props {
onChange: (filter: CategoryFilter) => void
allCount?: number
uncategorizedCount?: number
// Overrides each category's displayed count (channel_count by default) --
// e.g. the Saved page shows saved-video counts instead.
categoryCounts?: Record<number, number>
}
function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount, categoryCounts }: Props) {
function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount }: Props) {
return (
<nav className="category-nav" aria-label="Фильтр каналов по категориям">
<button type="button" aria-pressed={value === 'all'} className={value === 'all' ? 'active' : ''} onClick={() => onChange('all')}>
@ -33,9 +30,7 @@ function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount
onClick={() => onChange(category.id)}
>
{category.name}
<span className="category-nav-count">
{categoryCounts ? categoryCounts[category.id] ?? 0 : category.channel_count}
</span>
<span className="category-nav-count">{category.channel_count}</span>
</button>
))}
</nav>

View file

@ -96,7 +96,7 @@ function ChannelCard({ channel, categories }: Props) {
)}
</div>
{unsubscribeMutation.isError && (
<p className="error unsubscribe-error">{(unsubscribeMutation.error as Error).message}</p>
<p className="error">{(unsubscribeMutation.error as Error).message}</p>
)}
<div className="channel-categories">

View file

@ -25,7 +25,6 @@ function Player({ video }: Props) {
return (
<>
<div className="player-wrapper">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
controls
src={video.local.media_url!}

View file

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

View file

@ -6,7 +6,7 @@ import Icon from '../components/Icon'
import VideoCard from '../components/VideoCard'
function VideoSkeleton() {
return <li className="video-card skeleton-card" aria-hidden="true"><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /><div className="skeleton-line short" /></div></li>
return <li className="video-card" aria-hidden="true"><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /><div className="skeleton-line short" /></div></li>
}
function Feed() {
@ -30,7 +30,7 @@ function Feed() {
enabled: isUncategorized || (!categoryNumber && !isLocal && !search),
})
const feedQuery = useInfiniteQuery({
queryKey: ['feed', location.pathname, search],
queryKey: ['feed', location.pathname, search, localCategory, localUncategorized],
queryFn: ({ pageParam }) => getFeed({
categoryId: isLocal ? localCategory : categoryNumber,
uncategorized: isUncategorized || localUncategorized,
@ -76,7 +76,7 @@ function Feed() {
}, [feedQuery.isLoading, location.pathname, location.search])
const hasInvalidCategory = Boolean(categoryId && !categoryNumber)
return <section className="page feed-screen">
return <section className="page">
<div className="page-heading">
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}</p></div>
{!isLocal && !search && <button type="button" className="button-secondary refresh-button" onClick={() => syncMutation.mutate()} disabled={syncMutation.isPending || syncStatusQuery.data?.videos.running}><Icon name="refresh" size={17} className={syncStatusQuery.data?.videos.running ? 'spin' : ''} />{syncStatusQuery.data?.videos.running ? 'Обновляется' : 'Обновить'}</button>}

View file

@ -1,85 +0,0 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { getFeed, getSavedCounts, listCategories } from '../api/client'
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
import VideoCard from '../components/VideoCard'
import { useState } from 'react'
function Saved() {
const [filter, setFilter] = useState<CategoryFilter>('all')
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const categories = categoriesQuery.data ?? []
// Keyed under 'feed' so DownloadButton's existing invalidateQueries({
// queryKey: ['feed'] }) on download/delete also refreshes these counts.
const savedCountsQuery = useQuery({ queryKey: ['feed', 'saved-counts'], queryFn: getSavedCounts })
const savedCounts = savedCountsQuery.data
const categoryCounts = Object.fromEntries(
Object.entries(savedCounts?.categories ?? {}).map(([id, count]) => [Number(id), count]),
)
const feedQuery = useInfiniteQuery({
queryKey: ['feed', 'saved', filter],
queryFn: ({ pageParam }) =>
getFeed({
categoryId: typeof filter === 'number' ? filter : undefined,
uncategorized: filter === 'uncategorized',
downloaded: true,
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>
<h1>Сохранённые</h1>
<div className="actions">
<Link to="/">Лента</Link>
<Link to="/channels">Каналы</Link>
<Link to="/categories">Категории</Link>
</div>
</header>
<div className="layout">
<CategoryNav
categories={categories}
value={filter}
onChange={setFilter}
allCount={savedCounts?.all}
uncategorizedCount={savedCounts?.uncategorized}
categoryCounts={categoryCounts}
/>
<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 Saved

View file

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

View file

@ -1,9 +1,12 @@
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from app.api import auth as auth_module
from app.db import get_db
from app.main import app
from app.models.oauth_credentials import SINGLETON_ID, OAuthCredentials
from app.services import google_oauth
@ -78,3 +81,146 @@ def test_callback_rejects_invalid_state(client, monkeypatch):
assert response.status_code in (302, 307)
assert "invalid_state" in response.headers["location"]
def test_status_hides_email_without_session(client, db_session):
"""/api/auth/status is public (the Connect page reads `authenticated`), so
the connected account's email must not leak to unauthenticated requests."""
row = OAuthCredentials(
id=SINGLETON_ID,
google_email="owner@example.com",
encrypted_refresh_token="encrypted-blob",
)
db_session.add(row)
db_session.commit()
status = client.get("/api/auth/status").json()
assert status["authenticated"] is False
assert status["connected"] is True
assert status["email"] is None
def _seed_credentials_row(db_session, refresh_token="refresh-1"):
row = OAuthCredentials(
id=SINGLETON_ID,
google_email="owner@example.com",
encrypted_refresh_token=google_oauth.encrypt_token(refresh_token),
)
db_session.add(row)
db_session.commit()
class FakeGoogleCredentials:
"""Stands in for google.oauth2.credentials.Credentials in cache tests."""
def __init__(self, **kwargs):
self.refresh_token = kwargs["refresh_token"]
self.token = None
self.expiry = None
def refresh(self, request):
raise NotImplementedError
def test_get_credentials_caches_and_reuses_access_token(monkeypatch, db_session):
monkeypatch.setattr(google_oauth, "_cached_credentials", None)
_seed_credentials_row(db_session)
refreshes = []
class Creds(FakeGoogleCredentials):
def refresh(self, request):
refreshes.append(request)
self.token = "access-token"
self.expiry = datetime(2099, 1, 1, tzinfo=timezone.utc)
monkeypatch.setattr(google_oauth, "Credentials", Creds)
first = google_oauth.get_credentials(db_session)
second = google_oauth.get_credentials(db_session)
assert first is second
assert first.token == "access-token"
assert len(refreshes) == 1 # second call must not hit Google again
def test_get_credentials_refreshes_expiring_token(monkeypatch, db_session):
monkeypatch.setattr(google_oauth, "_cached_credentials", None)
_seed_credentials_row(db_session)
refreshes = []
class Creds(FakeGoogleCredentials):
def refresh(self, request):
refreshes.append(request)
self.token = "access-token"
# Expires in 30s -- inside the 60s refresh buffer.
self.expiry = datetime.now(timezone.utc) + timedelta(seconds=30)
monkeypatch.setattr(google_oauth, "Credentials", Creds)
google_oauth.get_credentials(db_session)
google_oauth.get_credentials(db_session)
assert len(refreshes) == 2
def test_get_credentials_refresh_failure_clears_cache(monkeypatch, db_session):
"""A failed token refresh must not leave a broken cached object behind:
the error propagates and the next call rebuilds credentials from the DB
instead of reusing (and re-failing on) the stale cache entry."""
monkeypatch.setattr(google_oauth, "_cached_credentials", None)
_seed_credentials_row(db_session)
built = []
class Creds(FakeGoogleCredentials):
def __init__(self, **kwargs):
super().__init__(**kwargs)
built.append(self.refresh_token)
def refresh(self, request):
raise RuntimeError("token endpoint down")
monkeypatch.setattr(google_oauth, "Credentials", Creds)
for _ in range(2):
with pytest.raises(RuntimeError, match="token endpoint down"):
google_oauth.get_credentials(db_session)
# Both calls failed, and the broken cache entry was dropped: each call
# rebuilt from the stored refresh token instead of sticking.
assert len(built) == 2
assert google_oauth._cached_credentials is None
def test_get_credentials_rebuilds_cache_after_reconnect(monkeypatch, db_session):
monkeypatch.setattr(google_oauth, "_cached_credentials", None)
_seed_credentials_row(db_session)
built = []
class Creds(FakeGoogleCredentials):
def __init__(self, **kwargs):
super().__init__(**kwargs)
built.append(self.refresh_token)
self.token = "already-valid"
self.expiry = datetime(2099, 1, 1, tzinfo=timezone.utc)
def refresh(self, request):
raise AssertionError("must not refresh a still-valid cached token")
monkeypatch.setattr(google_oauth, "Credentials", Creds)
google_oauth.get_credentials(db_session)
assert built == ["refresh-1"]
# Reconnect: the stored refresh token changes, the old cache entry must
# not be reused.
row = db_session.get(OAuthCredentials, SINGLETON_ID)
row.encrypted_refresh_token = google_oauth.encrypt_token("refresh-2")
db_session.commit()
google_oauth.get_credentials(db_session)
assert built == ["refresh-1", "refresh-2"]

View file

@ -235,6 +235,101 @@ async def test_handle_metube_event_canceled_marks_failed(db_session):
assert job.status == "failed"
@pytest.mark.asyncio
async def test_handle_metube_event_canceled_matches_by_url(db_session):
"""MeTube keys canceled/cleared events by the download's URL, not its id."""
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1")
db_session.add(job)
db_session.commit()
await download_jobs.handle_metube_event(db_session, "canceled", json.dumps(video.youtube_url))
db_session.refresh(job)
assert job.status == "failed"
assert job.error_message == "Отменено в MeTube"
@pytest.mark.asyncio
async def test_handle_metube_event_cleared_matches_job_by_url(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1")
other_video = _seed_video(db_session, youtube_video_id="vid2", youtube_channel_id="chanB")
other_job = DownloadJob(video_id=other_video.id, status="queued")
db_session.add(job)
db_session.add(other_job)
db_session.commit()
await download_jobs.handle_metube_event(db_session, "cleared", json.dumps(video.youtube_url))
db_session.refresh(job)
db_session.refresh(other_job)
assert job.status == "failed"
assert job.error_message == "Очищено в MeTube"
assert other_job.status == "queued"
@pytest.mark.asyncio
async def test_handle_metube_event_cleared_matching_terminal_job_is_noop(db_session):
"""Cleared payload resolving to a finished job must not touch terminal
statuses, nor spill over onto unrelated active jobs."""
video = _seed_video(db_session)
completed = DownloadJob(video_id=video.id, status="completed", media_url="http://x/f.mp4")
other_video = _seed_video(db_session, youtube_video_id="vid2", youtube_channel_id="chanB")
active = DownloadJob(video_id=other_video.id, status="downloading")
db_session.add_all([completed, active])
db_session.commit()
await download_jobs.handle_metube_event(db_session, "cleared", json.dumps(video.youtube_url))
db_session.refresh(completed)
db_session.refresh(active)
assert completed.status == "completed"
assert active.status == "downloading"
@pytest.mark.asyncio
async def test_handle_metube_event_cleared_unmatched_payload_is_noop(db_session):
"""'cleared' only ever refers to the one done-entry being removed (trash
or CLEAR_COMPLETED_AFTER). A payload that doesn't match any of our jobs is
a foreign MeTube download we never tracked -- the jobs MeTube is still
running must stay untouched."""
video1 = _seed_video(db_session)
job1 = DownloadJob(video_id=video1.id, status="downloading")
video2 = _seed_video(db_session, youtube_video_id="vid2", youtube_channel_id="chanB")
job2 = DownloadJob(video_id=video2.id, status="postprocessing")
video3 = _seed_video(db_session, youtube_video_id="vid3", youtube_channel_id="chanC")
completed = DownloadJob(video_id=video3.id, status="completed", media_url="http://x/f.mp4")
unknown = DownloadJob(video_id=video3.id, status="unknown")
db_session.add_all([job1, job2, completed, unknown])
db_session.commit()
await download_jobs.handle_metube_event(db_session, "cleared", json.dumps("https://example.com/other"))
db_session.refresh(job1)
db_session.refresh(job2)
db_session.refresh(completed)
db_session.refresh(unknown)
assert job1.status == "downloading"
assert job2.status == "postprocessing"
assert completed.status == "completed"
assert unknown.status == "unknown"
@pytest.mark.asyncio
async def test_handle_metube_event_cleared_without_payload_is_noop(db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="queued")
db_session.add(job)
db_session.commit()
await download_jobs.handle_metube_event(db_session, "cleared", None)
db_session.refresh(job)
assert job.status == "queued"
assert job.error_message is None
def test_reconcile_marks_unknown_when_history_unavailable(monkeypatch, db_session):
video = _seed_video(db_session)
job = DownloadJob(video_id=video.id, status="downloading")