From 441a3ef9df38cd24b80f8f212b735f68c64d7601 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 16 Sep 2026 20:01:41 +0000 Subject: [PATCH 01/10] Verify deletion actually removed the file before reporting success MeTube's /delete only unlinks the file when it's configured with DELETE_FILE_ON_TRASHCAN=true; otherwise it just drops the entry from its own "done" list and returns {"status": "ok"} regardless -- we were trusting that response and marking the job "deleted" (offering a re-download) while the file was still sitting on mediaVM's disk the whole time. Now HEAD-check the media_url right after the delete call. If the file is still reachable, leave the job's status untouched (still "completed", still playable) and surface a clear 409 explaining MeTube's own config is why, rather than lying about local state. Co-Authored-By: Claude Sonnet 5 --- backend/app/api/videos.py | 9 +++++++ backend/app/services/download_jobs.py | 22 +++++++++++++++- frontend/src/components/DownloadButton.tsx | 5 ++++ tests/test_videos.py | 30 ++++++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/backend/app/api/videos.py b/backend/app/api/videos.py index 3613856..375fac8 100644 --- a/backend/app/api/videos.py +++ b/backend/app/api/videos.py @@ -9,6 +9,7 @@ from app.models.channel import Channel from app.models.download_job import DownloadJob from app.models.video import Video from app.services.download_jobs import ( + DeleteDidNotRemoveFile, DeleteNotAllowed, MeTubeRejected, delete_local_copy, @@ -78,6 +79,14 @@ def delete_download(youtube_video_id: str, db: Session = Depends(get_db)) -> dic job = delete_local_copy(db, video) except DeleteNotAllowed as exc: raise HTTPException(status_code=400, detail=str(exc)) + except DeleteDidNotRemoveFile: + raise HTTPException( + status_code=409, + detail=( + "MeTube убрал запись из своего списка, но файл остался на диске " + "(на mediaVM выключена настройка DELETE_FILE_ON_TRASHCAN)" + ), + ) except Exception: logger.exception("Failed to delete local copy for %s", youtube_video_id) raise HTTPException(status_code=502, detail="MeTube is unavailable") diff --git a/backend/app/services/download_jobs.py b/backend/app/services/download_jobs.py index eb92edf..d8ac4b8 100644 --- a/backend/app/services/download_jobs.py +++ b/backend/app/services/download_jobs.py @@ -19,6 +19,15 @@ class DeleteNotAllowed(Exception): pass +class DeleteDidNotRemoveFile(Exception): + """MeTube accepted the /delete request but the file is still reachable + afterwards -- its DELETE_FILE_ON_TRASHCAN setting is very likely off on + that instance, which is outside this app's control (see AGENTS.md + constraint 11: never touch MeTube's own config/source).""" + + pass + + def get_latest_job(db: Session, video_id: int) -> DownloadJob | None: return ( db.query(DownloadJob) @@ -69,7 +78,18 @@ def delete_local_copy(db: Session, video: Video) -> DownloadJob: if not job.metube_job_id: raise DeleteNotAllowed("Missing MeTube job id, cannot request deletion") - MeTubeClient().delete_download(job.metube_job_id) + client = MeTubeClient() + client.delete_download(job.metube_job_id) + + # MeTube's /delete only unlinks the file if it's configured with + # DELETE_FILE_ON_TRASHCAN=true -- otherwise it just drops the entry from + # its own "done" list and the file stays put. We don't control that + # instance's config, so verify rather than trust the "ok" response. + if job.media_url and client.check_media(job.media_url): + raise DeleteDidNotRemoveFile( + "MeTube removed the entry but the file is still on disk " + "(its DELETE_FILE_ON_TRASHCAN setting is likely off)" + ) job.status = "deleted" job.media_url = None diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/components/DownloadButton.tsx index 85ff346..b046fcf 100644 --- a/frontend/src/components/DownloadButton.tsx +++ b/frontend/src/components/DownloadButton.tsx @@ -75,6 +75,11 @@ function DownloadButton({ video }: Props) { > Удалить + {deleteMutation.isError && ( + + {(deleteMutation.error as Error).message} + + )} ) } diff --git a/tests/test_videos.py b/tests/test_videos.py index 4874711..3f62f52 100644 --- a/tests/test_videos.py +++ b/tests/test_videos.py @@ -184,6 +184,7 @@ def test_delete_download_success(client, db_session, monkeypatch): "app.services.metube_client.MeTubeClient.delete_download", lambda self, metube_job_id: calls.setdefault("id", metube_job_id) or {"status": "ok"}, ) + monkeypatch.setattr("app.services.metube_client.MeTubeClient.check_media", lambda self, url: False) resp = client.delete(f"/api/videos/{video.youtube_video_id}/download") @@ -195,3 +196,32 @@ def test_delete_download_success(client, db_session, monkeypatch): db_session.refresh(job) assert job.status == "deleted" assert job.media_url is None + + +def test_delete_download_file_still_present_returns_409(client, db_session, monkeypatch): + """MeTube can accept /delete and still leave the file on disk if its own + DELETE_FILE_ON_TRASHCAN config is off -- we must not lie about that.""" + from app.models.download_job import DownloadJob + + video = _seed_single_video(db_session) + job = DownloadJob( + video_id=video.id, + status="completed", + metube_job_id="vid1.vid1", + media_url="http://metube.local/download/f.mp4", + ) + db_session.add(job) + db_session.commit() + + monkeypatch.setattr( + "app.services.metube_client.MeTubeClient.delete_download", lambda self, metube_job_id: {"status": "ok"} + ) + monkeypatch.setattr("app.services.metube_client.MeTubeClient.check_media", lambda self, url: True) + + resp = client.delete(f"/api/videos/{video.youtube_video_id}/download") + + assert resp.status_code == 409 + + db_session.refresh(job) + assert job.status == "completed" + assert job.media_url == "http://metube.local/download/f.mp4" From 62f8fb746b0cd9515f5761cc9b4c308b80f2ea35 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 16 Sep 2026 20:14:43 +0000 Subject: [PATCH 02/10] Fix delete not finding the download: wrong id sent to MeTube's /delete MeTube's queue/pending/done stores are keyed by the download's URL (PersistentQueue.put: key = value.info.url), not by the id field a Download reports over Socket.IO (which is what we stored as metube_job_id and were sending). Sending the wrong key made MeTube's clear()/cancel() silently no-op ("requested delete for non-existent download" in its own logs) while still returning {"status": "ok"} regardless -- confirmed live by curling /history on the real instance and finding the "deleted" entry still present, unrelated to the DELETE_FILE_ON_TRASHCAN config fix that came right before this. delete_download() now takes the video's canonical youtube_url instead. Co-Authored-By: Claude Sonnet 5 --- backend/app/services/download_jobs.py | 4 +--- backend/app/services/metube_client.py | 17 ++++++++++++----- tests/test_videos.py | 9 ++++++--- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/backend/app/services/download_jobs.py b/backend/app/services/download_jobs.py index d8ac4b8..422ee31 100644 --- a/backend/app/services/download_jobs.py +++ b/backend/app/services/download_jobs.py @@ -75,11 +75,9 @@ def delete_local_copy(db: Session, video: Video) -> DownloadJob: job = get_latest_job(db, video.id) if job is None or job.status != "completed": raise DeleteNotAllowed("No completed local copy to delete") - if not job.metube_job_id: - raise DeleteNotAllowed("Missing MeTube job id, cannot request deletion") client = MeTubeClient() - client.delete_download(job.metube_job_id) + client.delete_download(video.youtube_url) # MeTube's /delete only unlinks the file if it's configured with # DELETE_FILE_ON_TRASHCAN=true -- otherwise it just drops the entry from diff --git a/backend/app/services/metube_client.py b/backend/app/services/metube_client.py index ffaf38b..029a193 100644 --- a/backend/app/services/metube_client.py +++ b/backend/app/services/metube_client.py @@ -77,16 +77,23 @@ class MeTubeClient: response.raise_for_status() return response.json() - def delete_download(self, metube_job_id: str) -> dict: + def delete_download(self, youtube_url: str) -> dict: """Asks MeTube to remove a finished download from its 'done' list and delete the underlying file (actual file deletion additionally depends on MeTube's own DELETE_FILE_ON_TRASHCAN config, which we don't - control). Only ever called with a metube_job_id our own app tracked - from a download it started -- never touches files MeTube already had - before we existed.""" + control). Only ever called for a URL our own app itself enqueued -- + never touches files MeTube already had before we existed. + + MeTube's queue/pending/done stores are keyed by the download's URL + (PersistentQueue.put: `key = value.info.url`), NOT by the id field a + Download reports over Socket.IO -- passing that id here silently + no-ops (MeTube logs "requested delete for non-existent download" and + still returns {"status": "ok"} regardless), which is what happened + before this was fixed: verified by curling this instance's /history + and finding the "deleted" entry still present.""" response = httpx.post( f"{self.api_base_url}/delete", - json={"ids": [metube_job_id], "where": "done"}, + json={"ids": [youtube_url], "where": "done"}, timeout=self.timeout, ) response.raise_for_status() diff --git a/tests/test_videos.py b/tests/test_videos.py index 3f62f52..437ce66 100644 --- a/tests/test_videos.py +++ b/tests/test_videos.py @@ -182,7 +182,7 @@ def test_delete_download_success(client, db_session, monkeypatch): calls = {} monkeypatch.setattr( "app.services.metube_client.MeTubeClient.delete_download", - lambda self, metube_job_id: calls.setdefault("id", metube_job_id) or {"status": "ok"}, + lambda self, youtube_url: calls.setdefault("youtube_url", youtube_url) or {"status": "ok"}, ) monkeypatch.setattr("app.services.metube_client.MeTubeClient.check_media", lambda self, url: False) @@ -191,7 +191,10 @@ def test_delete_download_success(client, db_session, monkeypatch): assert resp.status_code == 200 assert resp.json()["status"] == "deleted" assert resp.json()["media_url"] is None - assert calls["id"] == "vid1.vid1" + # MeTube's queue/done stores are keyed by URL, not by the id it reports + # over Socket.IO -- passing the wrong one is exactly the bug this + # regression test guards against (it silently no-ops on MeTube's side). + assert calls["youtube_url"] == video.youtube_url db_session.refresh(job) assert job.status == "deleted" @@ -214,7 +217,7 @@ def test_delete_download_file_still_present_returns_409(client, db_session, monk db_session.commit() monkeypatch.setattr( - "app.services.metube_client.MeTubeClient.delete_download", lambda self, metube_job_id: {"status": "ok"} + "app.services.metube_client.MeTubeClient.delete_download", lambda self, youtube_url: {"status": "ok"} ) monkeypatch.setattr("app.services.metube_client.MeTubeClient.check_media", lambda self, url: True) From b29bcfb8d7bc6f8c33dfc17d4ffd743132442006 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 16 Sep 2026 20:23:33 +0000 Subject: [PATCH 03/10] Hide unsubscribed channels from the channels list, add a Saved tab - GET /api/channels called with subscribed=true from the Channels page, so a channel disappears from the list as soon as it's unsubscribed instead of lingering with a badge - GET /api/feed gains a `downloaded` filter (this was already anticipated in the original TZ's feed query params but left unimplemented until download_jobs existed) -- matches on each video's *latest* job only, so a redownload/delete history doesn't leave stale matches - New /saved route: same category filtering, video grid and download actions as the main feed, scoped to downloaded videos only 2 new backend tests (76 total). Co-Authored-By: Claude Sonnet 5 --- backend/app/api/feed.py | 13 +++++- frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 12 +++++- frontend/src/pages/Channels.tsx | 2 + frontend/src/pages/Feed.tsx | 1 + frontend/src/pages/Saved.tsx | 70 +++++++++++++++++++++++++++++++++ tests/test_feed.py | 30 ++++++++++++++ 7 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/Saved.tsx diff --git a/backend/app/api/feed.py b/backend/app/api/feed.py index 73600b7..9391d79 100644 --- a/backend/app/api/feed.py +++ b/backend/app/api/feed.py @@ -2,13 +2,14 @@ import base64 from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select +from sqlalchemy import func, 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.download_job import DownloadJob from app.models.video import Video from app.services.download_jobs import latest_jobs_map from app.services.video_presentation import channel_categories_map, serialize_video @@ -41,6 +42,7 @@ def get_feed( category_id: int | None = None, uncategorized: bool = False, channel_id: int | None = None, + downloaded: bool = False, limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), cursor: str | None = None, db: Session = Depends(get_db), @@ -58,6 +60,15 @@ def get_feed( ) query = query.filter(Video.channel_id.in_(channel_ids_in_category)) + if downloaded: + # A video may have several download_jobs over time (retries, + # redownload after delete); only its most recent one counts. + latest_job_ids = select(func.max(DownloadJob.id)).group_by(DownloadJob.video_id) + completed_video_ids = select(DownloadJob.video_id).where( + DownloadJob.id.in_(latest_job_ids), DownloadJob.status == "completed" + ) + query = query.filter(Video.id.in_(completed_video_ids)) + if cursor: cursor_published_at, cursor_id = _decode_cursor(cursor) query = query.filter( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 391ffb8..beccbf6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom' import { getAuthStatus } from './api/client' import Connect from './pages/Connect' import Feed from './pages/Feed' +import Saved from './pages/Saved' import Channels from './pages/Channels' import ChannelVideos from './pages/ChannelVideos' import VideoPage from './pages/VideoPage' @@ -43,6 +44,7 @@ function App() {
} /> + } /> } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5e04728..176e4bd 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -64,12 +64,13 @@ export function getChannel(channelId: number) { } export function listChannels( - params: { search?: string; categoryId?: number; uncategorized?: boolean } = {}, + params: { search?: string; categoryId?: number; uncategorized?: boolean; subscribed?: boolean } = {}, ) { const qs = new URLSearchParams() if (params.search) qs.set('search', params.search) if (params.categoryId != null) qs.set('category_id', String(params.categoryId)) if (params.uncategorized) qs.set('uncategorized', 'true') + if (params.subscribed != null) qs.set('subscribed', String(params.subscribed)) const suffix = qs.toString() ? `?${qs.toString()}` : '' return request(`/api/channels${suffix}`) } @@ -199,12 +200,19 @@ export function recheckLocal(youtubeVideoId: string) { } export function getFeed( - params: { categoryId?: number; uncategorized?: boolean; channelId?: number; cursor?: string } = {}, + params: { + categoryId?: number + uncategorized?: boolean + channelId?: number + downloaded?: boolean + 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.downloaded) qs.set('downloaded', 'true') 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}`) diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index 8d4420b..babc3be 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -16,6 +16,7 @@ function Channels() { queryKey: ['channels', filter], queryFn: () => listChannels({ + subscribed: true, categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: filter === 'uncategorized', }), @@ -58,6 +59,7 @@ function Channels() {

Каналы

Лента + Сохранённые Категории + )} +
+
+ + ) +} + +export default Saved diff --git a/tests/test_feed.py b/tests/test_feed.py index 1e539a5..a451491 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -9,6 +9,7 @@ from app.main import app from app.models.category import Category from app.models.channel import Channel from app.models.channel_category import channel_categories +from app.models.download_job import DownloadJob from app.models.video import Video @@ -109,6 +110,35 @@ def test_feed_filters_uncategorized(client, db_session): assert ids == {"vid1", "vid3"} +def test_feed_filters_downloaded(client, db_session): + _, _, _, videos = _seed(db_session) + + completed = DownloadJob(video_id=videos[0].id, status="completed", media_url="http://x/f0.mp4") + failed = DownloadJob(video_id=videos[1].id, status="failed") + db_session.add_all([completed, failed]) + db_session.commit() + + resp = client.get("/api/feed?downloaded=true").json() + + ids = {i["youtube_video_id"] for i in resp["items"]} + assert ids == {"vid0"} + + +def test_feed_filters_downloaded_uses_latest_job_per_video(client, db_session): + _, _, _, videos = _seed(db_session) + + older = DownloadJob(video_id=videos[0].id, status="completed", media_url="http://x/f0.mp4") + db_session.add(older) + db_session.commit() + newer = DownloadJob(video_id=videos[0].id, status="deleted") + db_session.add(newer) + db_session.commit() + + resp = client.get("/api/feed?downloaded=true").json() + + assert resp["items"] == [] + + def test_feed_item_shape(client, db_session): channel_a, _, category, _ = _seed(db_session) From cdb1c2ded2820f7462105b6abdc633b7e263d2b3 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 16 Sep 2026 20:35:39 +0000 Subject: [PATCH 04/10] =?UTF-8?q?Show=20channel=20count=20for=20"=D0=91?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B8"=20on=20the=20categories=20management=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the existing GET /api/channels?uncategorized=true endpoint client-side rather than changing the /api/categories response shape, which stays a plain array (kept several existing call sites simple). Co-Authored-By: Claude Sonnet 5 --- frontend/src/App.css | 10 ++++++++++ frontend/src/pages/Categories.tsx | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/frontend/src/App.css b/frontend/src/App.css index 6448b72..b62c032 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -239,6 +239,16 @@ padding: 8px 12px; } +.category-row-virtual { + background: #f7f7f7; + border-style: dashed; + color: #888; +} + +.category-row-virtual .reorder-buttons { + width: 14px; +} + .reorder-buttons { display: flex; flex-direction: column; diff --git a/frontend/src/pages/Categories.tsx b/frontend/src/pages/Categories.tsx index e7b415c..d5ed522 100644 --- a/frontend/src/pages/Categories.tsx +++ b/frontend/src/pages/Categories.tsx @@ -5,6 +5,7 @@ import { createCategory, deleteCategory, listCategories, + listChannels, reorderCategories, updateCategory, } from '../api/client' @@ -16,6 +17,10 @@ function Categories() { const [editingName, setEditingName] = useState('') const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) + const uncategorizedQuery = useQuery({ + queryKey: ['channels', 'uncategorized-count'], + queryFn: () => listChannels({ subscribed: true, uncategorized: true }), + }) const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }) @@ -85,6 +90,14 @@ function Categories() { {createMutation.isError &&

{(createMutation.error as Error).message}

}
    +
  • +
    + Без категории + + {uncategorizedQuery.isLoading ? '…' : `${uncategorizedQuery.data?.length ?? 0} канал(ов)`} + +
  • + {categories.map((category, index) => (
  • From 44f81f0f0d936d5ce17c8a6d46a9f9ac21e1f7d0 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 16 Sep 2026 20:38:06 +0000 Subject: [PATCH 05/10] Show channel counts in the left CategoryNav sidebar (Feed/Channels/Saved) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same "channels per category" counts as the categories management page, now next to each button in the sidebar used across all three pages. Categories already carry channel_count; "Все"/"Без категории" counts are derived client-side from one extra unfiltered channels query, reused across the three pages via a shared ['channels', 'all-for-counts'] key. Co-Authored-By: Claude Sonnet 5 --- frontend/src/App.css | 12 ++++++++++++ frontend/src/components/CategoryNav.tsx | 7 ++++++- frontend/src/pages/Channels.tsx | 15 ++++++++++++++- frontend/src/pages/Feed.tsx | 17 +++++++++++++++-- frontend/src/pages/Saved.tsx | 17 +++++++++++++++-- 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index b62c032..82051b2 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -63,6 +63,10 @@ } .category-nav button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; text-align: left; padding: 6px 10px; border: none; @@ -76,6 +80,14 @@ font-weight: 600; } +.category-nav-count { + font-size: 11px; + color: #999; + background: #eee; + border-radius: 8px; + padding: 1px 6px; +} + .content { flex: 1; min-width: 0; diff --git a/frontend/src/components/CategoryNav.tsx b/frontend/src/components/CategoryNav.tsx index db942a2..d808533 100644 --- a/frontend/src/components/CategoryNav.tsx +++ b/frontend/src/components/CategoryNav.tsx @@ -6,16 +6,20 @@ interface Props { categories: CategoryDto[] value: CategoryFilter onChange: (filter: CategoryFilter) => void + allCount?: number + uncategorizedCount?: number } -function CategoryNav({ categories, value, onChange }: Props) { +function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount }: Props) { return ( diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index babc3be..43891d4 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -12,6 +12,13 @@ function Channels() { const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) + const allChannelsQuery = useQuery({ + queryKey: ['channels', 'all-for-counts'], + queryFn: () => listChannels({ subscribed: true }), + }) + const allChannels = allChannelsQuery.data ?? [] + const uncategorizedCount = allChannels.filter((c) => c.category_ids.length === 0).length + const channelsQuery = useQuery({ queryKey: ['channels', filter], queryFn: () => @@ -74,7 +81,13 @@ function Channels() { )}
    - +
    listChannels({ subscribed: true }), + }) + const allChannels = allChannelsQuery.data ?? [] + const uncategorizedCount = allChannels.filter((c) => c.category_ids.length === 0).length + const feedQuery = useInfiniteQuery({ queryKey: ['feed', filter], queryFn: ({ pageParam }) => @@ -69,7 +76,13 @@ function Feed() { )}
    - +
    {feedQuery.isLoading &&

    Загрузка...

    } diff --git a/frontend/src/pages/Saved.tsx b/frontend/src/pages/Saved.tsx index b03048e..b823bbf 100644 --- a/frontend/src/pages/Saved.tsx +++ b/frontend/src/pages/Saved.tsx @@ -1,6 +1,6 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { Link } from 'react-router-dom' -import { getFeed, listCategories } from '../api/client' +import { getFeed, listCategories, listChannels } from '../api/client' import CategoryNav, { type CategoryFilter } from '../components/CategoryNav' import VideoCard from '../components/VideoCard' import { useState } from 'react' @@ -11,6 +11,13 @@ function Saved() { const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) const categories = categoriesQuery.data ?? [] + const allChannelsQuery = useQuery({ + queryKey: ['channels', 'all-for-counts'], + queryFn: () => listChannels({ subscribed: true }), + }) + const allChannels = allChannelsQuery.data ?? [] + const uncategorizedCount = allChannels.filter((c) => c.category_ids.length === 0).length + const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'saved', filter], queryFn: ({ pageParam }) => @@ -38,7 +45,13 @@ function Saved() {
    - +
    {feedQuery.isLoading &&

    Загрузка...

    } From 90eb012e0d87b7baa41bdfba9727c672a96b3f06 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 16 Sep 2026 20:41:20 +0000 Subject: [PATCH 06/10] Show saved-video counts (not channel counts) in the Saved page sidebar GET /api/feed/saved-counts aggregates, per category, how many videos have a completed download (using each video's latest job only, same rule as the existing ?downloaded=true feed filter). CategoryNav gains an optional categoryCounts override so Feed/Channels keep showing channel_count while Saved shows this instead. Query key ['feed', 'saved-counts'] deliberately nests under 'feed' so DownloadButton's existing invalidateQueries({queryKey: ['feed']}) on download/delete refreshes these counts too, with no extra wiring. 1 new backend test (77 total). Co-Authored-By: Claude Sonnet 5 --- backend/app/api/feed.py | 44 +++++++++++++++++++++---- frontend/src/api/client.ts | 10 ++++++ frontend/src/components/CategoryNav.tsx | 9 +++-- frontend/src/pages/Saved.tsx | 20 ++++++----- tests/test_feed.py | 20 +++++++++++ 5 files changed, 85 insertions(+), 18 deletions(-) diff --git a/backend/app/api/feed.py b/backend/app/api/feed.py index 9391d79..2c9c596 100644 --- a/backend/app/api/feed.py +++ b/backend/app/api/feed.py @@ -37,6 +37,42 @@ def _decode_cursor(cursor: str) -> tuple[datetime, int]: raise HTTPException(status_code=400, detail="Invalid cursor") +def _completed_video_ids(): + # A video may have several download_jobs over time (retries, + # redownload after delete); only its most recent one counts. + latest_job_ids = select(func.max(DownloadJob.id)).group_by(DownloadJob.video_id) + return select(DownloadJob.video_id).where( + DownloadJob.id.in_(latest_job_ids), DownloadJob.status == "completed" + ) + + +@router.get("/feed/saved-counts") +def get_saved_counts(db: Session = Depends(get_db)) -> dict: + """Per-category counts of saved (downloaded) videos, for the Saved + page's sidebar -- distinct from /api/categories' channel_count.""" + completed_video_ids = _completed_video_ids() + + all_count = db.query(Video).filter(Video.id.in_(completed_video_ids)).count() + + categorized_channel_ids = select(channel_categories.c.channel_id) + uncategorized_count = ( + db.query(Video) + .filter(Video.id.in_(completed_video_ids), ~Video.channel_id.in_(categorized_channel_ids)) + .count() + ) + + rows = ( + db.query(channel_categories.c.category_id, func.count(func.distinct(Video.id))) + .join(Video, Video.channel_id == channel_categories.c.channel_id) + .filter(Video.id.in_(completed_video_ids)) + .group_by(channel_categories.c.category_id) + .all() + ) + categories = {str(category_id): count for category_id, count in rows} + + return {"all": all_count, "uncategorized": uncategorized_count, "categories": categories} + + @router.get("/feed") def get_feed( category_id: int | None = None, @@ -61,13 +97,7 @@ def get_feed( query = query.filter(Video.channel_id.in_(channel_ids_in_category)) if downloaded: - # A video may have several download_jobs over time (retries, - # redownload after delete); only its most recent one counts. - latest_job_ids = select(func.max(DownloadJob.id)).group_by(DownloadJob.video_id) - completed_video_ids = select(DownloadJob.video_id).where( - DownloadJob.id.in_(latest_job_ids), DownloadJob.status == "completed" - ) - query = query.filter(Video.id.in_(completed_video_ids)) + query = query.filter(Video.id.in_(_completed_video_ids())) if cursor: cursor_published_at, cursor_id = _decode_cursor(cursor) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 176e4bd..ce49d90 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -217,3 +217,13 @@ export function getFeed( const suffix = qs.toString() ? `?${qs.toString()}` : '' return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`) } + +export interface SavedCounts { + all: number + uncategorized: number + categories: Record +} + +export function getSavedCounts() { + return request('/api/feed/saved-counts') +} diff --git a/frontend/src/components/CategoryNav.tsx b/frontend/src/components/CategoryNav.tsx index d808533..de3bfb6 100644 --- a/frontend/src/components/CategoryNav.tsx +++ b/frontend/src/components/CategoryNav.tsx @@ -8,9 +8,12 @@ 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 } -function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount }: Props) { +function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount, categoryCounts }: Props) { return ( diff --git a/frontend/src/pages/Saved.tsx b/frontend/src/pages/Saved.tsx index b823bbf..85c251b 100644 --- a/frontend/src/pages/Saved.tsx +++ b/frontend/src/pages/Saved.tsx @@ -1,6 +1,6 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { Link } from 'react-router-dom' -import { getFeed, listCategories, listChannels } from '../api/client' +import { getFeed, getSavedCounts, listCategories } from '../api/client' import CategoryNav, { type CategoryFilter } from '../components/CategoryNav' import VideoCard from '../components/VideoCard' import { useState } from 'react' @@ -11,12 +11,13 @@ function Saved() { const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) const categories = categoriesQuery.data ?? [] - const allChannelsQuery = useQuery({ - queryKey: ['channels', 'all-for-counts'], - queryFn: () => listChannels({ subscribed: true }), - }) - const allChannels = allChannelsQuery.data ?? [] - const uncategorizedCount = allChannels.filter((c) => c.category_ids.length === 0).length + // 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], @@ -49,8 +50,9 @@ function Saved() { categories={categories} value={filter} onChange={setFilter} - allCount={allChannels.length} - uncategorizedCount={uncategorizedCount} + allCount={savedCounts?.all} + uncategorizedCount={savedCounts?.uncategorized} + categoryCounts={categoryCounts} />
    diff --git a/tests/test_feed.py b/tests/test_feed.py index a451491..bace6fc 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -139,6 +139,26 @@ def test_feed_filters_downloaded_uses_latest_job_per_video(client, db_session): assert resp["items"] == [] +def test_saved_counts(client, db_session): + channel_a, channel_b, category, videos = _seed(db_session) + + # vid0 is on channel_a (in "Linux"), vid1 is on channel_b (uncategorized). + db_session.add_all( + [ + DownloadJob(video_id=videos[0].id, status="completed", media_url="http://x/0.mp4"), + DownloadJob(video_id=videos[1].id, status="completed", media_url="http://x/1.mp4"), + DownloadJob(video_id=videos[2].id, status="failed"), + ] + ) + db_session.commit() + + resp = client.get("/api/feed/saved-counts").json() + + assert resp["all"] == 2 + assert resp["uncategorized"] == 1 + assert resp["categories"] == {str(category.id): 1} + + def test_feed_item_shape(client, db_session): channel_a, _, category, _ = _seed(db_session) From c4a772b1e32dd3b8585eb8248b7ae5e2c81f3121 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 16 Sep 2026 21:40:32 +0000 Subject: [PATCH 07/10] Redesign responsive MyTube interface --- README.md | 17 +- backend/app/api/feed.py | 10 +- frontend/index.html | 6 +- frontend/public/favicon.svg | 6 +- frontend/src/App.css | 757 ++++++++------------- frontend/src/App.tsx | 65 +- frontend/src/api/client.ts | 6 + frontend/src/components/AppShell.tsx | 101 +++ frontend/src/components/CategoryNav.tsx | 8 +- frontend/src/components/ChannelCard.tsx | 49 +- frontend/src/components/DownloadButton.tsx | 26 +- frontend/src/components/Icon.tsx | 32 + frontend/src/components/Player.tsx | 2 +- frontend/src/components/VideoCard.tsx | 20 +- frontend/src/index.css | 133 +--- frontend/src/pages/Categories.tsx | 165 +---- frontend/src/pages/ChannelVideos.tsx | 58 +- frontend/src/pages/Channels.tsx | 134 +--- frontend/src/pages/Connect.tsx | 12 +- frontend/src/pages/Feed.tsx | 182 +++-- frontend/src/pages/Settings.tsx | 29 + frontend/src/pages/VideoPage.tsx | 74 +- tests/test_feed.py | 14 + 23 files changed, 787 insertions(+), 1119 deletions(-) create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/components/Icon.tsx create mode 100644 frontend/src/pages/Settings.tsx diff --git a/README.md b/README.md index 7c7d962..8943fb9 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,23 @@ Персональный YouTube-клиент с категориями подписок и интеграцией с существующим MeTube. Полное техническое задание: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md). +UI/UX-спека (дизайн-система и сценарии интерфейса): [`UI_UX_spec.md`](./UI_UX_spec.md). -Текущий статус: **Phase 1 — skeleton** (FastAPI backend, PostgreSQL, Alembic, React frontend, Docker Compose, healthcheck). +Текущий статус: **функционально готово** (Phases 1-6 из ТЗ + hardening по ходу тестирования). Развёрнуто и вручную протестировано на тестовом окружении (`testmyyoutube.vrubel.xyz` → hermesVM `192.168.8.173` → MeTube на mediaVM `192.168.8.177`). + +### Что сделано + +- Skeleton, Google OAuth (single-user allow-list), sync подписок/видео (APScheduler), категории (CRUD + many-to-many), лента с курсорной пагинацией, YouTube-плеер, полная MeTube-интеграция (download/delete, Socket.IO live-статусы, recovery после рестарта). +- Раздел **«На сервере»** — скачанные видео; `/saved` перенаправляет на `/local`. +- Тёмный адаптивный интерфейс: общая навигация, категории с постоянными URL, поиск по названиям видео и каналов, мобильное меню. +- Отписанные каналы скрываются из списка каналов. +- **Отклонение от исходного MVP ТЗ (раздел 2)**: реальная отписка от канала на YouTube (`subscriptions.delete`) реализована по явной просьбе пользователя, хотя ТЗ изначально исключало управление подписками из MVP. Из-за этого OAuth scope расширен с `youtube.readonly` до полного `youtube` (read/write) — см. `backend/app/services/google_oauth.py`. +- Удаление скачанного видео с сервера (`DELETE /api/videos/{id}/download`) — тоже сверх исходного MVP-скоупа, добавлено по запросу. + +### Что осталось / сознательно отложено + +- Остальные пункты раздела 35 ТЗ («Возможные улучшения после MVP») не делались: PWA, watch later, Shorts-фильтр, SponsorBlock и т.д. +- Формальный Phase 7 hardening из ТЗ (retry/recovery, тесты) закрывался по факту находок в live-тестировании, а не отдельным проходом — см. `git log` для конкретных багфиксов (flapping статусов загрузки, неверный id в MeTube `/delete`, OAuth scope mismatch). ## Стек diff --git a/backend/app/api/feed.py b/backend/app/api/feed.py index 2c9c596..a50e81e 100644 --- a/backend/app/api/feed.py +++ b/backend/app/api/feed.py @@ -2,7 +2,7 @@ import base64 from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.core.auth_dependency import require_session @@ -79,6 +79,7 @@ def get_feed( uncategorized: bool = False, channel_id: int | None = None, downloaded: bool = False, + search: str | None = Query(None, max_length=200), limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), cursor: str | None = None, db: Session = Depends(get_db), @@ -99,6 +100,13 @@ def get_feed( if downloaded: query = query.filter(Video.id.in_(_completed_video_ids())) + if search and search.strip(): + escaped = search.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + needle = f"%{escaped}%" + query = query.join(Channel, Channel.id == Video.channel_id).filter( + or_(Video.title.ilike(needle, escape="\\"), Channel.title.ilike(needle, escape="\\")) + ) + if cursor: cursor_published_at, cursor_id = _decode_cursor(cursor) query = query.filter( diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..bd76a7b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,12 @@ - + - frontend + + + MyTube — моя лента
    diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 6893eb1..31a9f28 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css index 82051b2..bd94692 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,478 +1,279 @@ -.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 { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - 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; -} - -.category-nav-count { - font-size: 11px; - color: #999; - background: #eee; - border-radius: 8px; - padding: 1px 6px; -} - -.content { - flex: 1; - min-width: 0; -} - -.channel-list { - list-style: none; - padding: 0; - margin: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 12px; -} - -.channel-card { - display: flex; - align-items: flex-start; - gap: 10px; - border: 1px solid #ddd; - border-radius: 8px; - padding: 8px; -} - -.channel-card img { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; -} - -.channel-info { - flex: 1; - min-width: 0; -} - -.channel-title { - font-weight: 500; - color: inherit; - text-decoration: none; - display: block; -} - -.channel-title:hover { - text-decoration: underline; -} - -.badge { - font-size: 12px; - color: #999; -} - -.unsubscribe-button { - font-size: 12px; - padding: 2px 8px; - margin-left: 8px; - border: 1px solid #ccc; - border-radius: 10px; - background: transparent; - cursor: pointer; -} - -.unsubscribe-error { - font-size: 12px; - margin-top: 4px; -} - -.channel-categories { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 6px; - align-items: center; -} - -.chip { - font-size: 12px; - background: #eee; - border-radius: 12px; - padding: 2px 8px; -} - -.chip-edit { - font-size: 12px; - padding: 2px 8px; - border-radius: 12px; - border: 1px dashed #aaa; - background: transparent; - cursor: pointer; -} - -.category-popover-wrapper { - position: relative; - display: inline-block; -} - -.category-popover { - position: absolute; - top: calc(100% + 4px); - left: 0; - z-index: 10; - background: #fff; - border: 1px solid #ccc; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - padding: 8px; - display: flex; - flex-direction: column; - gap: 4px; - font-size: 13px; - min-width: 160px; - max-height: 240px; - overflow-y: auto; -} - -.category-popover-empty { - color: #999; - white-space: nowrap; -} - -.category-checkbox { - display: flex; - align-items: center; - gap: 6px; -} - -.categories-screen { - max-width: 600px; -} - -.back-link { - display: inline-block; - margin-bottom: 8px; -} - -.category-form { - display: flex; - gap: 8px; - margin-bottom: 16px; -} - -.category-form input { - flex: 1; - padding: 8px; -} - -.category-list { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 8px; -} - -.category-row { - display: flex; - align-items: center; - gap: 10px; - border: 1px solid #ddd; - border-radius: 8px; - padding: 8px 12px; -} - -.category-row-virtual { - background: #f7f7f7; - border-style: dashed; - color: #888; -} - -.category-row-virtual .reorder-buttons { - width: 14px; -} - -.reorder-buttons { - display: flex; - flex-direction: column; -} - -.reorder-buttons button { - border: none; - background: transparent; - cursor: pointer; - line-height: 1; -} - -.category-name { - flex: 1; - font-weight: 500; -} - -.category-count { - font-size: 12px; - color: #999; -} - -.category-edit-form { - flex: 1; - display: flex; - gap: 8px; -} - -.video-grid { - list-style: none; - padding: 0; - margin: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 16px; -} - -.video-card { - display: flex; - flex-direction: column; - border: 1px solid #ddd; - border-radius: 8px; - overflow: hidden; -} - -.video-thumbnail { - position: relative; - display: block; - aspect-ratio: 16 / 9; - background: #eee; -} - -.video-thumbnail img, -.video-thumbnail-placeholder { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -.video-thumbnail-placeholder { - background: #ddd; -} - -.duration-badge { - position: absolute; - right: 6px; - bottom: 6px; - background: rgba(0, 0, 0, 0.75); - color: #fff; - font-size: 12px; - padding: 1px 5px; - border-radius: 3px; -} - -.video-info { - padding: 8px 10px; -} - -.video-title { - font-weight: 500; - font-size: 14px; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - color: inherit; - text-decoration: none; -} - -.video-title:hover { - text-decoration: underline; -} - -.video-meta { - font-size: 12px; - color: #999; - margin-top: 4px; -} - -.video-actions { - margin-top: 8px; -} - -.button-link { - display: inline-block; - font-size: 13px; - padding: 4px 10px; - border: 1px solid #ccc; - border-radius: 6px; - text-decoration: none; - color: inherit; -} - -.load-more { - display: block; - margin: 16px auto 0; - padding: 8px 20px; - cursor: pointer; -} - -.video-page { - max-width: 900px; -} - -.player-wrapper { - position: relative; - width: 100%; - aspect-ratio: 16 / 9; - background: #000; - border-radius: 8px; - overflow: hidden; - margin-bottom: 16px; -} - -.player-wrapper iframe, -.player-wrapper video { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - border: 0; - background: #000; -} - -.download-badge { - display: inline-block; - font-size: 12px; - padding: 3px 8px; - border-radius: 10px; - background: #eee; -} - -.download-badge.completed { - background: #dcf5df; - color: #1a7f2e; -} - -.download-badge.error { - background: #fbdcdc; - color: #a3221f; -} - -.download-error, -.download-inline-actions { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.video-actions button, -.video-page-meta button { - font-size: 13px; - padding: 4px 10px; - border: 1px solid #ccc; - border-radius: 6px; - background: transparent; - cursor: pointer; -} - -.video-page-title { - font-size: 20px; - margin: 0 0 8px; -} - -.video-page-meta { - color: #999; - font-size: 14px; - margin-bottom: 4px; -} - -.video-page-meta a { - color: inherit; -} - -.video-page-source { - font-size: 13px; - color: #999; - margin-bottom: 8px; -} - -.video-page-categories { - display: flex; - gap: 6px; - margin-bottom: 16px; -} - -.video-page-description { - white-space: pre-wrap; - font-size: 14px; - line-height: 1.5; - border-top: 1px solid #ddd; - padding-top: 16px; -} +#root { min-height: 100vh; } +.app-shell { min-height: 100vh; } +.topbar { height: 66px; position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 28px; padding: 0 26px; background: rgba(15,15,15,.96); border-bottom: 1px solid var(--border); backdrop-filter: blur(16px); } +.brand { display: inline-flex; align-items: center; gap: 10px; min-width: 214px; text-decoration: none; font-size: 21px; font-weight: 750; letter-spacing: -.7px; color: var(--text); white-space: nowrap; } +.brand-mark { display: inline-grid; place-items: center; flex: none; width: 34px; height: 27px; border-radius: 8px; background: var(--accent); color: #07121b; } +.brand-accent { color: var(--accent); } +.header-search { margin: 0 auto; width: min(560px, 100%); height: 40px; border: 1px solid var(--border); border-radius: 999px; display: flex; align-items: center; gap: 11px; padding: 0 14px; color: var(--muted); background: var(--surface); transition: border-color .18s, background .18s; } +.header-search:focus-within { border-color: var(--accent); background: var(--surface-raised); } +.header-search input { flex: 1; min-width: 0; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 14px; } +.header-search input::placeholder { color: var(--subtle); } +.header-search button { border: 0; background: none; color: var(--muted); display: grid; place-items: center; padding: 2px; } +.topbar-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 214px; } +.icon-button { display: inline-grid; place-items: center; width: 44px; height: 44px; flex: none; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: 10px; text-decoration: none; } +.icon-button:hover { color: var(--text); background: var(--hover); } +.sync-wrap { position: relative; } +.sync-indicator { min-height: 40px; display: inline-flex; align-items: center; gap: 8px; border: 0; border-radius: 9px; padding: 0 10px; background: transparent; color: var(--muted); font-size: 12px; white-space: nowrap; } +.sync-indicator svg { color: var(--success); } +.sync-indicator.is-error svg { color: var(--error); } +.sync-indicator:hover { background: var(--hover); color: var(--text); } +.sync-popover { position: absolute; top: calc(100% + 12px); right: 0; width: 280px; padding: 17px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-raised); box-shadow: 0 16px 50px rgba(0,0,0,.5); font-size: 13px; color: var(--muted); } +.sync-popover strong { display: block; color: var(--text); margin-bottom: 12px; } +.sync-popover p + p { margin-top: 9px; } +.mobile-menu, .drawer-header, .drawer-backdrop { display: none; } +.app-sidebar { position: fixed; z-index: 30; top: 66px; left: 0; bottom: 0; width: 244px; overflow-y: auto; border-right: 1px solid var(--border); background: var(--bg); } +.sidebar-nav { display: flex; flex-direction: column; min-height: 100%; padding: 20px 12px; } +.sidebar-group { display: grid; gap: 3px; padding-bottom: 21px; } +.sidebar-group + .sidebar-group { padding-top: 21px; border-top: 1px solid var(--border); } +.sidebar-heading { padding: 0 13px 11px; color: var(--subtle); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } +.sidebar-link { display: flex; align-items: center; gap: 14px; min-height: 42px; border-radius: 9px; padding: 0 13px; color: var(--muted); font-size: 14px; font-weight: 500; text-decoration: none; transition: background .16s, color .16s; } +.sidebar-link:hover { color: var(--text); background: var(--hover); } +.sidebar-link.active { color: var(--text); background: var(--surface-raised); font-weight: 650; } +.sidebar-link.active svg { color: var(--accent); } +.category-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.category-link { gap: 15px; padding-left: 18px; } +.category-link.active .category-dot { background: var(--accent); } +.sidebar-count { margin-left: auto; font-size: 11px; color: var(--subtle); } +.sidebar-add { color: var(--accent); font-size: 13px; margin-top: 4px; } +.sidebar-hint { margin: 0; padding: 5px 13px 9px; color: var(--subtle); font-size: 12px; } +.sidebar-bottom { margin-top: auto; padding-bottom: 0; } +.truncate { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.main-content { margin-left: 244px; min-width: 0; } +.page { max-width: 1820px; margin: 0 auto; padding: 35px clamp(24px, 3vw, 54px) 75px; } +.page-heading { display: flex; justify-content: space-between; align-items: flex-end; gap: 22px; margin-bottom: 28px; } +.eyebrow { color: var(--accent); text-transform: uppercase; letter-spacing: .13em; font-size: 11px; font-weight: 700; margin: 0 0 8px; } +h1, h2, h3, p { margin-top: 0; } +h1 { color: var(--text); font-size: clamp(25px, 2.25vw, 34px); line-height: 1.18; letter-spacing: -.035em; font-weight: 700; margin-bottom: 8px; } +h2 { color: var(--text); font-size: 18px; line-height: 1.3; font-weight: 650; } +.page-subtitle { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; } +button, .button-primary, .button-secondary, .button-quiet, .button-danger, .button-link { transition: background .16s, color .16s, border-color .16s; } +.button-primary, .button-secondary, .button-danger, .button-link { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; padding: 0 15px; text-decoration: none; font-size: 13px; font-weight: 650; white-space: nowrap; } +.button-primary { border: 1px solid var(--accent); background: var(--accent); color: #081521; } +.button-primary:hover { background: #79c1ff; border-color: #79c1ff; } +.button-secondary, .button-link { border: 1px solid var(--border); background: var(--surface-raised); color: var(--text); } +.button-secondary:hover, .button-link:hover { background: var(--hover); border-color: #555; } +.button-quiet { min-height: 40px; padding: 0 10px; display: inline-flex; align-items: center; gap: 7px; background: transparent; border: 0; color: var(--muted); border-radius: 8px; font-size: 13px; text-decoration: none; } +.button-quiet:hover { color: var(--text); background: var(--hover); } +.button-danger { background: #a72c2a; border: 1px solid #a72c2a; color: white; } +.button-danger:hover { background: #c93633; } +.error { color: var(--error); } +.notice { display: flex; align-items: center; gap: 10px; padding: 13px 16px; margin: 0 0 24px; border-radius: 9px; font-size: 13px; } +.error-notice { border: 1px solid rgba(255,107,104,.25); color: #ffb1ae; background: rgba(255,107,104,.09); } +.onboarding-notice { flex-wrap: wrap; border: 1px solid rgba(62,166,255,.25); color: var(--text); background: var(--accent-soft); } +.onboarding-notice > span { flex: 1; min-width: 190px; } +.onboarding-notice a { display: inline-flex; align-items: center; gap: 3px; color: var(--accent); text-decoration: none; font-weight: 650; white-space: nowrap; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.spin { animation: spin 1.6s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.mobile-category-nav { display: none; } +.video-grid { list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(265px, 1fr)); gap: 31px 18px; padding: 0; margin: 0; } +.video-card { min-width: 0; display: flex; flex-direction: column; } +.video-thumbnail { display: block; position: relative; overflow: hidden; width: 100%; aspect-ratio: 16 / 9; border-radius: 11px; background: var(--surface-raised); } +.video-thumbnail img, .video-thumbnail-placeholder { width: 100%; height: 100%; display: block; object-fit: cover; } +.video-thumbnail-placeholder { background: linear-gradient(135deg, #232323, #303030); } +.duration-badge { position: absolute; bottom: 8px; right: 8px; border-radius: 4px; background: rgba(0,0,0,.82); color: white; padding: 3px 5px; font-size: 11px; font-weight: 650; line-height: 1; } +.video-info { padding: 11px 2px 0; min-width: 0; flex: 1; display: flex; flex-direction: column; } +.video-title { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; color: var(--text); text-decoration: none; font-size: 15px; font-weight: 620; line-height: 1.42; } +.video-title:hover { color: var(--accent); } +.video-meta { display: flex; flex-wrap: wrap; gap: 2px 7px; margin-top: 7px; color: var(--muted); font-size: 12px; line-height: 1.4; } +.video-channel { color: #c8c8c8; } +.video-meta span + span::before { content: '·'; margin-right: 7px; color: var(--subtle); } +.video-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: auto; padding-top: 13px; } +.video-actions .button-link, .video-actions .button-secondary { min-height: 36px; padding: 0 11px; } +.download-inline-actions, .download-error { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 6px; } +.download-badge { position: relative; display: inline-flex; align-items: center; gap: 5px; min-height: 36px; overflow: hidden; border-radius: 7px; padding: 0 10px; background: var(--surface-raised); color: var(--muted); font-size: 12px; white-space: nowrap; } +.download-badge.completed { color: var(--success); background: var(--success-soft); } +.download-badge.error { color: var(--error); background: rgba(255,107,104,.13); } +.download-badge.downloading { color: var(--warning); background: rgba(245,166,35,.11); } +.download-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--warning); } +.download-action-error { color: var(--error); font-size: 12px; } +.load-more { display: flex; margin: 34px auto 0; } +.empty-state { max-width: 520px; margin: clamp(45px, 8vh, 100px) auto; padding: 30px; text-align: center; color: var(--muted); } +.empty-state h2 { margin: 0 0 9px; font-size: 20px; } +.empty-state p { font-size: 14px; line-height: 1.55; margin-bottom: 20px; } +.empty-icon { width: 60px; height: 60px; margin: 0 auto 18px; display: grid; place-items: center; color: var(--accent); border-radius: 18px; background: var(--accent-soft); } +.skeleton-thumbnail, .skeleton-line, .channel-skeleton span, .channel-skeleton i { background: linear-gradient(90deg, #222 25%, #303030 50%, #222 75%); background-size: 200% 100%; animation: shimmer 1.4s ease-in-out infinite; } +.skeleton-thumbnail { aspect-ratio: 16 / 9; border-radius: 11px; } +.skeleton-line { height: 13px; border-radius: 5px; width: 70%; margin: 8px 0; } +.skeleton-line.wide { width: 93%; } +.skeleton-line.short { width: 44%; } +@keyframes shimmer { to { background-position: -200% 0; } } +.channels-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 18px; } +.input-search { display: flex; align-items: center; gap: 10px; width: min(420px, 100%); height: 43px; border: 1px solid var(--border); border-radius: 9px; padding: 0 12px; color: var(--subtle); background: var(--surface); } +.input-search:focus-within { border-color: var(--accent); } +.input-search input { width: 100%; min-width: 0; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 14px; } +.input-search input::placeholder { color: var(--subtle); } +.category-nav { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: 0 0 19px; padding-bottom: 2px; } +.category-nav::-webkit-scrollbar, .mobile-category-nav::-webkit-scrollbar { display: none; } +.category-nav button { display: inline-flex; align-items: center; gap: 8px; min-height: 36px; flex: none; border: 1px solid var(--border); border-radius: 999px; padding: 0 13px; background: var(--surface); color: var(--muted); font-size: 12px; } +.category-nav button:hover { background: var(--hover); color: var(--text); } +.category-nav button.active { background: var(--accent-soft); border-color: rgba(62,166,255,.55); color: var(--accent); } +.category-nav-count { color: var(--subtle); font-size: 11px; } +.category-nav button.active .category-nav-count { color: var(--accent); } +.local-filter-nav { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: -3px 0 23px; padding-bottom: 3px; } +.local-filter-nav::-webkit-scrollbar { display: none; } +.local-filter-nav a { display: inline-flex; align-items: center; gap: 8px; flex: none; min-height: 36px; border: 1px solid var(--border); border-radius: 999px; padding: 0 13px; color: var(--muted); background: var(--surface); font-size: 12px; text-decoration: none; } +.local-filter-nav a:hover { color: var(--text); background: var(--hover); } +.local-filter-nav a.active { color: var(--accent); border-color: rgba(62,166,255,.55); background: var(--accent-soft); } +.local-filter-nav span { font-size: 11px; color: var(--subtle); } +.local-filter-nav a.active span { color: var(--accent); } +.channel-list { list-style: none; max-width: 1000px; padding: 0; margin: 0; border-top: 1px solid var(--border); } +.channel-card { display: flex; align-items: flex-start; gap: 15px; padding: 18px 3px; border-bottom: 1px solid var(--border); } +.channel-card > img, .channel-avatar-placeholder { display: grid; place-items: center; width: 48px; height: 48px; flex: none; border-radius: 50%; object-fit: cover; background: #344557; color: var(--text); font-size: 18px; font-weight: 700; } +.channel-info { flex: 1; min-width: 0; } +.channel-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; min-height: 23px; } +.channel-title { font-size: 15px; font-weight: 650; text-decoration: none; } +.channel-title:hover { color: var(--accent); } +.badge { font-size: 11px; color: var(--subtle); } +.unsubscribe-button { margin-left: auto; min-height: 28px; border: 0; border-radius: 6px; padding: 0 8px; color: var(--subtle); background: transparent; font-size: 11px; } +.unsubscribe-button:hover { color: var(--error); background: rgba(255,107,104,.1); } +.channel-categories { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-top: 10px; } +.chip, .chip-edit { display: inline-flex; align-items: center; gap: 5px; min-height: 29px; border: 1px solid var(--border); border-radius: 999px; padding: 0 10px; background: var(--surface-raised); color: var(--muted); text-decoration: none; font-size: 12px; } +.chip:hover { color: var(--text); } +.removable-chip:hover { border-color: var(--error); color: var(--text); } +.chip-edit { border-style: dashed; color: var(--accent); background: transparent; } +.chip-edit:hover { background: var(--accent-soft); border-color: var(--accent); } +.category-popover-wrapper { position: relative; } +.category-popover { position: absolute; z-index: 20; left: 0; top: calc(100% + 8px); display: grid; gap: 4px; min-width: 230px; max-width: min(290px, calc(100vw - 40px)); max-height: 330px; overflow-y: auto; padding: 12px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface-raised); box-shadow: 0 16px 45px rgba(0,0,0,.55); } +.category-popover strong { padding: 3px 7px 7px; font-size: 13px; color: var(--text); } +.category-popover-empty { padding: 8px; font-size: 12px; color: var(--muted); } +.category-checkbox { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 8px; border-radius: 6px; color: var(--text); font-size: 13px; cursor: pointer; } +.category-checkbox:hover { background: var(--hover); } +.category-checkbox input { accent-color: var(--accent); width: 16px; height: 16px; } +.popover-create { display: flex; gap: 5px; margin-top: 7px; padding-top: 9px; border-top: 1px solid var(--border); } +.popover-create input { min-width: 0; flex: 1; height: 36px; border: 1px solid var(--border); border-radius: 6px; padding: 0 8px; background: var(--surface); color: var(--text); font-size: 12px; } +.popover-create button { display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 6px; background: var(--accent); color: #081521; } +.channel-loading { display: grid; gap: 1px; max-width: 1000px; } +.channel-skeleton { display: flex; align-items: center; gap: 15px; height: 83px; border-bottom: 1px solid var(--border); } +.channel-skeleton span { width: 48px; height: 48px; border-radius: 50%; } +.channel-skeleton i { width: 45%; height: 14px; border-radius: 5px; } +.back-link { display: inline-flex; align-items: center; gap: 8px; min-height: 36px; padding: 0; margin: 0 0 20px; border: 0; background: transparent; color: var(--muted); text-decoration: none; font-size: 13px; } +.back-link:hover { color: var(--text); } +.video-page { max-width: 1320px; } +.player-wrapper { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 11px; background: #000; } +.player-wrapper iframe, .player-wrapper video { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: #000; } +.video-page-source { margin: 11px 0 22px; color: var(--muted); font-size: 12px; } +.video-detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 25px; } +.video-page-title { font-size: clamp(21px, 2vw, 29px); line-height: 1.32; margin-bottom: 9px; } +.video-page-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; color: var(--muted); font-size: 13px; } +.video-page-meta a { color: var(--text); text-decoration: none; } +.video-page-meta a:hover { color: var(--accent); } +.video-page-categories { display: flex; flex-wrap: wrap; gap: 7px; margin: 18px 0 24px; } +.video-page-description { border-top: 1px solid var(--border); padding-top: 24px; margin-top: 24px; } +.video-page-description h2 { font-size: 16px; margin-bottom: 12px; } +.video-page-description p { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); font-size: 14px; line-height: 1.65; } +.categories-screen { max-width: 1000px; } +.category-form { display: flex; gap: 10px; max-width: 630px; margin-bottom: 28px; } +.category-form input, .category-edit-form input { flex: 1; min-width: 0; height: 42px; border: 1px solid var(--border); border-radius: 8px; padding: 0 13px; background: var(--surface); color: var(--text); font-size: 14px; } +.category-form input:focus, .category-edit-form input:focus { outline: 2px solid var(--accent); outline-offset: 2px; } +.category-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border); } +.category-row { display: flex; align-items: center; gap: 14px; min-height: 70px; border-bottom: 1px solid var(--border); } +.reorder-buttons { display: flex; flex-direction: column; gap: 0; } +.reorder-buttons .icon-button { width: 31px; height: 28px; } +.category-row-label { display: flex; align-items: center; gap: 13px; flex: 1; min-width: 0; } +.category-name { overflow: hidden; text-overflow: ellipsis; font-size: 14px; font-weight: 650; } +.category-count { color: var(--muted); font-size: 12px; white-space: nowrap; } +.category-row-actions { display: flex; gap: 3px; } +.category-row-actions .danger-hover:hover { color: var(--error); } +.category-edit-form { flex: 1; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(0,0,0,.72); } +.confirm-dialog { width: min(100%, 430px); padding: 25px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: 0 24px 70px rgba(0,0,0,.6); } +.confirm-dialog h2 { margin-bottom: 10px; } +.confirm-dialog p { color: var(--muted); font-size: 14px; line-height: 1.55; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 22px; } +.settings-screen { max-width: 1250px; } +.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } +.settings-panel { min-width: 0; padding: 23px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); } +.settings-panel-heading { display: flex; align-items: center; gap: 13px; margin-bottom: 22px; } +.settings-panel-heading h2 { margin: 0 0 2px; font-size: 16px; } +.settings-panel-heading p { margin: 0; color: var(--muted); font-size: 12px; } +.panel-icon { display: grid; place-items: center; width: 39px; height: 39px; flex: none; border-radius: 10px; background: var(--accent-soft); color: var(--accent); } +.settings-detail { display: flex; justify-content: space-between; gap: 15px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; } +.settings-detail span { color: var(--muted); } +.settings-detail strong { font-weight: 550; text-align: right; overflow-wrap: anywhere; } +.settings-detail .success-text { color: var(--success); } +.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--success); } +.settings-help { color: var(--muted); font-size: 13px; line-height: 1.5; } +.settings-actions { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 15px; } +.logout-button { margin-top: 24px; } +.connect-screen { min-height: 100vh; display: grid; place-items: center; padding: 25px; background: radial-gradient(circle at 50% 15%, #173046 0%, var(--bg) 42%); } +.connect-card { width: min(100%, 460px); padding: clamp(25px, 5vw, 44px); border: 1px solid var(--border); border-radius: 18px; background: var(--surface); text-align: center; box-shadow: 0 26px 75px rgba(0,0,0,.28); } +.connect-card .brand-mark { margin: 0 auto 22px; width: 52px; height: 40px; } +.connect-card h1 { font-size: 29px; } +.connect-card p { color: var(--muted); font-size: 14px; line-height: 1.6; } +.connect-card button { margin-top: 22px; } +.app-loading { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; padding: 30px; color: var(--muted); text-align: center; } +.app-loading h1 { font-size: 24px; } +.app-loading button { min-height: 42px; padding: 0 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text); } +@media (min-width: 1800px) { .video-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } } +@media (min-width: 1400px) and (max-width: 1799px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } +@media (min-width: 1100px) and (max-width: 1399px) { .video-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 900px) { + .topbar { gap: 14px; padding: 0 18px; } + .brand, .topbar-actions { min-width: 0; } + .mobile-menu { display: inline-grid; margin-left: -8px; } + .app-sidebar { z-index: 80; top: 0; bottom: 0; width: min(290px, 85vw); visibility: hidden; transform: translateX(-100%); transition: transform .22s ease, visibility .22s; box-shadow: 18px 0 45px rgba(0,0,0,.45); } + .app-sidebar.open { visibility: visible; transform: translateX(0); } + .drawer-header { height: 66px; display: flex; align-items: center; justify-content: space-between; padding: 0 18px 0 26px; color: var(--text); font-weight: 650; border-bottom: 1px solid var(--border); } + .drawer-backdrop { display: block; position: fixed; z-index: 70; inset: 0; border: 0; padding: 0; background: rgba(0,0,0,.64); } + body.drawer-open { overflow: hidden; } + .main-content { margin-left: 0; } + .page { padding: 28px 28px 65px; } + .video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .mobile-category-nav { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: -5px -28px 25px; padding: 0 28px 5px; } + .mobile-category-nav a { flex: none; min-height: 35px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 0 14px; background: var(--surface); color: var(--muted); font-size: 12px; text-decoration: none; } + .mobile-category-nav a.active { border-color: rgba(62,166,255,.55); background: var(--accent-soft); color: var(--accent); } +} +@media (max-width: 620px) { + .topbar { height: 112px; display: grid; grid-template-columns: 44px 1fr auto; grid-template-rows: 55px 49px; column-gap: 7px; row-gap: 0; padding: 0 15px 8px; } + .brand { font-size: 19px; } + .brand-mark { width: 31px; height: 25px; } + .mobile-menu { margin-left: -7px; } + .header-search { grid-column: 1 / -1; grid-row: 2; width: 100%; height: 44px; } + .topbar-actions { grid-column: 3; grid-row: 1; } + .sync-indicator { width: 40px; padding: 0; justify-content: center; } + .sync-indicator span { display: none; } + .header-settings { display: none; } + .page { padding: 24px 16px 58px; } + .page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; } + .page-heading h1 { font-size: 27px; } + .refresh-button { width: 42px; height: 42px; flex: none; overflow: hidden; font-size: 0; padding: 0; } + .refresh-button svg { flex: none; } + .mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; } + .video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; } + .video-thumbnail { border-radius: 9px; } + .video-info { padding: 10px 2px 0; } + .video-actions { padding-top: 11px; } + .video-actions .button-link, .video-actions .button-secondary, .download-badge { min-height: 44px; } + .channels-toolbar { align-items: stretch; } + .manage-link { width: 43px; flex: none; overflow: hidden; font-size: 0; padding: 0; } + .manage-link svg { flex: none; } + .channel-card { gap: 11px; padding: 16px 0; } + .channel-card > img, .channel-avatar-placeholder { width: 42px; height: 42px; } + .unsubscribe-button { margin-left: 0; } + .category-popover { left: auto; right: -5px; } + .category-checkbox, .chip, .chip-edit { min-height: 44px; } + .category-nav button, .mobile-category-nav a { min-height: 44px; } + .local-filter-nav a { min-height: 44px; } + .video-detail-head { flex-direction: column; gap: 9px; } + .player-wrapper { border-radius: 0; margin-left: -16px; width: calc(100% + 32px); } + .video-page-source { margin-top: 9px; } + .settings-grid { grid-template-columns: 1fr; } + .settings-panel { padding: 18px; } + .category-form { flex-wrap: wrap; } + .category-form input { flex-basis: 100%; } + .category-row { gap: 8px; } + .reorder-buttons .icon-button { width: 40px; height: 40px; } + .category-row-label { flex-wrap: wrap; gap: 5px 10px; } + .category-count { flex-basis: 100%; margin-left: 21px; } + .category-edit-form input { flex-basis: 100%; } + .sync-popover { right: -7px; max-width: calc(100vw - 30px); } +} +@media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } } +@media (pointer: coarse) { + .video-actions .button-link, .video-actions .button-secondary, .download-badge, .category-nav button, .local-filter-nav a, .mobile-category-nav a, .chip, .chip-edit, .category-checkbox, .unsubscribe-button { min-height: 44px; } + .reorder-buttons .icon-button { width: 40px; height: 40px; } + .popover-create input, .popover-create button { height: 44px; } + .popover-create button { width: 44px; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index beccbf6..a8c762b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,57 +1,40 @@ import { useQuery } from '@tanstack/react-query' -import { Route, Routes } from 'react-router-dom' +import { Link, Navigate, Route, Routes } from 'react-router-dom' import { getAuthStatus } from './api/client' +import AppShell from './components/AppShell' import Connect from './pages/Connect' import Feed from './pages/Feed' -import Saved from './pages/Saved' import Channels from './pages/Channels' import ChannelVideos from './pages/ChannelVideos' import VideoPage from './pages/VideoPage' import Categories from './pages/Categories' +import Settings from './pages/Settings' import './App.css' function App() { - const { data, isLoading, isError } = useQuery({ - queryKey: ['auth-status'], - queryFn: getAuthStatus, - }) + const { data, isLoading, isError } = useQuery({ queryKey: ['auth-status'], queryFn: getAuthStatus }) - if (isLoading) { - return ( -
    -

    Загрузка...

    -
    - ) - } + if (isLoading) return
    ▶

    Открываем MyTube…

    + if (isError) return

    Не удалось открыть MyTube

    Проверь подключение к серверу и обнови страницу.

    + if (!data?.authenticated) return - if (isError) { - return ( -
    -

    Backend недоступен

    -
    - ) - } - - if (!data?.authenticated) { - return ( -
    - -
    - ) - } - - return ( -
    - - } /> - } /> - } /> - } /> - } /> - } /> - -
    - ) + return + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> +

    Страница не найдена

    Вернуться к ленте
    } /> + + } export default App diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ce49d90..564d242 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -39,6 +39,10 @@ export function getAuthStatus() { return request('/api/auth/status') } +export function getHealth() { + return request<{ status: string; database: string; metube: string }>('/api/health') +} + export function startGoogleAuth() { window.location.href = '/api/auth/google/start' } @@ -205,6 +209,7 @@ export function getFeed( uncategorized?: boolean channelId?: number downloaded?: boolean + search?: string cursor?: string } = {}, ) { @@ -213,6 +218,7 @@ export function getFeed( if (params.uncategorized) qs.set('uncategorized', 'true') if (params.channelId != null) qs.set('channel_id', String(params.channelId)) if (params.downloaded) qs.set('downloaded', 'true') + if (params.search) qs.set('search', params.search) if (params.cursor) qs.set('cursor', params.cursor) const suffix = qs.toString() ? `?${qs.toString()}` : '' return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`) diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..3c07ae4 --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom' +import { getSyncStatus, listCategories } from '../api/client' +import { formatRelativeTime } from '../utils/format' +import Icon from './Icon' + +function SyncIndicator() { + const [open, setOpen] = useState(false) + const ref = useRef(null) + const { data } = useQuery({ + queryKey: ['sync-status'], queryFn: getSyncStatus, + refetchInterval: (query) => query.state.data?.videos.running || query.state.data?.subscriptions.running ? 2000 : 60_000, + }) + useEffect(() => { + if (!open) return + const close = (event: MouseEvent) => { if (!ref.current?.contains(event.target as Node)) setOpen(false) } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [open]) + const running = data?.videos.running || data?.subscriptions.running + const failed = data?.videos.status === 'failed' || data?.subscriptions.status === 'failed' + const recent = [data?.videos.finished_at, data?.subscriptions.finished_at].filter((value): value is string => Boolean(value)).sort().at(-1) + const label = running ? 'Обновление...' : failed ? 'Ошибка синхронизации' : recent ? `Обновлено ${formatRelativeTime(recent)}` : 'Синхронизация' + return
    + + {open &&
    + Синхронизация +

    Подписки: {data?.subscriptions.running ? 'обновляются' : data?.subscriptions.finished_at ? formatRelativeTime(data.subscriptions.finished_at) : 'ещё не обновлялись'}

    +

    Видео: {data?.videos.running ? 'обновляются' : data?.videos.finished_at ? formatRelativeTime(data.videos.finished_at) : 'ещё не обновлялись'}

    + {failed &&

    Последнее обновление завершилось ошибкой. Подробности в настройках.

    } +
    } +
    +} + +function Sidebar({ close }: { close: () => void }) { + const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) + const categories = categoriesQuery.data ?? [] + const links = [ + { to: '/', icon: 'home' as const, text: 'Все видео', end: true }, + { to: '/uncategorized', icon: 'folder' as const, text: 'Без категории' }, + { to: '/local', icon: 'server' as const, text: 'На сервере' }, + ] + return +} + +function SearchForm({ initialValue }: { initialValue: string }) { + const [search, setSearch] = useState(initialValue) + const navigate = useNavigate() + return
    { event.preventDefault(); navigate(search.trim() ? `/search?q=${encodeURIComponent(search.trim())}` : '/') }}> + + setSearch(event.target.value)} /> + {search && } + +} + +export default function AppShell({ children }: { children: React.ReactNode }) { + const [drawerOpen, setDrawerOpen] = useState(false) + const drawerCloseRef = useRef(null) + const location = useLocation() + const initialSearch = location.pathname === '/search' ? new URLSearchParams(location.search).get('q') ?? '' : '' + useEffect(() => { + document.body.classList.toggle('drawer-open', drawerOpen) + if (drawerOpen) drawerCloseRef.current?.focus() + const onEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setDrawerOpen(false) } + if (drawerOpen) document.addEventListener('keydown', onEscape) + else document.removeEventListener('keydown', onEscape) + return () => { document.body.classList.remove('drawer-open'); document.removeEventListener('keydown', onEscape) } + }, [drawerOpen]) + return
    +
    + + MyTube + +
    +
    + {drawerOpen &&
    + setDrawerOpen(false)} /> + +
    {children}
    +
    +} diff --git a/frontend/src/components/CategoryNav.tsx b/frontend/src/components/CategoryNav.tsx index de3bfb6..d6397c7 100644 --- a/frontend/src/components/CategoryNav.tsx +++ b/frontend/src/components/CategoryNav.tsx @@ -15,18 +15,20 @@ interface Props { function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount, categoryCounts }: Props) { return ( -
    {unsubscribeMutation.isError && (

    {(unsubscribeMutation.error as Error).message}

    )}
    - {assignedNames.map((name) => ( - - {name} - + {assignedCategories.map((category) => ( + ))}
    - {open && ( -
    +
    + Категории {categories.length === 0 &&
    Сначала создай категорию
    } {categories.map((category) => ( ))} +
    { event.preventDefault(); if (newName.trim()) createMutation.mutate(newName.trim()) }}> + setNewName(event.target.value)} /> + +
    + {(mutation.isError || createMutation.isError) && Не удалось изменить категории.}
    )}
    diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/components/DownloadButton.tsx index b046fcf..14859cd 100644 --- a/frontend/src/components/DownloadButton.tsx +++ b/frontend/src/components/DownloadButton.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { FeedVideoDto } from '../api/client' import { deleteDownload, downloadVideo, getDownloadStatus } from '../api/client' +import Icon from './Icon' const ACTIVE_STATUSES = ['queued', 'downloading', 'postprocessing'] @@ -12,9 +13,10 @@ const STATUS_LABELS: Record = { interface Props { video: FeedVideoDto + showDelete?: boolean } -function DownloadButton({ video }: Props) { +function DownloadButton({ video, showDelete = false }: Props) { const queryClient = useQueryClient() // Once we start tracking a download (it was already active on load, or the // user just clicked "Скачать"), the query stays enabled for good -- @@ -64,17 +66,15 @@ function DownloadButton({ video }: Props) { if (status === 'completed') { return ( - ✓ На сервере - + >Удалить копию} {deleteMutation.isError && ( {(deleteMutation.error as Error).message} @@ -85,28 +85,28 @@ function DownloadButton({ video }: Props) { } if (status === 'downloading') { - return {percent ?? 0}% + return Скачивается {percent ?? 0}% } if (status === 'queued' || status === 'postprocessing') { - return {STATUS_LABELS[status]} + return {STATUS_LABELS[status]} } if (status === 'failed') { return ( - Ошибка - + {downloadMutation.isError && Не удалось повторить загрузку.} ) } + if (status === 'unknown') return + return ( - + {downloadMutation.isError && Не удалось добавить видео в очередь.} ) } diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx new file mode 100644 index 0000000..1e26183 --- /dev/null +++ b/frontend/src/components/Icon.tsx @@ -0,0 +1,32 @@ +import type { SVGProps } from 'react' + +export type IconName = 'menu' | 'close' | 'play' | 'download' | 'home' | 'folder' | 'server' | 'users' | 'settings' | 'search' | 'refresh' | 'plus' | 'check' | 'chevron' | 'arrow' | 'alert' | 'logout' | 'youtube' | 'edit' | 'trash' | 'up' | 'down' + +const paths: Record = { + menu: <>, + close: <>, + play: <>, + download: <>, + home: <>, + folder: <>, + server: <>, + users: <>, + settings: <>, + search: <>, + refresh: <>, + plus: <>, + check: <>, + chevron: <>, + arrow: <>, + alert: <>, + logout: <>, + youtube: <>, + edit: <>, + trash: <>, + up: <>, + down: <>, +} + +export default function Icon({ name, size = 20, ...props }: SVGProps & { name: IconName; size?: number }) { + return +} diff --git a/frontend/src/components/Player.tsx b/frontend/src/components/Player.tsx index 688b462..7d7c941 100644 --- a/frontend/src/components/Player.tsx +++ b/frontend/src/components/Player.tsx @@ -35,7 +35,7 @@ function Player({ video }: Props) { }} />
    -
    ● Локальная копия · mediaVM
    +
    Источник: локальная копия
    ) } diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index e344549..8cfd7af 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -1,7 +1,8 @@ -import { Link } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' import type { FeedVideoDto } from '../api/client' import { formatDuration, formatRelativeTime } from '../utils/format' import DownloadButton from './DownloadButton' +import Icon from './Icon' interface Props { video: FeedVideoDto @@ -10,28 +11,27 @@ interface Props { function VideoCard({ video }: Props) { const duration = formatDuration(video.duration_seconds) const href = `/video/${video.youtube_video_id}` + const location = useLocation() + const from = location.pathname + location.search + const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY)) return (
  • - + {video.thumbnail_url ? ( - {video.title} + ) : (
    )} {duration && {duration}}
    - + {video.title} -
    - {video.channel.title} · {formatRelativeTime(video.published_at)} -
    +
    {video.channel.title}{formatRelativeTime(video.published_at)}
    - - Смотреть - + Смотреть
    diff --git a/frontend/src/index.css b/frontend/src/index.css index 5fb3313..083c8bd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,111 +1,32 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); + font-family: Inter, Roboto, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #f1f1f1; + background: #0f0f0f; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -body { - margin: 0; -} - -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); + --bg: #0f0f0f; + --surface: #181818; + --surface-raised: #202020; + --hover: #292929; + --border: #303030; + --text: #f1f1f1; + --muted: #aaa; + --subtle: #777; + --accent: #3ea6ff; + --accent-soft: rgba(62, 166, 255, .14); + --success: #4bc467; + --success-soft: rgba(75, 196, 103, .13); + --warning: #f5a623; + --error: #ff6b68; } +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; } +button, input { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .55; } +a { color: inherit; } +img { max-width: 100%; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +::selection { background: var(--accent); color: #06111c; } diff --git a/frontend/src/pages/Categories.tsx b/frontend/src/pages/Categories.tsx index d5ed522..268baac 100644 --- a/frontend/src/pages/Categories.tsx +++ b/frontend/src/pages/Categories.tsx @@ -1,158 +1,45 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router-dom' -import { - createCategory, - deleteCategory, - listCategories, - listChannels, - reorderCategories, - updateCategory, -} from '../api/client' +import { createCategory, deleteCategory, listCategories, reorderCategories, updateCategory } from '../api/client' +import Icon from '../components/Icon' function Categories() { const queryClient = useQueryClient() const [newName, setNewName] = useState('') const [editingId, setEditingId] = useState(null) const [editingName, setEditingName] = useState('') - + const [deletingId, setDeletingId] = useState(null) + const cancelDeleteRef = useRef(null) + useEffect(() => { if (deletingId !== null) cancelDeleteRef.current?.focus() }, [deletingId]) const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) - const uncategorizedQuery = useQuery({ - queryKey: ['channels', 'uncategorized-count'], - queryFn: () => listChannels({ subscribed: true, uncategorized: true }), - }) - - const invalidate = () => { - queryClient.invalidateQueries({ queryKey: ['categories'] }) - queryClient.invalidateQueries({ queryKey: ['channels'] }) - } - - const createMutation = useMutation({ - mutationFn: (name: string) => createCategory(name), - onSuccess: () => { - setNewName('') - invalidate() - }, - }) - - const updateMutation = useMutation({ - mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name), - onSuccess: () => { - setEditingId(null) - invalidate() - }, - }) - - const deleteMutation = useMutation({ - mutationFn: (id: number) => deleteCategory(id), - onSuccess: invalidate, - }) - - const reorderMutation = useMutation({ - mutationFn: (ids: number[]) => reorderCategories(ids), - onSuccess: invalidate, - }) - + const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) } + const createMutation = useMutation({ mutationFn: createCategory, onSuccess: () => { setNewName(''); invalidate() } }) + const updateMutation = useMutation({ mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name), onSuccess: () => { setEditingId(null); invalidate() } }) + const deleteMutation = useMutation({ mutationFn: deleteCategory, onSuccess: () => { setDeletingId(null); invalidate() } }) + const reorderMutation = useMutation({ mutationFn: reorderCategories, onSuccess: invalidate }) const categories = categoriesQuery.data ?? [] - + const deleting = categories.find((category) => category.id === deletingId) function move(index: number, direction: -1 | 1) { const target = index + direction if (target < 0 || target >= categories.length) return - const ids = categories.map((c) => c.id) + const ids = categories.map((category) => category.id) ;[ids[index], ids[target]] = [ids[target], ids[index]] reorderMutation.mutate(ids) } - - return ( -
    - - ← Каналы - -

    Категории

    - -
    { - e.preventDefault() - if (newName.trim()) createMutation.mutate(newName.trim()) - }} - className="category-form" - > - setNewName(e.target.value)} - /> - -
    - {createMutation.isError &&

    {(createMutation.error as Error).message}

    } - -
      -
    • -
      - Без категории - - {uncategorizedQuery.isLoading ? '…' : `${uncategorizedQuery.data?.length ?? 0} канал(ов)`} - -
    • - - {categories.map((category, index) => ( -
    • -
      - - -
      - - {editingId === category.id ? ( -
      { - e.preventDefault() - if (editingName.trim()) updateMutation.mutate({ id: category.id, name: editingName.trim() }) - }} - > - setEditingName(e.target.value)} autoFocus /> - - -
      - ) : ( - <> - {category.name} - {category.channel_count} канал(ов) - - - - )} -
    • - ))} -
    - - {categories.length === 0 && !categoriesQuery.isLoading &&

    Категорий пока нет.

    } -
    - ) + return
    + Настройки +

    Моя лента

    Категории

    Создавай темы и меняй порядок в меню.

    +
    { event.preventDefault(); if (newName.trim()) createMutation.mutate(newName.trim()) }}> setNewName(event.target.value)} />
    + {createMutation.isError &&

    Не удалось создать категорию. Возможно, такое имя уже есть.

    } + {reorderMutation.isError &&

    Не удалось изменить порядок категорий.

    } + {categoriesQuery.isLoading &&
    {Array.from({ length: 3 }, (_, index) =>
    )}
    } + {categoriesQuery.isError &&

    Не удалось загрузить категории

    } + {categories.length > 0 &&
      {categories.map((category, index) =>
    • {editingId === category.id ?
      { event.preventDefault(); if (editingName.trim()) updateMutation.mutate({ id: category.id, name: editingName.trim() }) }}> setEditingName(event.target.value)} autoFocus />
      : <>
      {category.name}{category.channel_count} каналов
      }
    • )}
    } + {updateMutation.isError &&

    Не удалось переименовать категорию.

    } + {!categoriesQuery.isLoading && !categoriesQuery.isError && categories.length === 0 &&

    Категорий пока нет

    Создай первую тему и добавь в неё каналы.

    } + {deleting &&
    setDeletingId(null)} onKeyDown={(event) => { if (event.key === 'Escape') setDeletingId(null) }}>
    event.stopPropagation()}>

    Удалить «{deleting.name}»?

    Каналы останутся в подписках. Они просто больше не будут относиться к этой категории.

    {deleteMutation.isError &&

    Не удалось удалить категорию.

    }
    } +
    } export default Categories diff --git a/frontend/src/pages/ChannelVideos.tsx b/frontend/src/pages/ChannelVideos.tsx index ace7692..89e5659 100644 --- a/frontend/src/pages/ChannelVideos.tsx +++ b/frontend/src/pages/ChannelVideos.tsx @@ -1,56 +1,24 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { Link, useParams } from 'react-router-dom' import { getChannel, getFeed } from '../api/client' +import Icon from '../components/Icon' import VideoCard from '../components/VideoCard' function ChannelVideos() { const { channelId } = useParams<{ channelId: string }>() const id = Number(channelId) - - const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id) }) - - const feedQuery = useInfiniteQuery({ - queryKey: ['feed', 'channel', id], - queryFn: ({ pageParam }) => getFeed({ channelId: id, cursor: pageParam }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, - }) - + const valid = Number.isInteger(id) && id > 0 + const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id), enabled: valid }) + const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'channel', id], queryFn: ({ pageParam }) => getFeed({ channelId: id, 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 ( -
    -
    -
    - - ← Каналы - -

    {channelQuery.data?.title ?? 'Канал'}

    -
    -
    - - {feedQuery.isLoading &&

    Загрузка...

    } - {feedQuery.isError &&

    Не удалось загрузить видео

    } - -
      - {items.map((video) => ( - - ))} -
    - - {!feedQuery.isLoading && items.length === 0 &&

    У этого канала пока нет синхронизированных видео.

    } - - {feedQuery.hasNextPage && ( - - )} -
    - ) + return
    + Каналы +

    Видео канала

    {channelQuery.data?.title ?? 'Канал'}

    Последние ролики из твоих подписок.

    + {(channelQuery.isError || feedQuery.isError || !valid) &&

    Не удалось открыть канал

    К списку каналов
    } + {feedQuery.isLoading &&
    {Array.from({ length: 6 }, (_, index) =>
    )}
    } + {!feedQuery.isLoading && !feedQuery.isError && items.length > 0 &&
      {items.map((video) => )}
    } + {!feedQuery.isLoading && !feedQuery.isError && items.length === 0 &&

    Пока нет видео

    Обнови ленту, чтобы получить новые ролики канала.

    } + {feedQuery.hasNextPage && } +
    } - export default ChannelVideos diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index 43891d4..aca8cb4 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -1,121 +1,49 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router-dom' -import { getSyncStatus, listCategories, listChannels, logout, syncSubscriptions } from '../api/client' +import { getSyncStatus, listCategories, listChannels, syncSubscriptions } from '../api/client' import ChannelCard from '../components/ChannelCard' import CategoryNav, { type CategoryFilter } from '../components/CategoryNav' +import Icon from '../components/Icon' function Channels() { const [search, setSearch] = useState('') const [filter, setFilter] = useState('all') const queryClient = useQueryClient() - const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) - - const allChannelsQuery = useQuery({ - queryKey: ['channels', 'all-for-counts'], - queryFn: () => listChannels({ subscribed: true }), - }) - const allChannels = allChannelsQuery.data ?? [] - const uncategorizedCount = allChannels.filter((c) => c.category_ids.length === 0).length - + const allChannelsQuery = useQuery({ queryKey: ['channels', 'all-for-counts'], queryFn: () => listChannels({ subscribed: true }) }) const channelsQuery = useQuery({ queryKey: ['channels', filter], - queryFn: () => - listChannels({ - subscribed: true, - categoryId: typeof filter === 'number' ? filter : undefined, - uncategorized: filter === 'uncategorized', - }), + queryFn: () => listChannels({ subscribed: true, categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: filter === 'uncategorized' }), }) - - const filteredChannels = useMemo(() => { - const data = channelsQuery.data ?? [] - if (!search.trim()) return data - const needle = search.trim().toLowerCase() - return data.filter((c) => c.title.toLowerCase().includes(needle)) - }, [channelsQuery.data, search]) - - const syncStatusQuery = useQuery({ - queryKey: ['sync-status'], - queryFn: getSyncStatus, - refetchInterval: (query) => (query.state.data?.subscriptions.running ? 1500 : false), - }) - - const syncMutation = useMutation({ - mutationFn: syncSubscriptions, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['channels'] }) - queryClient.invalidateQueries({ queryKey: ['sync-status'] }) - }, - }) - - const logoutMutation = useMutation({ - mutationFn: logout, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['auth-status'] }) - }, - }) - - const running = syncStatusQuery.data?.subscriptions.running + const syncStatusQuery = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: (query) => query.state.data?.subscriptions.running ? 2000 : 60_000 }) + const syncMutation = useMutation({ mutationFn: syncSubscriptions, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['sync-status'] }) } }) + const finishedAt = syncStatusQuery.data?.subscriptions.finished_at + const previousFinished = useRef(finishedAt) + useEffect(() => { + if (finishedAt && previousFinished.current !== finishedAt) queryClient.invalidateQueries({ queryKey: ['channels'] }) + previousFinished.current = finishedAt + }, [finishedAt, queryClient]) + const allChannels = allChannelsQuery.data ?? [] + const uncategorizedCount = allChannels.filter((channel) => channel.category_ids.length === 0).length const categories = categoriesQuery.data ?? [] + const filteredChannels = useMemo(() => { + const needle = search.trim().toLocaleLowerCase() + return (channelsQuery.data ?? []).filter((channel) => channel.title.toLocaleLowerCase().includes(needle)) + }, [channelsQuery.data, search]) + const running = syncStatusQuery.data?.subscriptions.running - return ( -
    -
    -

    Каналы

    -
    - Лента - Сохранённые - Категории - - -
    -
    - - {syncMutation.isError &&

    {(syncMutation.error as Error).message}

    } - {syncStatusQuery.data?.subscriptions.status === 'failed' && ( -

    Последняя синхронизация завершилась ошибкой: {syncStatusQuery.data.subscriptions.error}

    - )} - -
    - - -
    - setSearch(e.target.value)} - /> - - {channelsQuery.isLoading &&

    Загрузка...

    } - {channelsQuery.isError &&

    Не удалось загрузить каналы

    } - -
      - {filteredChannels.map((channel) => ( - - ))} -
    - - {channelsQuery.data && channelsQuery.data.length === 0 && ( -

    Каналы не найдены{filter === 'all' ? '. Нажми "Обновить сейчас".' : ' в этой категории.'}

    - )} - {channelsQuery.data && channelsQuery.data.length > 0 && filteredChannels.length === 0 && ( -

    Ничего не найдено по запросу "{search}".

    - )} -
    -
    -
    - ) + return
    +

    Мои подписки

    Каналы

    Распределяй подписки по темам, чтобы лента была удобнее.

    + {syncMutation.isError &&
    Не удалось запустить обновление подписок.
    } + {syncStatusQuery.data?.subscriptions.status === 'failed' &&
    Последнее обновление подписок завершилось ошибкой.
    } +
    Управлять категориями
    + + {channelsQuery.isLoading &&
    {Array.from({ length: 6 }, (_, index) =>
    )}
    } + {channelsQuery.isError &&

    Не удалось загрузить каналы

    } + {!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length > 0 &&
      {filteredChannels.map((channel) => )}
    } + {!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length === 0 &&

    {running ? 'Импортируем каналы' : search ? 'Каналы не найдены' : filter === 'uncategorized' ? 'Все каналы распределены' : 'Здесь пока нет каналов'}

    {running ? 'Список подписок появится здесь автоматически.' : search ? 'Попробуй другое название.' : filter === 'all' ? 'Обнови подписки, чтобы импортировать свои каналы.' : 'Выбери другую категорию или назначь её каналам.'}

    } +
    } export default Channels diff --git a/frontend/src/pages/Connect.tsx b/frontend/src/pages/Connect.tsx index 3de4b14..9a0e44e 100644 --- a/frontend/src/pages/Connect.tsx +++ b/frontend/src/pages/Connect.tsx @@ -1,17 +1,19 @@ import { useSearchParams } from 'react-router-dom' import { authErrorMessage, startGoogleAuth } from '../api/client' +import Icon from '../components/Icon' function Connect() { const [searchParams] = useSearchParams() const authError = searchParams.get('auth_error') return ( -
    -

    MyYouTube

    -

    Персональная лента YouTube-подписок с категориями.

    +
    + +

    Твоя лента, твои темы

    +

    Подключи YouTube, чтобы видеть видео своих подписок и распределять каналы по категориям. Доступен только твой аккаунт.

    {authError &&

    {authErrorMessage(authError)}

    } - -
    + +
    ) } diff --git a/frontend/src/pages/Feed.tsx b/frontend/src/pages/Feed.tsx index 49c6096..58c1544 100644 --- a/frontend/src/pages/Feed.tsx +++ b/frontend/src/pages/Feed.tsx @@ -1,116 +1,108 @@ -import { useMutation, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' -import { Link } from 'react-router-dom' -import { getFeed, getSyncStatus, listCategories, listChannels, logout, syncVideos } from '../api/client' -import CategoryNav, { type CategoryFilter } from '../components/CategoryNav' +import { useEffect, useRef } from 'react' +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom' +import { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels, syncVideos } from '../api/client' +import Icon from '../components/Icon' import VideoCard from '../components/VideoCard' -import { useState } from 'react' + +function VideoSkeleton() { + return
  • +} function Feed() { - const [filter, setFilter] = useState('all') + const location = useLocation() + const { categoryId } = useParams<{ categoryId: string }>() + const [searchParams] = useSearchParams() + const search = location.pathname === '/search' ? searchParams.get('q')?.trim() ?? '' : '' + const categoryNumber = categoryId && /^\d+$/.test(categoryId) ? Number(categoryId) : undefined + const isLocal = location.pathname === '/local' + const isUncategorized = location.pathname === '/uncategorized' + const localCategoryRaw = isLocal ? searchParams.get('category') : null + const localCategory = localCategoryRaw && /^\d+$/.test(localCategoryRaw) ? Number(localCategoryRaw) : undefined + const localUncategorized = isLocal && searchParams.get('uncategorized') === 'true' const queryClient = useQueryClient() - const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) - const categories = categoriesQuery.data ?? [] - - const allChannelsQuery = useQuery({ - queryKey: ['channels', 'all-for-counts'], - queryFn: () => listChannels({ subscribed: true }), + const category = categoriesQuery.data?.find((item) => item.id === categoryNumber) + const savedCountsQuery = useQuery({ queryKey: ['feed', 'saved-counts'], queryFn: getSavedCounts, enabled: isLocal }) + const countQuery = useQuery({ + queryKey: ['channels', isUncategorized ? 'uncategorized-count' : 'all-for-counts'], + queryFn: () => listChannels({ subscribed: true, uncategorized: isUncategorized }), + enabled: isUncategorized || (!categoryNumber && !isLocal && !search), }) - const allChannels = allChannelsQuery.data ?? [] - const uncategorizedCount = allChannels.filter((c) => c.category_ids.length === 0).length - const feedQuery = useInfiniteQuery({ - queryKey: ['feed', filter], - queryFn: ({ pageParam }) => - getFeed({ - categoryId: typeof filter === 'number' ? filter : undefined, - uncategorized: filter === 'uncategorized', - cursor: pageParam, - }), + queryKey: ['feed', location.pathname, search], + queryFn: ({ pageParam }) => getFeed({ + categoryId: isLocal ? localCategory : categoryNumber, + uncategorized: isUncategorized || localUncategorized, + downloaded: isLocal, + search, + cursor: pageParam, + }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + enabled: !categoryId || categoryNumber !== undefined, }) - const syncStatusQuery = useQuery({ - queryKey: ['sync-status'], - queryFn: getSyncStatus, - refetchInterval: (query) => (query.state.data?.videos.running ? 1500 : false), + queryKey: ['sync-status'], queryFn: getSyncStatus, + refetchInterval: (query) => query.state.data?.videos.running ? 2000 : 60_000, }) - + const finishedAt = syncStatusQuery.data?.videos.finished_at + const previousFinished = useRef(finishedAt) + useEffect(() => { + if (finishedAt && previousFinished.current !== finishedAt) queryClient.invalidateQueries({ queryKey: ['feed'] }) + previousFinished.current = finishedAt + }, [finishedAt, queryClient]) + const subscriptionsFinished = syncStatusQuery.data?.subscriptions.finished_at + const previousSubscriptionsFinished = useRef(subscriptionsFinished) + useEffect(() => { + if (subscriptionsFinished && previousSubscriptionsFinished.current !== subscriptionsFinished) queryClient.invalidateQueries({ queryKey: ['channels'] }) + previousSubscriptionsFinished.current = subscriptionsFinished + }, [subscriptionsFinished, queryClient]) const syncMutation = useMutation({ mutationFn: syncVideos, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['feed'] }) - queryClient.invalidateQueries({ queryKey: ['sync-status'] }) - }, + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) }, }) - - const logoutMutation = useMutation({ - mutationFn: logout, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['auth-status'] }) - }, - }) - - const running = syncStatusQuery.data?.videos.running + const title = search ? `Поиск: ${search}` : isLocal ? 'На сервере' : isUncategorized ? 'Без категории' : categoryId ? category?.name ?? 'Категория' : 'Все видео' + const subtitle = search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? `${countQuery.data?.length ?? '…'} каналов пока без категории` : category ? `${category.channel_count} каналов в категории` : `${countQuery.data?.length ?? '…'} подписок в вашей ленте` const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? [] + useEffect(() => { + if (feedQuery.isLoading) return + const key = `feed-scroll:${location.pathname + location.search}` + const saved = sessionStorage.getItem(key) + if (saved) { + sessionStorage.removeItem(key) + requestAnimationFrame(() => window.scrollTo(0, Number(saved))) + } + }, [feedQuery.isLoading, location.pathname, location.search]) + const hasInvalidCategory = Boolean(categoryId && !categoryNumber) - return ( -
    -
    -

    Лента

    -
    - Сохранённые - Каналы - Категории - - -
    -
    - - {syncMutation.isError &&

    {(syncMutation.error as Error).message}

    } - {syncStatusQuery.data?.videos.status === 'failed' && ( -

    Последняя синхронизация видео завершилась ошибкой: {syncStatusQuery.data.videos.error}

    - )} - -
    - - -
    - {feedQuery.isLoading &&

    Загрузка...

    } - {feedQuery.isError &&

    Не удалось загрузить ленту

    } - -
      - {items.map((video) => ( - - ))} -
    - - {!feedQuery.isLoading && items.length === 0 && ( -

    Видео пока нет. Нажми "Обновить сейчас" или проверь, есть ли каналы в этой категории.

    - )} - - {feedQuery.hasNextPage && ( - - )} -
    -
    + return
    +
    +

    Моя лента

    {title}

    {subtitle}

    + {!isLocal && !search && }
    - ) + + {isLocal && } + {syncMutation.isError &&
    Не удалось запустить обновление видео.
    } + {syncStatusQuery.data?.videos.status === 'failed' &&
    Последнее обновление видео завершилось ошибкой. Попробуйте позже.
    } + {location.pathname === '/' && (countQuery.data?.length ?? 0) > 0 && categoriesQuery.data?.length === 0 &&
    Каналы уже в ленте. Разложи их по темам, чтобы быстрее находить видео.Настроить категории
    } + {hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ?

    Категория не найдена

    Ко всем видео
    : <> + {feedQuery.isLoading &&
      {Array.from({ length: 8 }, (_, index) => )}
    } + {feedQuery.isError &&

    Не удалось загрузить видео

    Проверь соединение и попробуй ещё раз.

    } + {!feedQuery.isLoading && !feedQuery.isError && items.length > 0 &&
      {items.map((video) => )}
    } + {!feedQuery.isLoading && !feedQuery.isError && items.length === 0 &&

    {syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}

    {syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}

    {!search && !isLocal && !syncStatusQuery.data?.subscriptions.running && !syncStatusQuery.data?.videos.running && Перейти к каналам}
    } + {feedQuery.hasNextPage && } + } +
    } export default Feed diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..18f1d65 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,29 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link, useNavigate } from 'react-router-dom' +import { getHealth, getSyncStatus, logout, startGoogleAuth, syncSubscriptions, syncVideos } from '../api/client' +import { formatRelativeTime } from '../utils/format' +import Icon from '../components/Icon' + +function Settings({ email }: { email: string | null }) { + const queryClient = useQueryClient() + const navigate = useNavigate() + const statusQuery = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: 30_000 }) + const healthQuery = useQuery({ queryKey: ['health'], queryFn: getHealth, staleTime: 0 }) + const subscriptionsMutation = useMutation({ mutationFn: syncSubscriptions, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sync-status'] }) }) + const videosMutation = useMutation({ mutationFn: syncVideos, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sync-status'] }) }) + const logoutMutation = useMutation({ mutationFn: logout, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['auth-status'] }); navigate('/') } }) + const subscriptions = statusQuery.data?.subscriptions + const videos = statusQuery.data?.videos + return
    +

    Аккаунт и сервисы

    Настройки

    Подключения и обновление вашей ленты.

    +
    +

    YouTube

    Подписки и новые видео

    Аккаунт{email ?? 'Неизвестен'}
    Статус Подключён
    Подписки обновлены{subscriptions?.finished_at ? formatRelativeTime(subscriptions.finished_at) : 'Ещё не обновлялись'}
    {subscriptionsMutation.isError &&

    Не удалось запустить обновление.

    }
    +

    Локальные видео

    Сохранённые копии

    Соединение{healthQuery.isLoading ? 'Проверяем…' : healthQuery.data?.metube === 'ok' ? 'Доступно' : 'Недоступно'}

    Скачанные видео доступны во вкладке «На сервере».

    Открыть видео
    +

    Синхронизация

    Видео в вашей ленте

    Видео обновлены{videos?.finished_at ? formatRelativeTime(videos.finished_at) : 'Ещё не обновлялись'}
    Состояние{videos?.running ? 'Обновляются' : videos?.status === 'failed' ? 'Ошибка' : videos?.status === 'completed' ? 'Успешно' : 'Ещё не запускалась'}
    {videosMutation.isError &&

    Не удалось запустить обновление.

    }
    +

    Категории

    Организация каналов

    Переименовывай категории и меняй их порядок.

    Управлять категориями
    +
    + +
    +} + +export default Settings diff --git a/frontend/src/pages/VideoPage.tsx b/frontend/src/pages/VideoPage.tsx index de78430..1e440e8 100644 --- a/frontend/src/pages/VideoPage.tsx +++ b/frontend/src/pages/VideoPage.tsx @@ -1,73 +1,31 @@ import { useQuery } from '@tanstack/react-query' -import { Link, useParams } from 'react-router-dom' +import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' import { getVideo } from '../api/client' import { formatDuration, formatRelativeTime } from '../utils/format' import DownloadButton from '../components/DownloadButton' +import Icon from '../components/Icon' import Player from '../components/Player' function VideoPage() { const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>() + const location = useLocation() + const navigate = useNavigate() + const from = (location.state as { from?: string } | null)?.from + const videoQuery = useQuery({ queryKey: ['video', youtubeVideoId], queryFn: () => getVideo(youtubeVideoId!), enabled: !!youtubeVideoId }) + const back = () => { if (from && window.history.length > 1) navigate(-1); else navigate(from ?? '/') } - const videoQuery = useQuery({ - queryKey: ['video', youtubeVideoId], - queryFn: () => getVideo(youtubeVideoId!), - enabled: !!youtubeVideoId, - }) - - if (videoQuery.isLoading) { - return ( -
    -

    Загрузка...

    -
    - ) - } - - if (videoQuery.isError || !videoQuery.data) { - return ( -
    - - ← Лента - -

    Видео не найдено

    -
    - ) - } + if (videoQuery.isLoading) return
    + if (videoQuery.isError || !videoQuery.data) return

    Видео не найдено

    Возможно, оно больше не доступно в ленте.

    const video = videoQuery.data const duration = formatDuration(video.duration_seconds) - - return ( -
    - - ← Лента - - - - -

    {video.title}

    - -
    - {video.channel.title} - {' · '} - {formatRelativeTime(video.published_at)} - {duration && ` · ${duration}`} - {' · '} - -
    - - {video.categories.length > 0 && ( -
    - {video.categories.map((c) => ( - - {c.name} - - ))} -
    - )} - - {video.description &&

    {video.description}

    } -
    - ) + return
    + + +

    {video.title}

    {video.channel.title}·{formatRelativeTime(video.published_at)}{duration && <>·{duration}}

    + {video.categories.length > 0 &&
    {video.categories.map((category) => {category.name})}
    } + {video.description &&

    Описание

    {video.description}

    } +
    } export default VideoPage diff --git a/tests/test_feed.py b/tests/test_feed.py index bace6fc..c6ab3cb 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -11,6 +11,7 @@ from app.models.channel import Channel from app.models.channel_category import channel_categories from app.models.download_job import DownloadJob from app.models.video import Video +from app.api.feed import get_feed @pytest.fixture @@ -67,6 +68,19 @@ def test_feed_orders_by_published_at_desc(client, db_session): assert resp["next_cursor"] is None +def test_feed_searches_video_and_channel_titles(db_session): + _seed(db_session) + + by_video = get_feed(search="video 4", limit=30, db=db_session) + assert [item["youtube_video_id"] for item in by_video["items"]] == ["vid4"] + + by_channel = get_feed(search="channel b", limit=30, db=db_session) + assert [item["youtube_video_id"] for item in by_channel["items"]] == ["vid3", "vid1"] + + literal_wildcard = get_feed(search="%", limit=30, db=db_session) + assert literal_wildcard["items"] == [] + + def test_feed_pagination_cursor(client, db_session): _seed(db_session) From 6c704cac972b9cd732b31f1deffd4d76b552aae6 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 16 Sep 2026 23:08:21 +0000 Subject: [PATCH 08/10] Switch agent team to opencode, drop Codex/Herdr multi-agent setup --- .opencode/agent/coder.md | 26 + .opencode/agent/orchestrator.md | 32 + .opencode/agent/reviewer.md | 25 + .opencode/agent/tester.md | 27 + AGENTS.md | 45 +- AGENT_TEAM.md | 28 + README.md | 33 +- UI_UX_spec.md | 1018 -------------------- opencode.json | 4 + youtube_categories_metube_TZ.md | 1552 ------------------------------- 10 files changed, 184 insertions(+), 2606 deletions(-) create mode 100644 .opencode/agent/coder.md create mode 100644 .opencode/agent/orchestrator.md create mode 100644 .opencode/agent/reviewer.md create mode 100644 .opencode/agent/tester.md create mode 100644 AGENT_TEAM.md delete mode 100644 UI_UX_spec.md create mode 100644 opencode.json delete mode 100644 youtube_categories_metube_TZ.md diff --git a/.opencode/agent/coder.md b/.opencode/agent/coder.md new file mode 100644 index 0000000..7284c93 --- /dev/null +++ b/.opencode/agent/coder.md @@ -0,0 +1,26 @@ +--- +description: Implementation owner. Makes the smallest coherent change for an assigned bounded task, runs the relevant checks, and reports CODER_DONE. +mode: subagent +--- + +# Coder + +You are the implementation owner in an opencode agent team. The Orchestrator (main session) gives you bounded tasks. You are the only agent allowed to change tracked project files in this worktree. + +## Working rules + +1. Read `AGENTS.md` and the relevant project specification before changing code. Inspect existing changes (`git status`, `git diff`) and preserve them. +2. Implement the assigned acceptance criteria with the smallest coherent change. Follow the existing architecture and naming patterns. Avoid unrelated refactors and dependencies. +3. Do not change global opencode configuration, secrets, deployment, or remote services unless the assignment explicitly requires it. +4. Run the relevant lint, build, unit tests, and focused checks. Fix errors caused by your changes. Do not claim a check passed unless it ran and you saw the result. +5. When an API, product behavior, or external fact may have changed, verify it from primary documentation or the installed tool's help. +6. Stop editing before reporting. The Orchestrator will send the result to independent review and QA. When feedback arrives, fix the stated issues and verify again. +7. If blocked, give the Orchestrator a precise cause and the smallest decision needed. + +## Final report format + +- Implemented: concrete behavior. +- Changed files: paths and why. +- Checks: exact commands and pass/fail results. +- Limits: remaining risks or unverified behavior. +- End the final response with `CODER_DONE`. diff --git a/.opencode/agent/orchestrator.md b/.opencode/agent/orchestrator.md new file mode 100644 index 0000000..4400499 --- /dev/null +++ b/.opencode/agent/orchestrator.md @@ -0,0 +1,32 @@ +--- +description: Orchestrator / Tech Lead — coordinates coder, reviewer, and tester subagents and reports to the user. +mode: primary +--- + +# Orchestrator / Tech Lead + +You are the only agent who normally speaks with the user. Coordinate the opencode subagents `coder`, `reviewer`, and `tester` through the Task tool; do not edit product code yourself. The subagents share this worktree. Only `coder` changes tracked project files. + +## Start every task + +1. Read the repository's `AGENTS.md` and any relevant specification before planning. Inspect the current worktree (`git status`, `git diff`) and identify pre-existing changes. +2. Turn the user's request into a bounded task for Coder: scope, acceptance criteria, files or subsystems likely involved, constraints, and validation expected. Resolve routine choices yourself. Ask the user only for genuinely missing decisions. +3. Give Coder ownership of implementation. Do not send implementation work to Reviewer or Tester. +4. Submit the task to Coder with the Task tool (subagent_type `coder`). Keep one code writer at a time: never run two Coder tasks concurrently and do not start a new Coder task while another is working. +5. Read Coder's report (ends with `CODER_DONE`) and inspect the diff yourself. Then run Reviewer and Tester on the result. Reviewer must not edit; Tester must not fix. They may run in parallel — their commands cannot interfere with each other's. +6. Collect Critical, Major, and Minor findings with evidence. Send actionable findings back to Coder as a follow-up task. Repeat review and test on the changed result until Critical and Major findings are resolved, or report a concrete blocker to the user. +7. Give the user a concise final report: changes, affected files, review findings resolved or remaining, tests run and results, known limits, and worktree/branch. Do not claim visual or integration checks that were not performed. + +## Working rules + +- Do not merge, deploy, publish, or commit unless the user requested it or existing authorization covers it. +- For frontend work, include responsive behavior, accessibility, loading/error/empty states, and real browser verification when tooling exists in the acceptance criteria. +- For backend work, include data integrity, security, edge cases, and relevant API checks. + +## Expected worker reports + +- Coder ends with `CODER_DONE` and lists changed files, implementation, verification, and limits. +- Reviewer ends with `REVIEW_DONE` and lists findings by severity with file/line evidence, or states that no actionable findings were found. +- Tester ends with `TEST_DONE` and lists commands, passes, failures, reproduction steps, expected and actual behavior, and severity. + +This workflow adapts the narrow-role and read-only review patterns from the official OpenAI Docs on subagents. diff --git a/.opencode/agent/reviewer.md b/.opencode/agent/reviewer.md new file mode 100644 index 0000000..fa43f38 --- /dev/null +++ b/.opencode/agent/reviewer.md @@ -0,0 +1,25 @@ +--- +description: Independent read-only reviewer. Analyzes the coder's diff, classifies findings by severity, and reports REVIEW_DONE. Never edits files. +mode: subagent +permission: + edit: deny +--- + +# Reviewer / Designer + +You are an independent reviewer in an opencode agent team. Your session is read-only: do not edit code, tests, snapshots, or configuration. Analyze Coder's result and report to the Orchestrator. + +## Review method + +1. Read the assignment, acceptance criteria, `AGENTS.md`, and relevant specifications. Review the actual diff (`git diff`) and enough surrounding code to understand behavior. +2. Trace changed execution paths. Prioritize defects with user impact. Cite exact files and lines, explain the consequence, and give a reproduction or concrete failure scenario where possible. +3. For frontend/UI, inspect UX, visual hierarchy, consistency, responsive widths, touch targets, keyboard and screen-reader access, loading/error/empty states, component boundaries, and state handling. Use an available browser or screenshot tool for visual claims; if none exists, say what remains unverified. +4. For backend/general code, inspect architecture, data flow, edge cases, security, validation, race conditions, error handling, tests, and violations of existing patterns. +5. Classify findings: **Critical** blocks safe use or risks loss/security; **Major** breaks a supported workflow or causes a material regression; **Minor** is a bounded quality issue. Do not inflate speculative concerns. +6. Separate confirmed findings from questions. If there are no actionable findings, state that plainly. Do not fill the report with style-only comments or restatements of Coder's work. + +## Report format + +- Findings in severity order, each with file/line, impact, and evidence. +- Verification performed and any limits, especially missing visual checks. +- End the final response with `REVIEW_DONE`. diff --git a/.opencode/agent/tester.md b/.opencode/agent/tester.md new file mode 100644 index 0000000..91565e3 --- /dev/null +++ b/.opencode/agent/tester.md @@ -0,0 +1,27 @@ +--- +description: Independent QA. Verifies the coder's finished result, runs checks, and reports TEST_DONE. Does not modify tracked files. +mode: subagent +permission: + edit: deny +--- + +# Tester / QA + +You are independent QA in an opencode agent team. Verify Coder's finished result and report to the Orchestrator. Do not fix product code or tests; you must not modify tracked project files (build tools may create ignored artifacts). + +## Test method + +1. Read the assignment, acceptance criteria, `AGENTS.md`, and Coder's changed-file report. Inspect the diff enough to identify high-risk paths. +2. Run relevant existing lint, build, and unit tests. Run integration or end-to-end tests when configured. Use Playwright/Cypress or another browser tool if present; verify responsive UI, console errors, network/API behavior, and core flows. Do not claim visual verification from code inspection alone. +3. Exercise positive, negative, boundary, refresh/navigation, and recovery scenarios that matter to the change. Prefer focused tests before broad test suites when a failure needs isolation. +4. Respect project safety rules: external Google/MeTube calls are mocked by default; do not perform real integration calls unless the task explicitly opts in. Never delete production data or change external state as a test without authorization. +5. Capture exact commands and observations. If a check is blocked by environment, distinguish that from an application failure and explain the blocker. +6. After verification, check `git status --short`. Report any tracked file that test commands changed; do not clean it up without coordination. + +## Report format + +- Scope and commands run. +- Passed checks. +- Failed checks with reproduction steps, expected versus actual behavior, evidence, and Critical/Major/Minor severity. +- Untested areas and why. +- End the final response with `TEST_DONE`. diff --git a/AGENTS.md b/AGENTS.md index 625f309..1c4f6bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,44 +1,31 @@ # AGENTS.md -Полное ТЗ: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md). Перед любой существенной работой сверяйся с ним — этот файл лишь выжимка ограничений. +Правила для персонального YouTube-клиента с категориями подписок и интеграцией с существующим MeTube. Основные этапы разработки (каркас, OAuth/подписки, категории, лента, воспроизведение и MeTube) уже реализованы; ориентируйся на фактический код и `README.md`. -## Жёсткие ограничения (раздел 34 ТЗ) +## Архитектурные ограничения 1. Не форкать/копировать Tube Archivist. -2. Не встраивать yt-dlp в этот сервис — скачивание только через существующий MeTube (`mediaVM`). +2. Не встраивать yt-dlp в этот сервис — скачивание выполняет существующий MeTube на `mediaVM`. 3. Не дублировать функции скачивания MeTube. -4. Не монтировать media storage MeTube на `hermesVM` — видео не хранится локально, только проксируется/линкуется. +4. Не монтировать media storage MeTube на `hermesVM`: видео не хранится локально, только проксируется или линкуется. 5. Не реализовывать YouTube Recommendations/Home. -6. Не делать multi-user — приложение строго single-user (`ALLOWED_GOOGLE_EMAIL`). -7. Frontend не должен напрямую вызывать Google API. -8. Frontend не должен напрямую вызывать MeTube write API (`POST /add` и т.п.) — только через наш backend. -9. Не восстанавливать filename скачанного видео из YouTube title — использовать только точное имя, пришедшее от MeTube по событию `completed`. -10. Не удалять существующие файлы MeTube. +6. Приложение остаётся single-user (`ALLOWED_GOOGLE_EMAIL`). +7. Frontend не вызывает Google API напрямую. +8. Frontend не вызывает MeTube write API (`POST /add` и т.п.) напрямую — только через наш backend. +9. Не восстанавливать имя скачанного файла из YouTube title: использовать точное имя из события MeTube `completed`. +10. Не удалять существующие файлы MeTube при синхронизации или обслуживании; удаление по явному действию пользователя — отдельный поддерживаемый сценарий. 11. Не менять исходный код MeTube. -12. Все внешние base URL (MeTube, Google) — через env/config, не хардкодить. +12. Все внешние base URL (MeTube, Google) задаются через env/config. 13. REST API должен оставаться пригодным для будущего mobile/PWA клиента. -## Порядок фаз (раздел 33 ТЗ) - -Не реализовывать следующую фазу, пока не работает предыдущая: - -1. Skeleton (текущая фаза) — FastAPI, PostgreSQL, Alembic, React, Docker Compose, healthcheck. -2. Google OAuth + subscriptions sync + channels UI. -3. Categories CRUD + many-to-many + фильтр по категориям. -4. Video sync/feed (uploads playlists, playlistItems, videos, background scheduler). -5. YouTube playback (embed). -6. MeTube integration (`/add`, download_jobs, Socket.IO consumer, media URL). -7. Hardening (retry/recovery, logging, quota handling, tests, README). - ## Структура и соглашения -- `migrations/` — Alembic, живёт в корне репозитория (не внутри `backend/`), импортирует модели из `backend/app`. -- Схема БД добавляется миграциями инкрементально по фазам (см. раздел 11 ТЗ), а не одним махом в Phase 1. -- MeTube-специфичные HTTP/Socket.IO вызовы должны быть инкапсулированы в отдельный класс `MeTubeClient` (раздел 36 ТЗ) — не размазывать по backend. -- Секреты только через `.env` (см. `.env.example`), никогда не коммитить `.env`, refresh token, client secret. -- Backend тесты — `pytest`, лежат в `tests/` в корне. Integration-тесты против Google/MeTube — mocked по умолчанию; реальные вызовы к `http://192.168.8.177:8081` — только opt-in, не в обычном CI. -- **В unit-тестах не использовать `with TestClient(app) as client:`** — это запускает lifespan приложения, который с Phase 6 реально стучится в MeTube (Socket.IO) и в Postgres (`reconcile_on_startup`). Используй `TestClient(app)` без `with` (lifespan не запускается, дефолтное поведение starlette) — так и сделано во всех текущих тестах. +- `migrations/` — Alembic в корне репозитория; миграции импортируют модели из `backend/app`. Схему БД изменять миграциями, не создавать и не менять ad hoc при старте приложения. +- MeTube-специфичные HTTP/Socket.IO вызовы инкапсулировать в `MeTubeClient`, не размазывать по backend. +- Секреты хранить только в `.env` (образец — `.env.example`); не коммитить `.env`, refresh token или client secret. +- Backend тесты — `pytest` в корневом `tests/`. Интеграции с Google/MeTube по умолчанию mock; реальные вызовы к `http://192.168.8.177:8081` — только opt-in, не в обычном CI. +- В unit-тестах использовать `TestClient(app)` без `with`: контекстный менеджер запускает lifespan с подключением к MeTube (Socket.IO) и Postgres (`reconcile_on_startup`). -## Запуск/проверка +## Запуск и проверка См. `README.md`. Быстрая проверка: `docker compose up -d --build` и `curl http://localhost:8080/api/health`. diff --git a/AGENT_TEAM.md b/AGENT_TEAM.md new file mode 100644 index 0000000..bfb8954 --- /dev/null +++ b/AGENT_TEAM.md @@ -0,0 +1,28 @@ +# Агентская команда opencode + +Одна сессия opencode в роли **Orchestrator** и три субагента — **Coder**, **Reviewer**, **Tester**, которых Orchestrator вызывает через Task tool. Роли лежат в [`.opencode/agent/`](./.opencode/agent/), Orchestrator назначен агентом по умолчанию в [`opencode.json`](./opencode.json). + +## Запуск и работа + +В корне репозитория запусти `opencode`. Каждая новая сессия стартует Orchestrator'ом: задачу пиши обычным сообщением. Он сам: + +- декомпозирует задачу и отправляет её Coder'у через Task tool; +- после Coder'а запускает Reviewer (только чтение) и Tester (проверка без правок) параллельно; +- возвращает Coder'у actionable findings и повторяет цикл, пока Critical/Major не закрыты; +- отдаёт финальный отчёт с изменениями, проверками и оставшимися рисками. + +Вручную писать субагентам не нужно — их вызывает только Orchestrator. + +## Роли + +- **Orchestrator** (primary, агент по умолчанию) — единственный общается с пользователем; сам код не правит. +- **Coder** (subagent) — единственный меняет отслеживаемые файлы; отчитывается `CODER_DONE`. +- **Reviewer** (subagent, `edit: deny`) — read-only review; отчитывается `REVIEW_DONE`. +- **Tester** (subagent, `edit: deny`) — прогоняет проверки; может создавать игнорируемые артефакты, но не меняет отслеживаемые файлы; отчитывается `TEST_DONE`. + +## Файлы + +- `.opencode/agent/*.md` — определения агентов команды; +- `opencode.json` — `default_agent: orchestrator`. + +Конфиг и агенты читаются при старте opencode: после изменений перезапусти сессию. diff --git a/README.md b/README.md index 8943fb9..69a9099 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # MyYouTube Персональный YouTube-клиент с категориями подписок и интеграцией с существующим MeTube. -Полное техническое задание: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md). -UI/UX-спека (дизайн-система и сценарии интерфейса): [`UI_UX_spec.md`](./UI_UX_spec.md). +Работа агентской команды (opencode) описана в [руководстве по агентской команде](./AGENT_TEAM.md). -Текущий статус: **функционально готово** (Phases 1-6 из ТЗ + hardening по ходу тестирования). Развёрнуто и вручную протестировано на тестовом окружении (`testmyyoutube.vrubel.xyz` → hermesVM `192.168.8.173` → MeTube на mediaVM `192.168.8.177`). +Текущий статус: **функционально готово** (основные этапы 1–6 и доработки по итогам тестирования). Развёрнуто и вручную протестировано на тестовом окружении (`testmyyoutube.vrubel.xyz` → hermesVM `192.168.8.173` → MeTube на mediaVM `192.168.8.177`). ### Что сделано @@ -12,13 +11,13 @@ UI/UX-спека (дизайн-система и сценарии интерфе - Раздел **«На сервере»** — скачанные видео; `/saved` перенаправляет на `/local`. - Тёмный адаптивный интерфейс: общая навигация, категории с постоянными URL, поиск по названиям видео и каналов, мобильное меню. - Отписанные каналы скрываются из списка каналов. -- **Отклонение от исходного MVP ТЗ (раздел 2)**: реальная отписка от канала на YouTube (`subscriptions.delete`) реализована по явной просьбе пользователя, хотя ТЗ изначально исключало управление подписками из MVP. Из-за этого OAuth scope расширен с `youtube.readonly` до полного `youtube` (read/write) — см. `backend/app/services/google_oauth.py`. +- Реальная отписка от канала на YouTube (`subscriptions.delete`) реализована по запросу пользователя сверх исходного MVP. Из-за этого OAuth scope расширен с `youtube.readonly` до полного `youtube` (read/write) — см. `backend/app/services/google_oauth.py`. - Удаление скачанного видео с сервера (`DELETE /api/videos/{id}/download`) — тоже сверх исходного MVP-скоупа, добавлено по запросу. ### Что осталось / сознательно отложено -- Остальные пункты раздела 35 ТЗ («Возможные улучшения после MVP») не делались: PWA, watch later, Shorts-фильтр, SponsorBlock и т.д. -- Формальный Phase 7 hardening из ТЗ (retry/recovery, тесты) закрывался по факту находок в live-тестировании, а не отдельным проходом — см. `git log` для конкретных багфиксов (flapping статусов загрузки, неверный id в MeTube `/delete`, OAuth scope mismatch). +- Дополнительные идеи после MVP пока не реализованы: PWA, watch later, Shorts-фильтр, SponsorBlock и т.д. +- Hardening (retry/recovery, тесты) выполнялся по факту находок в live-тестировании, а не отдельным проходом — см. `git log` для конкретных багфиксов (flapping статусов загрузки, неверный id в MeTube `/delete`, OAuth scope mismatch). ## Стек @@ -46,6 +45,25 @@ UI/UX-спека (дизайн-система и сценарии интерфе ## Локальная разработка +### Агентская команда (opencode) + +Подробная инструкция: [AGENT_TEAM.md](./AGENT_TEAM.md). + +Работа идёт в одной сессии opencode: она стартует агентом `orchestrator` +(`default_agent` в `opencode.json`). Orchestrator сам декомпозирует задачу и +вызывает субагентов через Task tool: `coder` (единственный меняет +отслеживаемые файлы), `reviewer` (read-only review, `edit: deny`) и `tester` +(проверки без правок, `edit: deny`). Роли описаны в +[`.opencode/agent/`](./.opencode/agent/). + +```bash +opencode +``` + +Задачу пиши обычным сообщением: Orchestrator передаст её Coder'у, соберёт +review и QA, повторит цикл при находках и вернёт финальный отчёт. Вручную +писать субагентам не нужно. + ### Backend ```bash @@ -96,7 +114,8 @@ myyoutube/ ├── Dockerfile ├── compose.yml ├── .env / .env.example -└── youtube_categories_metube_TZ.md +├── .opencode/ # агенты команды (orchestrator/coder/reviewer/tester) +└── AGENT_TEAM.md # руководство по агентской команде opencode ``` ## Секреты diff --git a/UI_UX_spec.md b/UI_UX_spec.md deleted file mode 100644 index 3f2ad5c..0000000 --- a/UI_UX_spec.md +++ /dev/null @@ -1,1018 +0,0 @@ -# UI/UX-спецификация: персональный YouTube-клиент - -**Назначение:** передать coding agent вместе с основным техническим заданием. -**Формат:** single-user self-hosted web app, в будущем PWA/mobile client. - -## 1. Концепция - -Приложение должно ощущаться как персональная лента YouTube-подписок, но с нормальной категоризацией каналов и локальным хранением выбранных видео. - -Пользовательский сценарий: - -```text -Открыть приложение -→ выбрать категорию -→ увидеть последние видео каналов этой категории -→ смотреть через YouTube -→ при желании нажать "Скачать" -→ после загрузки видеть "На сервере" -→ дальше смотреть локальную копию через MeTube -``` - -Интерфейс не должен выглядеть как admin panel. Это consumer media UI. - ---- - -## 2. Общий layout - -Desktop: - -```text -┌──────────────────────────────────────────────────────────────────────┐ -│ Header │ -├──────────────────────┬───────────────────────────────────────────────┤ -│ Sidebar │ Main content │ -│ │ │ -│ Все видео │ Video feed │ -│ Без категории │ │ -│ На сервере │ │ -│ │ │ -│ КАТЕГОРИИ │ │ -│ Linux │ │ -│ Шахматы │ │ -│ Научпоп │ │ -│ │ │ -│ Каналы │ │ -│ Настройки │ │ -└──────────────────────┴───────────────────────────────────────────────┘ -``` - -Sidebar: 220–260 px. -Header: фиксированный сверху. -Main content: responsive grid. - -Mobile: sidebar превращается в drawer/hamburger. - ---- - -## 3. Визуальный стиль - -Тёмная тема по умолчанию. - -Рекомендуемые design tokens: - -```text -Background main #0F0F0F -Surface #181818 -Surface hover #272727 -Border #303030 -Primary text #F1F1F1 -Secondary text #AAAAAA -Accent #3EA6FF -Success/local #2BA640 -Warning/downloading #F5A623 -Error #E53935 -``` - -Использовать CSS variables, а не раскидывать цвета по компонентам. - -Типографика: - -```text -Inter / Roboto / system-ui -Page title 24px / 600 -Section title 18px / 600 -Video title 15–16px / 500 -Metadata 12–14px -Buttons 14px / 500 -``` - ---- - -## 4. Header - -Пример: - -```text -┌──────────────────────────────────────────────────────────────────────┐ -│ ▶ MyTube 🔍 Поиск... ✓ 12 мин ⚙ │ -└──────────────────────────────────────────────────────────────────────┘ -``` - -Слева: -- логотип; -- рабочее название `MyTube` или configurable app name. - -Центр: -- поиск по локальной БД: video title + channel title. - -Справа: -- sync indicator; -- settings. - -Sync states: - -```text -✓ Обновлено 12 мин назад -↻ Обновление... -⚠ Ошибка синхронизации -``` - -По клику на sync indicator: - -```text -Подписки: обновлены 20:11 -Видео: обновлены 20:12 -MeTube: доступен -``` - ---- - -## 5. Sidebar - -Пример: - -```text -● Все видео - Без категории - На сервере - -КАТЕГОРИИ - -🐧 Linux -♟ Шахматы -🔬 Научпоп -🎮 Игры - -+ Новая категория - -──────────── - -Каналы -Настройки -``` - -Активная категория должна иметь заметный, но спокойный selected state. - -`Все видео`, `Без категории`, `На сервере` — виртуальные категории. - ---- - -## 6. Feed - -Route: - -```text -/ -``` - -или: - -```text -/feed -``` - -Header выбранной категории: - -```text -Linux -24 канала -Обновлено 12 минут назад [↻ Обновить] -``` - -Опциональные chips: - -```text -[Все] [На сервере] [Не скачаны] -``` - -Сортировка видео: - -```text -published_at DESC -``` - ---- - -## 7. Video grid - -Responsive: - -```text ->= 1800 px → 5 columns ->= 1400 px → 4 columns ->= 1000 px → 3 columns ->= 700 px → 2 columns -mobile → 1 column -``` - -Рекомендуемый CSS: - -```css -grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); -``` - ---- - -## 8. Video card - -Пример: - -```text -┌────────────────────────────────────┐ -│ │ -│ THUMBNAIL │ -│ 18:42 │ -│ │ -├────────────────────────────────────┤ -│ Linux Kernel 7.0 — What's New │ -│ The Linux Experiment │ -│ 2 часа назад │ -│ │ -│ [▶ Смотреть] [⬇ Скачать] │ -└────────────────────────────────────┘ -``` - -Thumbnail: -- aspect ratio 16:9; -- duration badge справа снизу. - -Карточка содержит: -- thumbnail; -- title; -- channel name; -- publish time; -- duration; -- download/local state; -- Watch; -- Download. - ---- - -## 9. Состояния скачивания - -### Не скачано - -```text -[▶ Смотреть] [⬇ Скачать] -``` - -### В очереди - -```text -[▶ Смотреть] [В очереди...] -``` - -### Скачивается - -```text -[▶ Смотреть] [64%] -``` - -Дополнительно progress-line внизу thumbnail. - -### Post-processing - -```text -[▶ Смотреть] [Обработка...] -``` - -### На сервере - -```text -[▶ Смотреть] [✓ На сервере] -``` - -### Ошибка - -```text -[▶ Смотреть] [⚠ Повторить] -``` - -Не показывать traceback. - ---- - -## 10. Video page - -Route: - -```text -/video/:youtubeVideoId -``` - -Desktop: - -```text -┌──────────────────────────────────────────────────────────────────┐ -│ │ -│ VIDEO PLAYER │ -│ │ -└──────────────────────────────────────────────────────────────────┘ - -Linux Kernel 7.0 — What's New - -The Linux Experiment -2 часа назад [⬇ Скачать] - -Источник: YouTube - -[Linux] [IT] - -──────────────────────────────────────────────────────────────────── - -Описание видео... -``` - -Если local copy существует: - -```text -Источник: ● Локальная копия · mediaVM -``` - -и вместо YouTube iframe используется HTML5 `
    {unsubscribeMutation.isError && ( -

    {(unsubscribeMutation.error as Error).message}

    +

    {(unsubscribeMutation.error as Error).message}

    )}
    diff --git a/frontend/src/components/Player.tsx b/frontend/src/components/Player.tsx index 7d7c941..03e0448 100644 --- a/frontend/src/components/Player.tsx +++ b/frontend/src/components/Player.tsx @@ -25,7 +25,6 @@ function Player({ video }: Props) { return ( <>
    - {/* eslint-disable-next-line jsx-a11y/media-has-caption */}