From c2b183cba21b83830c36ecacf2f064e2aaa971d2 Mon Sep 17 00:00:00 2001 From: ruffbuff Date: Thu, 28 May 2026 09:08:55 +0300 Subject: [PATCH] =?UTF-8?q?Arch=20linux=20=D0=B8=D0=BD=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=83=D0=BA=D1=86=D0=B8=D1=8F=20=D1=83=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B8=20+=20=D0=B8=D0=B7=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B1=D0=B0=D0=B3=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D1=8B=20=D0=B2=20CLI=20+=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- README_ARCH.md | 335 +++++++++++++++++++++++++++++++++++++ bridge_player.py | 19 ++- ffplay-yt/ffplay.c | 105 ++++++++++++ grid_demo.py | 356 ++++++++++++++++++++++++++++------------ scripts/install-arch.sh | 249 ++++++++++++++++++++++++++++ scripts/run-youthub.sh | 30 ++++ youthub/feed.py | 272 +++++++++++++++++++++++++++--- youthub/innertube.py | 48 +++++- 9 files changed, 1288 insertions(+), 132 deletions(-) create mode 100644 README_ARCH.md create mode 100755 scripts/install-arch.sh create mode 100755 scripts/run-youthub.sh diff --git a/README.md b/README.md index 9d302aa..af927d5 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,11 @@ YouHub — это терминальный YouTube, работающий цел --- -## Установка +## Установка под Arch linux: + +[ARCH](README_ARCH.md) + +## Установка под Debian/Ubuntu: ### 1. Клонирование diff --git a/README_ARCH.md b/README_ARCH.md new file mode 100644 index 0000000..7d89e3f --- /dev/null +++ b/README_ARCH.md @@ -0,0 +1,335 @@ +# YouHub на Arch Linux + +Дополнение к основному [README.md](README.md). +Все общие разделы (управление, архитектура, OAuth, переменные окружения) — в основном README; здесь только то, что отличается на Arch (включая Omarchy и другие дистрибутивы на базе Arch). + +### Быстрый старт (скрипты) + +Из корня репозитория: + +```bash +./scripts/install-arch.sh # pacman, venv, pip, npm, ffplay-yt, команда youthub в PATH +youthub # из любой папки (после установки) +``` + +Пути **не нужно** прописывать вручную: скрипты сами находят корень репозитория; установщик создаёт `~/.local/bin/youthub` → `scripts/run-youthub.sh`. + +Опции: `./scripts/install-arch.sh --help` (`--skip-pacman`, `--skip-ffplay`, `--rebuild-ffplay`, `--install-alias`, `--no-launcher`). + +--- + +## Требования (Arch) + +- **Arch Linux** (или производный: Omarchy, EndeavourOS и т.д.) +- **X11** — upstream тестировал под X11; Wayland в README не заявлен (`xdotool` / `wmctrl` завязаны на X11) +- **kitty** >= 0.26 (тестировалось на 0.46.2) +- **Python 3.11+** — в README указан именно 3.11; на Arch в `extra` часто новее (3.12/3.13). Если что-то ломается, поставьте `python311` из AUR или соберите venv с нужной версией +- **Node.js** >= 18 (`nodejs` в extra) +- **FFmpeg** — системный, для мультиплексирования (`ffmpeg`) +- **SDL2**, **ALSA** — для сборки ffplay-yt + +### Пакеты одной командой + +```bash +sudo pacman -S --needed \ + base-devel nasm wget curl git \ + python python-pip \ + nodejs npm \ + ffmpeg \ + sdl2 dav1d opus alsa-lib \ + kitty \ + xdotool wmctrl \ + ttf-dejavu +``` + +Опционально для cookie watchstats: **Firefox** (скрипт `extract_cookies.py` читает профиль Firefox). + +--- + +## Установка + +### 1. Клонирование + +```bash +git clone https://github.com/HelpFreedom/youthub.git +cd youthub +``` + +### 2. Python-окружение + +```bash +python3 -m venv .venv +source .venv/bin/activate +python3 -m pip install --upgrade pip +python3 -m pip install curl_cffi "httpx[http2]" Pillow +``` + +Если в репозитории жёстко ожидается 3.11: + +```bash +# при наличии python3.11 в системе или AUR +python3.11 -m venv .venv +source .venv/bin/activate +python3.11 -m pip install curl_cffi "httpx[http2]" Pillow +``` + +### 3. Node-зависимости + +```bash +npm install +``` + +### 4. Сборка ffplay-yt + +Патченный исходник: `ffplay-yt/ffplay.c`. +На Arch нет `apt source` — берём официальный tarball FFmpeg **4.3.9**: + +```bash +# Зависимости сборки (если ещё не ставили) +sudo pacman -S --needed base-devel nasm sdl2 dav1d opus alsa-lib + +mkdir -p ffplay-yt/src && cd ffplay-yt/src +wget https://ffmpeg.org/releases/ffmpeg-4.3.9.tar.xz +tar xf ffmpeg-4.3.9.tar.xz +cd ffmpeg-4.3.9 + +cp ../../ffplay.c fftools/ffplay.c + +# Важно для Arch: системный dav1d (1.5.x) НЕ совместим с FFmpeg 4.3.9 +# (ошибка libdav1d.c: n_tile_threads / DAV1D_MAX_TILE_THREADS). +# YouHub в основном крутит H.264/VP9 — libdav1d (AV1) для сборки не обязателен. +./configure \ + --disable-everything \ + --enable-gpl --enable-version3 \ + --enable-decoder=h264,vp9,opus,aac,mp3,mjpeg,png \ + --enable-demuxer=matroska,mov,webm \ + --enable-protocol=file,pipe,unix,fd \ + --enable-filter=aresample,scale,atempo,volume \ + --enable-parser=h264,vp9,opus,aac \ + --enable-libopus \ + --enable-sdl2 --enable-ffplay \ + --enable-indev=alsa \ + --disable-doc --disable-htmlpages --disable-manpages \ + --disable-ffmpeg --disable-ffprobe + +make ffplay -j$(nproc) + +mkdir -p ../../bin +cp ffplay ../../bin/ffplay-yt +cd ../../.. +``` + +Проверка (бинарник лежит в `ffplay-yt/bin/`, не в корневом `bin/`): + +```bash +test -x ffplay-yt/bin/ffplay-yt && ffplay-yt/bin/ffplay-yt -version | head -1 +``` + +### 5. Системные утилиты + +```bash +sudo pacman -S --needed xdotool wmctrl ttf-dejavu kitty +``` + +### 6. Прокси (опционально) + +```bash +export HTTPS_PROXY="socks5://127.0.0.1:1080" +``` + +--- + +## Запуск (Arch / Omarchy) + +Нужен **kitty** с работающим graphics protocol (превью в сетке). + +**Рекомендуемый способ** — скрипт `scripts/run-youthub.sh` (отдельное окно kitty, cwd = репозиторий): + +```bash +~/youthub/scripts/run-youthub.sh +# то же вручную: +# kitty --directory=~/youthub -e bash -lc 'source .venv/bin/activate && python3 grid_demo.py' +``` + +Или в уже открытом kitty: + +```bash +cd ~/youthub +source .venv/bin/activate +python3 grid_demo.py +``` + +Перед запуском проверка (в **том же** окне): + +```bash +echo "TERM=$TERM TERM_PROGRAM=${TERM_PROGRAM:-}" +# желательно TERM_PROGRAM=kitty; если пусто, но окно визуально kitty — +# всё равно проверьте тест превью в разделе ниже +``` + +Первый запуск, OAuth, cookie, горячие клавиши — см. [README.md](README.md). + +--- + +## Превью не отображаются + +Метаданные (название, длительность) есть, а картинок нет — JPEG обычно **уже скачаны** (`cache/thumbnails/*.jpg`), но терминал **не рисует** Kitty graphics protocol. + +Проверьте **в том же окне**, где запускаете `grid_demo.py`: + +```bash +echo "TERM=$TERM TERM_PROGRAM=${TERM_PROGRAM:-}" +# нужно: TERM_PROGRAM=kitty (TERM часто xterm-kitty или xterm-256color) +``` + +| Что видите | Значение | +|------------|----------| +| `TERM_PROGRAM=kitty` | Ок для graphics protocol | +| `TERM_PROGRAM=` пусто | Часто **другой** эмулятор (foot/ghostty) или оболочка без переменных kitty; **иногда** бывает и внутри kitty — ориентируйтесь на **тест картинки** ниже, а не только на `echo` | +| внутри **tmux** | Graphics protocol часто не проходит — запустите вне tmux | + +Если превью нет, но вы уверены, что это kitty — попробуйте явный запуск: + +```bash +kitty --directory=~/youthub -e bash -lc 'source .venv/bin/activate && python3 grid_demo.py' +``` + +(у части пользователей Omarchy это сразу включает отрисовку превью). + +Тест картинки в kitty: + +```bash +cd ~/youthub && source .venv/bin/activate +python3 -c " +from pathlib import Path +from youthub import graphics, thumbnails +vid = next(Path('cache/thumbnails').glob('*.jpg')).stem +png = thumbnails.get_png(vid) +graphics.transmit_and_place(1, png, width_cells=20, height_cells=10) +print('Должно быть превью выше') +" +``` + +Если тест **не** показывает картинку — проблема в терминале/конфиге kitty, не в YouHub. +Если тест **показывает**, а сетка нет — напишите в issue (редкий случай). + +**Ghostty** (если хотите оставить его): в конфиге включите поддержку Kitty graphics protocol (см. документацию Ghostty) — код YouHub шлёт именно `\033_G`, не Sixel. + +--- + +## Omarchy + +- Для YouHub нужен **kitty** (graphics protocol). Если превью пустые — `kitty --directory=~/youthub -e ...` из раздела «Запуск»; пустой `TERM_PROGRAM` при `echo` не всегда значит «не kitty», но тест с `transmit_and_place` не врёт. +- Убедитесь, что сессия **X11**, если используете Hyprland/Wayland: для позиционирования окна ffplay может понадобиться XWayland и рабочие `xdotool`/`wmctrl` (как в upstream). +- Версия **kitty 0.46.2** полностью покрывает graphics protocol, который использует `youthub/graphics.py`. + +--- + +## Другие терминалы + +### Что сейчас в коде + +| Компонент | Терминал | Протокол | +|-----------|----------|----------| +| `grid_demo.py`, `youthub/graphics.py`, превью, поиск | **kitty** (обязательно для картинок в сетке) | Kitty graphics (`\033_G`, PNG `f=100`) | +| `bridge_player.py` + `bin/ffplay-yt` | Любой (отдельное SDL-окно) | — | +| `tui_player.py` (прототип) | kitty | Тот же graphics protocol | +| `player.py` (legacy) | kitty через `mpv --vo=kitty` | mpv → kitty | + +Другие эмуляторы **не проверяются** и **не выбираются** автоматически: в коде нет веток под iTerm2, Sixel, sixel-кит и т.д. + +### Можно ли «заточить» под другие + +**Реалистично без полной переделки UI:** + +1. **WezTerm / Ghostty** — часто поддерживают **тот же** Kitty graphics protocol (у WezTerm может понадобиться `enable_kitty_graphics = true` в конфиге). Теоретически достаточно запустить `grid_demo.py` там и проверить; официально проект это не тестирует. + +2. **Sixel** (foot, некоторые другие) — другой формат вывода; нужен отдельный бэкенд в `graphics.py` (конвертация PNG → sixel, другие размеры/позиционирование). Объём работы: средний. + +3. **iTerm2 inline images** (macOS/Linux) — свой OSC-протокол; отдельный бэкенд. + +4. **Без графики в терминале** — только текстовая сетка (без превью) или вынести UI в GUI/web; это уже другой продукт. + +**Практический совет на Arch:** оставить **kitty** для сетки (как задумано upstream) — у вас он уже есть и подходит по версии. + +--- + +## Типичные проблемы на Arch + +| Симптом | Что проверить | +|---------|----------------| +| `libdav1d.c: n_tile_threads` / `DAV1D_MAX_TILE_THREADS` | Системный **dav1d 1.5.x** слишком новый для **FFmpeg 4.3.9**. Пересоберите **без** `--enable-libdav1d` (см. блок `./configure` выше) или соберите старый dav1d в отдельный prefix (ниже) | +| `No space left on device` при `make` | Раздел `/` полон; освободите **2–5 ГБ**, затем `make distclean`, `./configure`, `export TMPDIR=/tmp`, `make ffplay` | +| `make: ffbuild/config.mak: No such file` | После `make distclean` нужен снова `./configure` | +| `cp: cannot stat 'ffplay'` | `make ffplay` не завершился — бинарник не создался | +| Нет картинок в сетке, только текст | Нет graphics protocol: см. [Превью не отображаются](#превью-не-отображаются); JPEG в `cache/thumbnails/` при этом могут быть | +| `./configure` не находит sdl2/opus | `pacman -S sdl2 opus alsa-lib` | +| ffplay не собирается | `base-devel`, `nasm`, версия исходников именно **4.3.9** | +| Окно плеера не встаёт как надо | X11/XWayland, `xdotool`, `wmctrl` | +| `ImportError: ... 'h2' package is not installed` | `pip install "httpx[http2]"` (нужен для `http2=True` в innertube/thumbnails) | +| `No module named 'playwright'` при Enter | В старом `bridge_player.py` был лишний `import bootstrap` — возьмите версию без него; **playwright для просмотра не нужен** | +| Сетка есть, **превью пустые** | [Превью не отображаются](#превью-не-отображаются) | +| Ошибки npm/node | `nodejs` >= 18, повторить `npm install` | +| `Cache entry deserialization failed` (pip) | Не критично; при желании: `pip cache purge` | + +### Опционально: libdav1d (AV1) со старым dav1d + +Если принципиально нужен декодер AV1 в ffplay-yt, соберите **dav1d 1.0.0** в локальный prefix (не путать с `pacman -S dav1d`): + +```bash +DEPS=$HOME/.local/youthub-ffmpeg-deps +mkdir -p "$DEPS" && cd /tmp +curl -LO https://code.videolan.org/videolan/dav1d/-/archive/1.0.0/dav1d-1.0.0.tar.bz2 +tar xf dav1d-1.0.0.tar.bz2 && cd dav1d-1.0.0 +meson setup build --prefix="$DEPS" --buildtype=release +meson compile -C build && meson install -C build + +cd /path/to/youthub/ffplay-yt/src/ffmpeg-4.3.9 +make distclean 2>/dev/null || true +PKG_CONFIG_PATH="$DEPS/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" \ + ./configure ... --enable-libdav1d --enable-decoder=...,libdav1d,... +``` + +Для YouHub обычно достаточно сборки **без** libdav1d. + +### Место на диске при сборке + +`make ffplay` может занять **1–3 ГБ**. Если `/` заполнен: + +```bash +df -h / +# освободить место (yay -Sc, pip cache purge, …) +cd ffplay-yt/src/ffmpeg-4.3.9 +make distclean +./configure # те же флаги, что в разделе 4 +export TMPDIR=/tmp +make ffplay -j$(nproc) +``` + +--- + +## Чеклист перед PR / отчётом «работает на Arch» + +- [ ] `ffplay-yt/bin/ffplay-yt` собран (`test -x ffplay-yt/bin/ffplay-yt`) +- [ ] `npm install` без ошибок +- [ ] venv: `curl_cffi`, `"httpx[http2]"`, `Pillow` +- [ ] Превью: тест `transmit_and_place` или сетка с картинками в kitty +- [ ] `python3 grid_demo.py` — лента + превью +- [ ] Enter — ffplay-yt, звук/картинка (без `playwright`) +- [ ] (опционально) OAuth и cookie по README + +Если что-то падает — версии: `pacman -Q kitty python nodejs ffmpeg`, вывод ошибки, `echo $TERM $TERM_PROGRAM`. + +--- + +## Кратко: что покрывает этот файл + +| Тема | Где в документе | +|------|-----------------| +| Скрипты `install-arch.sh`, `run-youthub.sh` | Быстрый старт | +| `pacman`, venv, `httpx[http2]` | Установка §1–3 | +| FFmpeg 4.3.9 без `libdav1d`, путь `ffplay-yt/bin/ffplay-yt` | §4 | +| Диск / `TMPDIR`, `configure` после `distclean` | Типичные проблемы | +| Запуск kitty, превью, `TERM_PROGRAM` | Запуск, § «Превью» | +| `playwright` не нужен | Типичные проблемы | +| Ghostty / другие терминалы | § «Другие терминалы» | diff --git a/bridge_player.py b/bridge_player.py index 87af37e..00f72a8 100644 --- a/bridge_player.py +++ b/bridge_player.py @@ -45,13 +45,28 @@ PROJECT_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(PROJECT_DIR)) -import bootstrap as bs_mod # noqa: E402 +import re + import sponsorblock as sb_mod # noqa: E402 from watchstats import WatchStats # noqa: E402 FFPLAY_YT = PROJECT_DIR / "ffplay-yt" / "bin" / "ffplay-yt" + +def _video_id_from_url(url: str) -> str: + """Extract YouTube video ID (same rules as bootstrap.video_id_from_url).""" + m = re.search(r"[?&]v=([A-Za-z0-9_-]{11})", url) + if m: + return m.group(1) + m = re.search(r"youtu\.be/([A-Za-z0-9_-]{11})", url) + if m: + return m.group(1) + m = re.search(r"/(?:embed|shorts)/([A-Za-z0-9_-]{11})", url) + if m: + return m.group(1) + raise ValueError(f"could not extract video id from URL: {url!r}") + # 2 MB ≈ a couple of seconds of 1080p — buffer between current playhead # and "must restart" decision so we don't trigger restarts on jitter. SEEK_SAFETY_BYTES = 2 * 1024 * 1024 @@ -803,7 +818,7 @@ def main() -> int: print("usage: bridge_player.py ", file=sys.stderr) return 2 arg = sys.argv[1] - vid = bs_mod.video_id_from_url(arg) if arg.startswith("http") else arg + vid = _video_id_from_url(arg) if arg.startswith("http") else arg title = f"YouTube — {vid}" p = start_player(vid, window_title=title) try: diff --git a/ffplay-yt/ffplay.c b/ffplay-yt/ffplay.c index bea09d0..34059d6 100644 --- a/ffplay-yt/ffplay.c +++ b/ffplay-yt/ffplay.c @@ -154,6 +154,7 @@ static int update_sponsor_overlay(void); static void draw_speed_overlay(int win_w, int win_h, int video_x); static void draw_seek_overlay(int win_w, int win_h, int video_x); static void draw_sponsor_overlay(int win_w, int win_h, int video_x); +static void draw_playback_hud(struct VideoState *is, int win_w, int win_h, int video_x); static void trigger_speed_overlay(double speed); static void trigger_seek_overlay(double delta_sec); static void trigger_sponsor_overlay(const char *category, @@ -1388,6 +1389,109 @@ static int draw_speed_text(int x, int y, int scale, const char *text, return pen_x - x; } +/* Persistent playback HUD at the bottom of the video area: + * - progress slider (master clock / container duration) + * - current/total time in seconds + * - volume mini-bars + * - current speed (x1.0 / x2.0 / ...) + * + * Deliberately uses primitive SDL rectangles + the built-in 3x5 bitmap + * glyphs so we stay compatible with SDL2 baseline (no SDL_ttf dependency). + */ +static void draw_playback_hud(VideoState *is, int win_w, int win_h, int video_x) { + if (!is) return; + int video_w = win_w - video_x; + if (video_w < 180 || win_h < 80) return; + + int panel_h = 34; + int panel_x = video_x + 12; + int panel_w = video_w - 24; + int panel_y = win_h - panel_h - 10; + if (panel_w < 120) return; + + SDL_Rect shadow = { panel_x + 2, panel_y + 2, panel_w, panel_h }; + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 120); + SDL_RenderFillRect(renderer, &shadow); + + SDL_Rect panel = { panel_x, panel_y, panel_w, panel_h }; + SDL_SetRenderDrawColor(renderer, 20, 20, 24, 210); + SDL_RenderFillRect(renderer, &panel); + + SDL_SetRenderDrawColor(renderer, 70, 70, 78, 255); + SDL_Rect top = { panel_x, panel_y, panel_w, 1 }; + SDL_RenderFillRect(renderer, &top); + + double pos_sec = get_master_clock(is); + if (isnan(pos_sec) || pos_sec < 0.0) pos_sec = 0.0; + double dur_sec = NAN; + if (is->ic && is->ic->duration > 0) + dur_sec = is->ic->duration / (double)AV_TIME_BASE; + + int cur_s = (int)(pos_sec + 0.5); + int total_s = (!isnan(dur_sec) && dur_sec > 0.0) ? (int)(dur_sec + 0.5) : 0; + double ratio = (!isnan(dur_sec) && dur_sec > 0.1) ? pos_sec / dur_sec : 0.0; + if (ratio < 0.0) ratio = 0.0; + if (ratio > 1.0) ratio = 1.0; + + int left_info_w = 56; + int right_info_w = 84; + int track_x = panel_x + left_info_w; + int track_w = panel_w - left_info_w - right_info_w; + if (track_w < 40) track_w = 40; + int track_h = 6; + int track_y = panel_y + 10; + + SDL_Rect rail = { track_x, track_y, track_w, track_h }; + SDL_SetRenderDrawColor(renderer, 52, 52, 60, 255); + SDL_RenderFillRect(renderer, &rail); + + int fill_w = (int)(ratio * track_w); + if (fill_w > 0) { + SDL_Rect fill = { track_x, track_y, fill_w, track_h }; + SDL_SetRenderDrawColor(renderer, 239, 206, 106, 255); + SDL_RenderFillRect(renderer, &fill); + } + + int thumb_x = track_x + fill_w - 2; + if (thumb_x < track_x) thumb_x = track_x; + if (thumb_x > track_x + track_w - 3) thumb_x = track_x + track_w - 3; + SDL_Rect thumb = { thumb_x, track_y - 2, 4, track_h + 4 }; + SDL_SetRenderDrawColor(renderer, 255, 232, 153, 255); + SDL_RenderFillRect(renderer, &thumb); + + char cur_buf[24], total_buf[24], speed_buf[24]; + snprintf(cur_buf, sizeof(cur_buf), "%ds", cur_s); + if (total_s > 0) snprintf(total_buf, sizeof(total_buf), "%ds", total_s); + else snprintf(total_buf, sizeof(total_buf), "--s"); + snprintf(speed_buf, sizeof(speed_buf), "%.1fx", playback_speed); + + draw_speed_text(panel_x + 8, panel_y + 7, 2, cur_buf, 220, 220, 220, 255); + draw_speed_text(track_x + track_w - 6 * 4, panel_y + 7, 2, total_buf, 170, 170, 178, 255); + + /* Right cluster: volume bars + speed text. */ + int vol = is->muted ? 0 : (int)(is->audio_volume * 100.0 / SDL_MIX_MAXVOLUME + 0.5); + if (vol < 0) vol = 0; + if (vol > 100) vol = 100; + int bars = (vol + 19) / 20; + int bx = panel_x + panel_w - 70; + int by = panel_y + 21; + int i; + for (i = 0; i < 5; i++) { + int h = 3 + i * 2; + SDL_Rect b = { bx + i * 5, by - h, 3, h }; + if (i < bars) SDL_SetRenderDrawColor(renderer, 239, 206, 106, 255); + else SDL_SetRenderDrawColor(renderer, 90, 90, 98, 255); + SDL_RenderFillRect(renderer, &b); + } + if (is->muted) { + SDL_SetRenderDrawColor(renderer, 200, 90, 90, 255); + SDL_Rect m = { bx - 6, by - 10, 2, 12 }; + SDL_RenderFillRect(renderer, &m); + } + + draw_speed_text(panel_x + panel_w - 34, panel_y + 20, 2, speed_buf, 220, 220, 220, 255); +} + /* The centered overlay: rounded-ish dark plate with 3 chevrons (lit * up to `speed_anim_value`) and the speed in a small caption. Pops * in with overshoot, holds, then fades out. */ @@ -2300,6 +2404,7 @@ static void video_display(VideoState *is) draw_speed_overlay(is->width, is->height, sidebar_offset_px); draw_seek_overlay(is->width, is->height, sidebar_offset_px); draw_sponsor_overlay(is->width, is->height, sidebar_offset_px); + draw_playback_hud(is, is->width, is->height, sidebar_offset_px); SDL_RenderPresent(renderer); diff --git a/grid_demo.py b/grid_demo.py index ff45896..3c147f4 100644 --- a/grid_demo.py +++ b/grid_demo.py @@ -37,6 +37,11 @@ HOME_TTL_SECS = 120 # short — we want fresh stuff on reload ERROR_LOG = Path(__file__).resolve().parent / "cache" / "grid_debug.log" KEY_LOG = Path(__file__).resolve().parent / "cache" / "grid_keys.log" +SESSION_LOG = Path(__file__).resolve().parent / "cache" / "session.log" + +# Краткое сообщение в строке подсказок (обновление, поиск, …). +_status_banner: str = "" +_status_banner_until: float = 0.0 # Time the focused tile must be unchanged before the hover preview starts. HOVER_DELAY = 3.0 @@ -54,6 +59,22 @@ RELOAD_KEYS = {"r", "R", "к", "К"} SEARCH_KEYS = {"f", "F", "а", "А"} +# Подсказки в нижней строке (см. draw_status). От короткой к полной. +_STATUS_HOTKEYS = ( + "hjkl · Enter · f · r · PgUp/Dn · Home · q", + "hjkl · Enter · f поиск · r обновить · PgUp/Dn · Home · q выход", + "←↑↓→/hjkl · Enter воспр. · f поиск · r обновить · " + "PgUp/PgDn листать · Home · q выход", +) +# Нижняя «хром»-полоса: подсказки (тёмная) + заголовок (инверсия, как было). +_STATUS_HINT_STYLE = "\033[48;5;236;38;5;250m" # тёмно-серый фон, светлый текст +_STATUS_TITLE_STYLE = "\033[7m" # инвертированный (белая полоса) + +# Удержание стрелок может давать десятки событий в секунду. +# Схлопываем короткий burst в один redraw, чтобы UI не "рвало". +_NAV_BURST_WINDOW_SEC = 0.012 +_NAV_BURST_MAX_KEYS = 64 + _PLAY_LOG = Path(__file__).resolve().parent / "cache" / "play.log" @@ -245,16 +266,94 @@ def _read_fresh(path: Path, ttl: int) -> dict | None: return None -def load_feed_combined() -> tuple[list[feed_mod.Video], list[str]]: - """Fetch home + /next-pivot for the first home video, merge & dedupe. +def _fetch_home_enriched(it: innertube.InnerTube, + *, max_continuation_pages: int = 0) -> feed_mod.Feed: + """Home feed, optionally plus continuation pages (more shelves).""" + home = feed_mod.parse_home(it.home()) + shelves = list(home.shelves) + cont = home.continuation + pages = 0 + while cont and pages < max_continuation_pages: + more = feed_mod.parse_browse_continuation( + it.browse("FEwhat_to_watch", continuation=cont)) + shelves.extend(more.shelves) + cont = more.continuation + pages += 1 + return feed_mod.Feed(shelves=shelves, continuation=cont) + + +def _fetch_pivot_merged(it: innertube.InnerTube, + seed_ids: list[str]) -> feed_mod.Feed: + """Merge ``/next`` pivot shelves from several seed videos.""" + merged_shelves: list[feed_mod.Shelf] = [] + for seed in seed_ids: + try: + pivot = feed_mod.parse_next_pivot(it.next(seed)) + merged_shelves.extend(pivot.shelves) + except Exception as e: + print(f"[grid] pivot {seed} failed: {e}", file=sys.stderr) + return feed_mod.Feed(shelves=merged_shelves) + + +def _log_session(msg: str) -> None: + SESSION_LOG.parent.mkdir(parents=True, exist_ok=True) + line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n" + with SESSION_LOG.open("a", encoding="utf-8") as f: + f.write(line) + print(f"[grid] {msg}", file=sys.stderr) + - Why combined: TVHTML5 home is just 4 shelves × 5 = 20 videos with no - continuation. The watch-next `pivot` of any video gives 10 more - shelves × 3 = ~30 contextual recommendations — far richer. +def set_status_banner(msg: str, *, secs: float = 8.0) -> None: + global _status_banner, _status_banner_until + _status_banner = msg + _status_banner_until = time.time() + secs - Returns: (videos, shelf_of) — flat lists; shelf_of[i] is the name - of the shelf videos[i] came from (used in the header bar). + +def load_feed_combined( + *, + refresh: bool = False, + exclude_ids: set[str] | None = None, +) -> tuple[list[feed_mod.Video], list[str]]: + """Fetch home + pivot recommendations, merge for the grid. + + Normal load: cache-friendly, one random pivot seed, home block then pivot. + Refresh (``r``): no cache, home + up to 2 continuation pages, several + pivot seeds, shelves interleaved like YouTube's mixed rows. """ + if refresh: + with innertube.InnerTube() as it: + home = _fetch_home_enriched(it, max_continuation_pages=2) + if home.shelves: + random.shuffle(home.shelves) + seeds = feed_mod.pivot_seed_ids(home, max_seeds=6) + pivot = _fetch_pivot_merged(it, seeds) if seeds else feed_mod.Feed() + videos, shelf_of = feed_mod.merge_feeds_for_grid( + [home, pivot], interleave=True) + if exclude_ids: + pairs = [(v, s) for v, s in zip(videos, shelf_of) + if v.video_id not in exclude_ids] + random.shuffle(pairs) + if len(pairs) < 12: + back = [(v, s) for v, s in zip(videos, shelf_of) + if v.video_id in exclude_ids] + random.shuffle(back) + need = min(len(back), 12 - len(pairs)) + pairs.extend(back[:need]) + if pairs: + videos, shelf_of = map(list, zip(*pairs)) + else: + videos, shelf_of = [], [] + else: + pairs = list(zip(videos, shelf_of)) + random.shuffle(pairs) + if pairs: + videos, shelf_of = map(list, zip(*pairs)) + _log_session( + f"refresh: {len(home.shelves)} home shelves, " + f"{len(seeds)} pivot seeds → {len(videos)} videos" + + (f" (без {len(exclude_ids)} старых)" if exclude_ids else "")) + return videos, shelf_of + raw_home = _read_fresh(HOME_CACHE, HOME_TTL_SECS) if raw_home is None: with innertube.InnerTube() as it: @@ -262,9 +361,6 @@ def load_feed_combined() -> tuple[list[feed_mod.Video], list[str]]: HOME_CACHE.write_text(json.dumps(raw_home, ensure_ascii=False)) home = feed_mod.parse_home(raw_home) - # Pick a random home video as the seed for `/next`. Different seed - # each launch → different pivot recommendations each launch, which - # is what the user actually means by "fresh on reload". all_home_videos = [v for sh in home.shelves for v in sh.videos] seed_id: str | None = None if all_home_videos: @@ -279,26 +375,7 @@ def load_feed_combined() -> tuple[list[feed_mod.Video], list[str]]: NEXT_CACHE.write_text(json.dumps(raw_next, ensure_ascii=False)) pivot = feed_mod.parse_next_pivot(raw_next) - # Merge in order: home shelves first (personalized), then pivot - # (contextual). Dedupe by video_id keeping first occurrence. - seen: set[str] = set() - videos: list[feed_mod.Video] = [] - shelf_of: list[str] = [] - for sh in home.shelves: - for v in sh.videos: - if v.video_id in seen: - continue - seen.add(v.video_id) - videos.append(v) - shelf_of.append(sh.title) - for sh in pivot.shelves: - for v in sh.videos: - if v.video_id in seen: - continue - seen.add(v.video_id) - videos.append(v) - shelf_of.append(sh.title.strip() or "Up next") - return videos, shelf_of + return feed_mod.merge_feeds_for_grid([home, pivot], interleave=False) def _seed_of(raw_next: dict) -> str | None: @@ -314,7 +391,7 @@ def _seed_of(raw_next: dict) -> str | None: # --- layout --------------------------------------------------------------- -def compute_layout(ts: terminal.TermSize, target_tile_w: int = 36): +def compute_layout(ts: terminal.TermSize, target_tile_w: int = 32): """Compute grid that fills the screen with tiles ~target_tile_w cells wide. Aspect 16:9 in pixels; tile_h derived from kitty's reported cell px size @@ -325,7 +402,7 @@ def compute_layout(ts: terminal.TermSize, target_tile_w: int = 36): """ cell_w = ts.cell_w or 9 cell_h = ts.cell_h or 18 - gutter = 3 + gutter = 2 top_margin = 2 bottom_margin = 2 side_margin = 2 @@ -337,7 +414,7 @@ def compute_layout(ts: terminal.TermSize, target_tile_w: int = 36): tile_h_px = tile_w * cell_w * 9 / 16 tile_h = max(3, int(tile_h_px / cell_h)) text_rows = 2 - min_row_gap = 2 # baseline gap between rows + min_row_gap = 1 # плотнее вертикально: на средних экранах чаще влезает 3 ряда row_content = tile_h + text_rows rows_available = ts.rows - top_margin - bottom_margin @@ -346,15 +423,9 @@ def compute_layout(ts: terminal.TermSize, target_tile_w: int = 36): 1, (rows_available + min_row_gap) // (row_content + min_row_gap), ) - # Spread the leftover rows back into the inter-row gaps so the grid - # vertically fills the screen rather than leaving a void at the bottom. - used = n_rows * row_content + max(0, n_rows - 1) * min_row_gap - leftover = max(0, rows_available - used) - if n_rows > 1: - extra_gap = leftover // (n_rows - 1) - else: - extra_gap = 0 - row_gap = min_row_gap + extra_gap + # Не размазываем leftover в межрядные зазоры: большие "дырки" между рядами + # визуально ломают сетку и оставляют место для артефактов рамки. + row_gap = min_row_gap row_total = row_content + row_gap return { @@ -517,9 +588,15 @@ def clear_slot(layout: dict, slot_idx: int) -> None: clear_focus_border(row, col, w, layout["tile_h"]) -def redraw_grid(layout: dict, videos, shelf_of, offset: int, +def redraw_grid(layout: dict, ts: terminal.TermSize, videos, shelf_of, offset: int, focus: int) -> None: """Redraw every visible slot for the current offset & focus.""" + # Полная очистка рабочей области (всё между шапкой и статусом). + # Это добивает любые хвосты по краям экрана после быстрых переходов. + for r in range(2, max(2, ts.rows - 1)): + graphics.move_cursor(r, 1) + writes(" " * ts.cols) + cap = layout["n_cols"] * layout["n_rows"] for slot in range(cap): global_idx = offset + slot @@ -530,23 +607,54 @@ def redraw_grid(layout: dict, videos, shelf_of, offset: int, clear_slot(layout, slot) -def draw_header(layout: dict, shelf_title: str, idx: int, total: int) -> None: +def draw_header(layout: dict, ts: terminal.TermSize, + header_title: str, idx: int, total: int) -> None: + """Top chrome row. Keep it stable while moving focus.""" + graphics.move_cursor(1, 1) + writes("\033[2K") graphics.move_cursor(1, 2) - writes("\033[1;96m" + clamp(f"YouTube — {shelf_title}", 70) + "\033[0m") - graphics.move_cursor(1, 80) + writes("\033[1;96m" + clamp(f"YouTube — {header_title}", 70) + "\033[0m") + graphics.move_cursor(1, max(2, ts.cols - 14)) writes(f"\033[90m[{idx + 1}/{total}]\033[0m") +def _status_hotkey_hint(cols: int) -> str: + """Строка подсказок, влезающая в ширину терминала.""" + for hint in reversed(_STATUS_HOTKEYS): + if len(hint) <= max(cols - 2, 0): + return hint + return _STATUS_HOTKEYS[0][: max(cols - 2, 0)] + + +def _fill_status_line(row: int, cols: int, text: str, style: str) -> None: + """Одна строка статуса на всю ширину терминала с заливкой фона.""" + graphics.move_cursor(row, 1) + writes("\033[2K") + bar = text.ljust(cols)[:cols] + writes(style) + writes(bar) + writes("\033[0m") + + def draw_status(ts: terminal.TermSize, video: feed_mod.Video, last_key: str = "") -> None: - graphics.move_cursor(ts.rows, 1) - writes("\033[2K") # clear line - head = f" {video.title}" - tail = f" [key={last_key or '·'}] hjkl/arrows · Enter · q " - space = ts.cols - len(tail) - 2 - if space < 10: - space = 10 - head = clamp(head, space) - writes(f"\033[7m{head}{' ' * max(0, space - len(head))}{tail}\033[0m") + """Нижние 2 строки: подсказки (тёмная полоса) + заголовок (инверсия).""" + global _status_banner, _status_banner_until + hint = _status_hotkey_hint(max(ts.cols - 2, 0)) + if _status_banner and time.time() < _status_banner_until: + banner = clamp(_status_banner, max(ts.cols // 2, 20)) + hint = f"{banner} · {hint}" + elif _status_banner: + _status_banner = "" + if ts.rows >= 2: + _fill_status_line(ts.rows - 1, ts.cols, hint, _STATUS_HINT_STYLE) + + tail = f" [{last_key}]" if last_key else "" + space = ts.cols - len(tail) + if space < 8: + space = 8 + head = clamp(f" {video.title}", space) + title_bar = f"{head}{tail}" + _fill_status_line(ts.rows, ts.cols, title_bar, _STATUS_TITLE_STYLE) # --- connecting animation ------------------------------------------------- @@ -831,10 +939,18 @@ def draw(self) -> None: def run_search(query: str) -> tuple[list[feed_mod.Video], list[str]]: - """Run a TVHTML5 search and flatten the result into a feed snapshot.""" + """InnerTube search (TV, then WEB context if TV returned nothing).""" with innertube.InnerTube() as it: raw = it.search(query) - parsed = feed_mod.parse_search(raw) + parsed = feed_mod.parse_search(raw) + n = sum(len(sh.videos) for sh in parsed.shelves) + if n == 0: + raw = it.search_web(query) + parsed = feed_mod.parse_search(raw) + n = sum(len(sh.videos) for sh in parsed.shelves) + _log_session(f"search «{query}»: WEB fallback → {n} videos") + else: + _log_session(f"search «{query}»: TV → {n} videos") videos: list[feed_mod.Video] = [] shelf_of: list[str] = [] label = f"Поиск: {query}" @@ -852,12 +968,10 @@ def run_search(query: str) -> tuple[list[feed_mod.Video], list[str]]: # --- reload helper -------------------------------------------------------- -def reload_feed() -> tuple[list[feed_mod.Video], list[str]]: - """Fresh fetch for the r/к reload key. - - Drops the home + next caches so we genuinely refetch, then reuses - `load_feed_combined` so the merge/dedupe logic stays in one place. - """ +def reload_feed( + exclude_ids: set[str] | None = None, +) -> tuple[list[feed_mod.Video], list[str]]: + """Fresh fetch for the r/к reload key — YouTube-like mixed refresh.""" for p in (HOME_CACHE, NEXT_CACHE): try: p.unlink() @@ -865,7 +979,7 @@ def reload_feed() -> tuple[list[feed_mod.Video], list[str]]: pass except OSError: pass - return load_feed_combined() + return load_feed_combined(refresh=True, exclude_ids=exclude_ids) # --- main loop ------------------------------------------------------------ @@ -887,6 +1001,26 @@ def clamp_focus(focus: int, n: int) -> int: return max(0, min(focus, n - 1)) +def _apply_nav_key(cur_focus: int, key: str, *, n_cols: int, cap: int, + total: int) -> int | None: + """Return updated focus for one nav key; None if key isn't navigation.""" + if key in (terminal.KEY_LEFT, "h"): + return max(0, cur_focus - 1) + if key in (terminal.KEY_RIGHT, "l"): + return min(total - 1, cur_focus + 1) + if key in (terminal.KEY_UP, "k"): + return max(0, cur_focus - n_cols) + if key in (terminal.KEY_DOWN, "j"): + return min(total - 1, cur_focus + n_cols) + if key == terminal.KEY_PGUP: + return max(0, cur_focus - cap) + if key == terminal.KEY_PGDN: + return min(total - 1, cur_focus + cap) + if key == terminal.KEY_HOME: + return 0 + return None + + def main() -> int: print("[grid] loading home + watch-next pivot…", file=sys.stderr) initial_videos, initial_shelf_of = load_feed_combined() @@ -919,6 +1053,7 @@ def on_winch(signum, frame): layout = compute_layout(ts) focus = 0 offset = 0 + header_title = "Рекомендованные" last_key = "" crash_info: str | None = None focus_changed_at = time.time() @@ -932,9 +1067,9 @@ def full_redraw(): graphics.delete_all() graphics.clear_screen() if videos: - draw_header(layout, shelf_of[focus], + draw_header(layout, ts, header_title, focus, len(videos)) - redraw_grid(layout, videos, shelf_of, offset, focus) + redraw_grid(layout, ts, videos, shelf_of, offset, focus) draw_status(ts, videos[focus], last_key=last_key) W.flush() @@ -964,6 +1099,7 @@ def cb(png: bytes) -> None: KEY_LOG.parent.mkdir(parents=True, exist_ok=True) key_log = KEY_LOG.open("w") + queued_key: str | None = None try: with terminal.KeyReader() as keys: while True: @@ -1010,7 +1146,11 @@ def cb(png: bytes) -> None: focus - offset, layout), ) - k = keys.read(timeout=0.2) + if queued_key is not None: + k = queued_key + queued_key = None + else: + k = keys.read(timeout=0.2) if k is None: continue last_key = k @@ -1050,15 +1190,14 @@ def cb(png: bytes) -> None: W.flush() continue if not new_v: + set_status_banner( + f"По запросу «{query}» ничего не найдено") full_redraw() - with _screen_lock: - graphics.move_cursor(ts.rows, 1) - writes("\033[2K\033[93m" - " По запросу ничего не " - "найдено.\033[0m") - W.flush() continue loader.replace(new_v, new_s) + set_status_banner( + f"Найдено: {len(new_v)} · «{clamp(query, 30)}»") + header_title = f"Поиск: {clamp(query, 32)}" videos, shelf_of = loader.snapshot() focus = 0 offset = 0 @@ -1113,7 +1252,8 @@ def cb(png: bytes) -> None: " Обновляем ленту…\033[0m") W.flush() try: - new_v, new_s = reload_feed() + old_ids = {v.video_id for v in videos} + new_v, new_s = reload_feed(exclude_ids=old_ids) except Exception as e: with _screen_lock: graphics.move_cursor(ts.rows, 1) @@ -1125,11 +1265,16 @@ def cb(png: bytes) -> None: loader.resume() if new_v: loader.replace(new_v, new_s) + header_title = "Рекомендованные" videos, shelf_of = loader.snapshot() focus = 0 offset = 0 thumbnails.prefetch( [v.video_id for v in videos[:60]]) + set_status_banner( + f"Обновлено: {len(new_v)} видео · лог: cache/session.log") + else: + set_status_banner("Не удалось обновить ленту") full_redraw() focus_changed_at = time.time() continue @@ -1179,23 +1324,33 @@ def cb(png: bytes) -> None: focus_changed_at = time.time() continue - new_focus = focus n_cols = layout["n_cols"] cap = n_cols * layout["n_rows"] - if k in (terminal.KEY_LEFT, "h"): - new_focus = max(0, focus - 1) - elif k in (terminal.KEY_RIGHT, "l"): - new_focus = min(len(videos) - 1, focus + 1) - elif k in (terminal.KEY_UP, "k"): - new_focus = max(0, focus - n_cols) - elif k in (terminal.KEY_DOWN, "j"): - new_focus = min(len(videos) - 1, focus + n_cols) - elif k == terminal.KEY_PGUP: - new_focus = max(0, focus - cap) - elif k == terminal.KEY_PGDN: - new_focus = min(len(videos) - 1, focus + cap) - elif k == terminal.KEY_HOME: - new_focus = 0 + new_focus = _apply_nav_key( + focus, k, n_cols=n_cols, cap=cap, total=len(videos)) + if new_focus is None: + with _screen_lock: + draw_status(ts, videos[focus], last_key=last_key) + W.flush() + continue + + # Collapse buffered key-repeat burst into one final focus. + burst_deadline = time.time() + _NAV_BURST_WINDOW_SEC + burst_count = 0 + while burst_count < _NAV_BURST_MAX_KEYS and time.time() < burst_deadline: + k2 = keys.read(timeout=0.0) + if k2 is None: + break + key_log.write(f"{time.time():.3f} k={k2!r}\n") + key_log.flush() + nxt = _apply_nav_key( + new_focus, k2, n_cols=n_cols, cap=cap, total=len(videos)) + if nxt is None: + queued_key = k2 + break + last_key = k2 + new_focus = nxt + burst_count += 1 if new_focus == focus: with _screen_lock: @@ -1209,23 +1364,14 @@ def cb(png: bytes) -> None: preview_player.stop() new_offset = scroll_offset_for(new_focus, offset, layout) + offset = new_offset + focus = new_focus + # Надёжнее частичных дельта-апдейтов: перерисовываем весь + # видимый viewport. Чуть дороже, зато без "жёлтых хвостов" + # рамки при быстрых переходах вверх/вниз/влево/вправо. with _screen_lock: - if new_offset != offset: - offset = new_offset - focus = new_focus - draw_header(layout, shelf_of[focus], focus, len(videos)) - redraw_grid(layout, videos, shelf_of, offset, focus) - else: - old_slot = focus - offset - new_slot = new_focus - offset - old_row, old_col = tile_origin(layout, old_slot) - clear_focus_border(old_row, old_col, - layout["tile_w"], layout["tile_h"]) - draw_tile(layout, old_slot, videos[focus], focused=False) - draw_tile(layout, new_slot, videos[new_focus], focused=True) - draw_header(layout, shelf_of[new_focus], - new_focus, len(videos)) - focus = new_focus + draw_header(layout, ts, header_title, focus, len(videos)) + redraw_grid(layout, ts, videos, shelf_of, offset, focus) draw_status(ts, videos[focus], last_key=last_key) W.flush() focus_changed_at = time.time() diff --git a/scripts/install-arch.sh b/scripts/install-arch.sh new file mode 100755 index 0000000..558f234 --- /dev/null +++ b/scripts/install-arch.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# YouHub — установка зависимостей и сборка ffplay-yt на Arch Linux. +# Запуск из корня репозитория: ./scripts/install-arch.sh +# Опции: --skip-pacman не вызывать sudo pacman +# --skip-ffplay не собирать ffplay-yt +# --rebuild-ffplay пересобрать ffplay-yt (make distclean + configure) + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +SKIP_PACMAN=0 +SKIP_FFPLAY=0 +REBUILD_FFPLAY=0 +INSTALL_LAUNCHER=1 +INSTALL_ALIAS=0 + +for arg in "$@"; do + case "$arg" in + --skip-pacman) SKIP_PACMAN=1 ;; + --skip-ffplay) SKIP_FFPLAY=1 ;; + --rebuild-ffplay) REBUILD_FFPLAY=1 ;; + --no-launcher) INSTALL_LAUNCHER=0 ;; + --install-alias) INSTALL_ALIAS=1 ;; + -h|--help) + sed -n '2,6p' "$0" | sed 's/^# \?//' + echo " --skip-pacman только venv, pip, npm (без pacman)" + echo " --skip-ffplay без сборки ffplay-yt" + echo " --rebuild-ffplay заново configure + make ffplay" + echo " --no-launcher не создавать ~/.local/bin/youthub" + echo " --install-alias дополнительно прописать alias в ~/.bashrc / ~/.zshrc" + exit 0 + ;; + *) echo "Неизвестный аргумент: $arg" >&2; exit 1 ;; + esac +done + +RUN_SCRIPT="$ROOT/scripts/run-youthub.sh" +LAUNCHER_BIN="$HOME/.local/bin/youthub" +ALIAS_MARKER="# youthub-launcher (install-arch.sh)" + +FFPLAY_BIN="$ROOT/ffplay-yt/bin/ffplay-yt" +FFMPEG_SRC="$ROOT/ffplay-yt/src/ffmpeg-4.3.9" +FFMPEG_TAR="$ROOT/ffplay-yt/src/ffmpeg-4.3.9.tar.xz" +FFMPEG_URL="https://ffmpeg.org/releases/ffmpeg-4.3.9.tar.xz" +MIN_FREE_MB=2048 + +if [[ -f /etc/os-release ]]; then + # shellcheck disable=SC1091 + source /etc/os-release + if [[ "${ID:-}" != "arch" && "${ID_LIKE:-}" != *arch* ]]; then + echo "[!] Похоже, это не Arch (${ID:-unknown}). Скрипт всё равно можно пробовать." + fi +fi + +step() { echo; echo "==> $*"; } +ok() { echo " ✓ $*"; } +warn() { echo " ! $*" >&2; } +die() { echo " ✗ $*" >&2; exit 1; } + +check_disk() { + local avail_kb + avail_kb="$(df -k "$ROOT" | awk 'NR==2 {print $4}')" + if [[ -z "$avail_kb" ]]; then return; fi + if (( avail_kb < MIN_FREE_MB * 1024 )); then + die "Мало места на диске ($(df -h "$ROOT" | awk 'NR==2 {print $4}') свободно). Нужно ~${MIN_FREE_MB} МБ для сборки ffplay." + fi + ok "Свободно на разделе: $(df -h "$ROOT" | awk 'NR==2 {print $4}')" +} + +PACMAN_PKGS=( + base-devel nasm wget curl git + python + nodejs npm + ffmpeg + sdl2 opus alsa-lib + kitty + xdotool wmctrl + ttf-dejavu +) + +step "YouHub — установка на Arch (корень: $ROOT)" + +if (( SKIP_PACMAN == 0 )); then + step "Системные пакеты (pacman)" + if ! command -v pacman >/dev/null; then + die "pacman не найден" + fi + echo " Пакеты: ${PACMAN_PKGS[*]}" + sudo pacman -S --needed "${PACMAN_PKGS[@]}" + ok "pacman готов" +else + warn "Пропуск pacman (--skip-pacman)" +fi + +step "Python venv (.venv)" +if ! command -v python3 >/dev/null; then + die "python3 не найден — установите пакет python" +fi +echo " Версия: $(python3 --version)" +if [[ ! -d .venv ]]; then + python3 -m venv .venv + ok "venv создан" +else + ok "venv уже есть" +fi +# shellcheck disable=SC1091 +source .venv/bin/activate +python3 -m pip install --upgrade pip -q +python3 -m pip install curl_cffi "httpx[http2]" Pillow +ok "pip: curl_cffi, httpx[http2], Pillow" + +step "Node.js (npm install)" +if ! command -v npm >/dev/null; then + die "npm не найден" +fi +echo " node: $(node --version 2>/dev/null || echo '?')" +npm install +ok "node_modules готовы" + +if (( SKIP_FFPLAY == 0 )); then + if [[ -x "$FFPLAY_BIN" && $REBUILD_FFPLAY -eq 0 ]]; then + step "ffplay-yt" + ok "уже собран: $FFPLAY_BIN (для пересборки: --rebuild-ffplay)" + else + step "Сборка ffplay-yt (FFmpeg 4.3.9, без libdav1d — совместимость с Arch)" + check_disk + + mkdir -p ffplay-yt/src + if [[ ! -f "$FFMPEG_TAR" ]]; then + echo " Скачиваю $FFMPEG_URL" + wget -O "$FFMPEG_TAR" "$FFMPEG_URL" + else + ok "tarball уже есть: ffmpeg-4.3.9.tar.xz" + fi + + if [[ ! -d "$FFMPEG_SRC" ]]; then + echo " Распаковка…" + tar -xf "$FFMPEG_TAR" -C ffplay-yt/src + fi + + cp -f "$ROOT/ffplay-yt/ffplay.c" "$FFMPEG_SRC/fftools/ffplay.c" + ok "патч ffplay.c применён" + + cd "$FFMPEG_SRC" + export TMPDIR="${TMPDIR:-/tmp}" + + if (( REBUILD_FFPLAY == 1 )); then + echo " make distclean…" + make distclean 2>/dev/null || true + fi + + if [[ ! -f ffbuild/config.mak ]] || (( REBUILD_FFPLAY == 1 )); then + echo " ./configure (это может занять минуту)…" + ./configure \ + --disable-everything \ + --enable-gpl --enable-version3 \ + --enable-decoder=h264,vp9,opus,aac,mp3,mjpeg,png \ + --enable-demuxer=matroska,mov,webm \ + --enable-protocol=file,pipe,unix,fd \ + --enable-filter=aresample,scale,atempo,volume \ + --enable-parser=h264,vp9,opus,aac \ + --enable-libopus \ + --enable-sdl2 --enable-ffplay \ + --enable-indev=alsa \ + --disable-doc --disable-htmlpages --disable-manpages \ + --disable-ffmpeg --disable-ffprobe + else + ok "configure уже выполнен (используй --rebuild-ffplay для пересборки с нуля)" + fi + + echo " make ffplay -j$(nproc) (TMPDIR=$TMPDIR)…" + make ffplay -j"$(nproc)" + + mkdir -p "$ROOT/ffplay-yt/bin" + cp -f ffplay "$FFPLAY_BIN" + chmod +x "$FFPLAY_BIN" + cd "$ROOT" + ok "ffplay-yt: $FFPLAY_BIN" + fi +else + warn "Пропуск сборки ffplay (--skip-ffplay)" +fi + +step "Проверка" +[[ -d .venv ]] || die "нет .venv" +[[ -d node_modules ]] || die "нет node_modules" +if (( SKIP_FFPLAY == 0 )) || [[ -x "$FFPLAY_BIN" ]]; then + [[ -x "$FFPLAY_BIN" ]] || die "нет исполняемого $FFPLAY_BIN" + ok "ffplay-yt исполняемый" +fi +command -v kitty >/dev/null && ok "kitty: $(kitty --version 2>/dev/null | head -1)" || warn "kitty не в PATH" + +install_launcher() { + step "Команда youthub в PATH (путь подставляется автоматически)" + mkdir -p "$HOME/.local/bin" + ln -sf "$RUN_SCRIPT" "$LAUNCHER_BIN" + ok "symlink: $LAUNCHER_BIN → $RUN_SCRIPT" + case ":$PATH:" in + *":$HOME/.local/bin:"*) ok '~/.local/bin уже в PATH' ;; + *) + warn '~/.local/bin нет в PATH — добавьте в ~/.bashrc или ~/.zshrc:' + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" + ;; + esac +} + +install_shell_alias() { + local line="alias youthub='$RUN_SCRIPT'" + local rc + for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + [[ -f "$rc" ]] || continue + if grep -qF "$ALIAS_MARKER" "$rc" 2>/dev/null; then + ok "алиас уже есть в $rc" + continue + fi + { + echo "" + echo "$ALIAS_MARKER" + echo "$line" + } >>"$rc" + ok "алиас добавлен в $rc" + done +} + +if (( INSTALL_LAUNCHER )); then + install_launcher +fi +if (( INSTALL_ALIAS )); then + install_shell_alias +fi + +echo +echo "Готово." +echo +if (( INSTALL_LAUNCHER )) && [[ -x "$LAUNCHER_BIN" ]]; then + echo " Запуск из любой папки:" + echo " youthub" + echo +fi +echo " Или напрямую (путь к репо не нужен — скрипт сам найдёт себя):" +echo " $RUN_SCRIPT" +echo +if (( INSTALL_ALIAS == 0 && INSTALL_LAUNCHER )); then + echo " Алиас в shell (опционально): ./scripts/install-arch.sh --install-alias" + echo +fi +echo " Подробности: README_ARCH.md" diff --git a/scripts/run-youthub.sh b/scripts/run-youthub.sh new file mode 100755 index 0000000..e8419da --- /dev/null +++ b/scripts/run-youthub.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# YouHub — запуск grid_demo.py в отдельном окне kitty (Kitty graphics protocol → превью). +# Путь к репозиторию определяется по расположению этого файла — вручную ничего не подставляйте. +# Использование: ./scripts/run-youthub.sh +# youthub (после install-arch.sh: symlink ~/.local/bin/youthub) + +set -euo pipefail + +# Через symlink (~/.local/bin/youthub) BASH_SOURCE[0] — путь к ссылке, не к репо. +SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" +ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)" +FFPLAY_BIN="$ROOT/ffplay-yt/bin/ffplay-yt" +VENV_PY="$ROOT/.venv/bin/python3" + +die() { echo "youthub: $*" >&2; exit 1; } + +command -v kitty >/dev/null || die "kitty не найден. Установите: sudo pacman -S kitty" + +[[ -x "$VENV_PY" ]] || die "нет $VENV_PY — сначала: $ROOT/scripts/install-arch.sh" + +[[ -x "$FFPLAY_BIN" ]] || die "нет $FFPLAY_BIN — сначала: $ROOT/scripts/install-arch.sh" + +[[ -d "$ROOT/node_modules" ]] || die "нет node_modules — в $ROOT выполните: npm install" + +# Отдельное окно kitty, cwd = репозиторий (как в README_ARCH). +exec kitty --directory="$ROOT" bash -lc ' + set -euo pipefail + source .venv/bin/activate + exec python3 grid_demo.py +' diff --git a/youthub/feed.py b/youthub/feed.py index 4578743..fece9e2 100644 --- a/youthub/feed.py +++ b/youthub/feed.py @@ -9,6 +9,7 @@ """ from __future__ import annotations +import random from dataclasses import dataclass, field from typing import Optional @@ -151,6 +152,82 @@ def parse_tile(tile: dict) -> Optional[Video]: ) +def parse_video_renderer(node: dict) -> Optional[Video]: + """Parse WEB/TV search item renderers (not only tileRenderer).""" + vr = ( + node.get("videoRenderer") + or node.get("gridVideoRenderer") + or node.get("compactVideoRenderer") + or node.get("playlistVideoRenderer") + ) + if not vr: + return None + + video_id = vr.get("videoId") + if not video_id: + nav = vr.get("navigationEndpoint") or vr.get("command", {}) + if isinstance(nav, dict): + watch = nav.get("watchEndpoint") or {} + video_id = watch.get("videoId") + if not video_id or len(video_id) != 11: + return None + + title = _text(vr.get("title")) or _text(vr.get("headline")) or "" + thumb_url = _best_thumbnail( + (vr.get("thumbnail") or {}).get("thumbnails", []) + ) + + channel = None + owner = vr.get("ownerText") or vr.get("longBylineText") or vr.get("shortBylineText") + if owner: + channel = _text(owner) + + views = _text(vr.get("viewCountText")) or _text(vr.get("shortViewCountText")) + age = _text(vr.get("publishedTimeText")) + + duration = None + for key in ("lengthText", "thumbnailOverlays"): + if key == "lengthText": + duration = _text(vr.get("lengthText")) + if duration: + break + for ov in vr.get("thumbnailOverlays", []) or []: + ts = ov.get("thumbnailOverlayTimeStatusRenderer") + if ts: + duration = _text(ts.get("text")) + break + if duration: + break + + return Video( + video_id=video_id, + title=title, + channel=channel, + views=views, + age=age, + duration=duration, + thumbnail_url=thumb_url, + ) + + +def _videos_from_list_items(items: list) -> list[Video]: + """Extract videos from a horizontal list / item section.""" + out: list[Video] = [] + for it in items or []: + if not isinstance(it, dict): + continue + tile = it.get("tileRenderer") + if tile: + v = parse_tile(tile) + if v: + out.append(v) + continue + v = parse_video_renderer(it) + if v: + out.append(v) + return out + + def parse_shelf(shelf_node: dict) -> Optional[Shelf]: """Parse one shelfRenderer into a Shelf with its videos.""" sh = shelf_node.get("shelfRenderer") @@ -167,19 +244,11 @@ def parse_shelf(shelf_node: dict) -> Optional[Shelf]: or "" ) - items = ( - sh.get("content", {}) - .get("horizontalListRenderer", {}) - .get("items", []) - ) - videos: list[Video] = [] - for it in items: - tile = it.get("tileRenderer") - if not tile: - continue - v = parse_tile(tile) - if v: - videos.append(v) + content = sh.get("content", {}) + items = content.get("horizontalListRenderer", {}).get("items", []) + if not items: + items = content.get("gridShelfViewModel", {}).get("contents", []) + videos = _videos_from_list_items(items) return Shelf(title=title, videos=videos) @@ -191,6 +260,11 @@ def _walk_sections(sections: list) -> Feed: sh = parse_shelf(sec) if sh: feed.shelves.append(sh) + elif "itemSectionRenderer" in sec: + contents = sec["itemSectionRenderer"].get("contents", []) + vids = _videos_from_list_items(contents) + if vids: + feed.shelves.append(Shelf(title="Результаты", videos=vids)) elif "continuationItemRenderer" in sec: cont = ( sec["continuationItemRenderer"] @@ -216,18 +290,172 @@ def parse_home(raw: dict) -> Feed: return _walk_sections(sections) -def parse_search(raw: dict) -> Feed: - """Parse a TVHTML5 /search response into a Feed. +def _search_section_lists(raw: dict) -> list[list]: + """Collect section-list ``contents`` arrays from search JSON.""" + found: list[list] = [] + + def add(sections: object) -> None: + if isinstance(sections, list) and sections: + found.append(sections) - Same shape as home — sectionListRenderer → shelfRenderer → - horizontalListRenderer → tileRenderer — just without the - `tvBrowseRenderer` wrapper that home uses. - """ try: - sections = raw["contents"]["sectionListRenderer"]["contents"] + add(raw["contents"]["sectionListRenderer"]["contents"]) except (KeyError, TypeError): - return Feed() - return _walk_sections(sections) + pass + try: + add(raw["contents"]["tvSearchRenderer"]["content"] + ["sectionListRenderer"]["contents"]) + except (KeyError, TypeError): + pass + try: + add(raw["contents"]["twoColumnSearchResultsRenderer"] + ["primaryContents"]["sectionListRenderer"]["contents"]) + except (KeyError, TypeError): + pass + try: + add(raw["contents"]["tvBrowseRenderer"]["content"] + ["tvSurfaceContentRenderer"]["content"] + ["sectionListRenderer"]["contents"]) + except (KeyError, TypeError): + pass + return found + + +def parse_search(raw: dict) -> Feed: + """Parse InnerTube /search (TV or WEB-shaped JSON).""" + feed = Feed() + seen: set[str] = set() + + for sections in _search_section_lists(raw): + part = _walk_sections(sections) + feed.continuation = feed.continuation or part.continuation + for sh in part.shelves: + uniq: list[Video] = [] + for v in sh.videos: + if v.video_id in seen: + continue + seen.add(v.video_id) + uniq.append(v) + if uniq: + feed.shelves.append(Shelf(title=sh.title, videos=uniq)) + + # Fallback: walk renderers (WEB search often nests videoRenderer deeply). + if not feed.shelves: + extras: list[Video] = [] + + def walk(obj: object, depth: int = 0) -> None: + if depth > 22 or len(extras) > 80: + return + if isinstance(obj, dict): + if any(k in obj for k in ( + "videoRenderer", "gridVideoRenderer", + "compactVideoRenderer", "tileRenderer", + )): + v = parse_video_renderer(obj) + if not v and "tileRenderer" in obj: + v = parse_tile(obj["tileRenderer"]) + if v and v.video_id not in seen: + seen.add(v.video_id) + extras.append(v) + for v in obj.values(): + walk(v, depth + 1) + elif isinstance(obj, list): + for x in obj[:60]: + walk(x, depth + 1) + + walk(raw.get("contents", raw)) + if extras: + feed.shelves.append(Shelf(title="Результаты", videos=extras)) + + return feed + + +def parse_browse_continuation(raw: dict) -> Feed: + """Parse a browse continuation page (home/search section list). + + Continuation responses may omit the ``tvBrowseRenderer`` wrapper and + expose ``sectionListRenderer`` directly — try both shapes. + """ + for path in ( + lambda r: r["contents"]["tvBrowseRenderer"]["content"] + ["tvSurfaceContentRenderer"]["content"]["sectionListRenderer"]["contents"], + lambda r: r["contents"]["sectionListRenderer"]["contents"], + lambda r: r["onResponseReceivedActions"][0]["appendContinuationItemsAction"] + ["continuationItems"], + ): + try: + sections = path(raw) + if sections: + return _walk_sections(sections) + except (KeyError, TypeError, IndexError): + continue + return Feed() + + +def flatten_shelves_interleaved(feed: Feed) -> tuple[list[Video], list[str]]: + """Round-robin across shelves — closer to YouTube's mixed home rows.""" + if not feed.shelves: + return [], [] + max_len = max(len(sh.videos) for sh in feed.shelves) + videos: list[Video] = [] + shelf_of: list[str] = [] + for i in range(max_len): + for sh in feed.shelves: + if i < len(sh.videos): + videos.append(sh.videos[i]) + shelf_of.append(sh.title) + return videos, shelf_of + + +def merge_feeds_for_grid(feeds: list[Feed], *, interleave: bool = True + ) -> tuple[list[Video], list[str]]: + """Merge several feeds; dedupe by video_id, keep first shelf label.""" + seen: set[str] = set() + videos: list[Video] = [] + shelf_of: list[str] = [] + for feed in feeds: + if interleave: + chunk_v, chunk_s = flatten_shelves_interleaved(feed) + else: + chunk_v, chunk_s = [], [] + for sh in feed.shelves: + for v in sh.videos: + chunk_v.append(v) + chunk_s.append(sh.title) + for v, label in zip(chunk_v, chunk_s): + if v.video_id in seen: + continue + seen.add(v.video_id) + videos.append(v) + shelf_of.append(label) + return videos, shelf_of + + +def pivot_seed_ids(home: Feed, *, max_seeds: int = 4) -> list[str]: + """Pick diverse seeds for ``/next`` pivot — one per shelf, then random fill.""" + seeds: list[str] = [] + seen: set[str] = set() + for sh in home.shelves: + if not sh.videos: + continue + vid = sh.videos[0].video_id + if vid not in seen: + seeds.append(vid) + seen.add(vid) + if len(seeds) >= max_seeds: + return seeds + rest = [ + v.video_id + for sh in home.shelves + for v in sh.videos + if v.video_id not in seen + ] + random.shuffle(rest) + for vid in rest: + seeds.append(vid) + if len(seeds) >= max_seeds: + break + return seeds def parse_next_pivot(raw: dict) -> Feed: diff --git a/youthub/innertube.py b/youthub/innertube.py index b7e3e19..1d99b9a 100644 --- a/youthub/innertube.py +++ b/youthub/innertube.py @@ -27,6 +27,9 @@ CLIENT_VERSION = "7.20250122.14.00" CLIENT_NAME_HEADER = "7" # X-YouTube-Client-Name for TVHTML5 +# InnerTube search filter: results type «Videos» (works for TV + WEB bodies). +SEARCH_PARAMS_VIDEOS = "EgIQAQ==" + USER_AGENT = ( "Mozilla/5.0 (PlayStation; PlayStation 4/12.50) AppleWebKit/605.1.15 " "(KHTML, like Gecko) Version/13.1.2 Safari/605.1.15" @@ -136,10 +139,51 @@ def search(self, query: str, *, params: Optional[str] = None, body["continuation"] = continuation else: body["query"] = query - if params: - body["params"] = params + body["params"] = params if params is not None else SEARCH_PARAMS_VIDEOS return self._post("search", body) + def search_web(self, query: str, *, params: Optional[str] = None, + continuation: Optional[str] = None) -> dict: + """Search with WEB client context — richer results than bare TV search.""" + self._ensure_fresh() + body: dict[str, Any] = {} + if continuation: + body["continuation"] = continuation + else: + body["query"] = query + body["params"] = params if params is not None else SEARCH_PARAMS_VIDEOS + ctx = { + "client": { + "clientName": "WEB", + "clientVersion": "2.20250222.10.00", + "hl": "ru", + "gl": "RU", + "userAgent": ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36" + ), + }, + "user": {"lockedSafetyMode": False}, + "request": {"useSsl": True}, + } + full = {"context": ctx, **body} + headers = {"Authorization": f"Bearer {self._tokens.access_token}"} + r = self._http.post( + f"{BASE}/search", + headers=headers, + content=json.dumps(full).encode(), + ) + if r.status_code == 401: + self._tokens = auth.refresh(self._tokens) + headers["Authorization"] = f"Bearer {self._tokens.access_token}" + r = self._http.post( + f"{BASE}/search", + headers=headers, + content=json.dumps(full).encode(), + ) + r.raise_for_status() + return r.json() + def next(self, video_id: str, *, continuation: Optional[str] = None) -> dict: body: dict[str, Any] = {}