From ce731c28da8707feb1d6d58dcd13d705672c2d86 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 23 Feb 2026 16:47:17 +0300 Subject: [PATCH] Initial homelab docs Co-authored-by: Cursor --- homelab/VPN-KEENETIC-CONTEXT-PROMPT.md | 71 + homelab/architecture.md | 58 + homelab/containers-context.md | 134 ++ homelab/docs/storage-mount-layout.md | 49 + homelab/gitea/.env.example | 4 + homelab/gitea/README.md | 138 ++ homelab/gitea/create-lxc-103.sh | 29 + homelab/gitea/docker-compose-103.yml | 71 + homelab/gitea/homepage-services-snippet.yaml | 7 + homelab/immich/proxmox-config.md | 75 + homelab/immich/setup-swap.sh | 21 + homelab/nextcloud/docker-compose-101.yml | 46 + homelab/nextcloud/docker-nextcloud.service | 15 + homelab/nextcloud/php-uploads.ini | 6 + homelab/npm-add-proxy.sh | 59 + homelab/npm-cert-cloud.sh | 98 + .../__pycache__/gen-dashboard.cpython-313.pyc | Bin 0 -> 22027 bytes homelab/npm-log-dashboard/gen-dashboard.py | 404 ++++ homelab/npm-log-dashboard/verify-dashboard.py | 71 + homelab/paperless-ngx/docker-compose-104.yml | 37 + homelab/paperless-ngx/docker-compose.env | 8 + homelab/paperless-ngx/docker-compose.yml | 40 + .../paperless-ngx/docker-paperless.service | 15 + homelab/paperless-ollama-README.md | 44 + homelab/paperless-ollama-ask.py | 142 ++ .../scripts/README-reorganize-games-common.md | 47 + homelab/scripts/README-reorganize-games.md | 35 + .../steam_library_size.cpython-313.pyc | Bin 0 -> 10528 bytes homelab/scripts/check_size_sources.py | 128 ++ homelab/scripts/merge_names_into_sizes.py | 50 + homelab/scripts/reorganize-games-common.sh | 185 ++ homelab/scripts/reorganize-games.sh | 232 ++ homelab/scripts/resolve_steam_appid.py | 115 + homelab/scripts/steam-library-to-json.py | 59 + homelab/scripts/steam-to-cloud-options.md | 37 + homelab/scripts/steam_app_sizes.json | 271 +++ homelab/scripts/steam_library.json | 1885 +++++++++++++++++ homelab/scripts/steam_library_size.py | 195 ++ homelab/scripts/steam_library_with_sizes.json | 1347 ++++++++++++ homelab/us-router.conf | 19 + homelab/vpn-keenetic-us-second-connection.md | 99 + homelab/vpn-route-check/.gitignore | 1 + homelab/vpn-route-check/Dockerfile | 9 + .../PROMPT-deploy-to-server.md | 28 + homelab/vpn-route-check/README.md | 49 + .../__pycache__/check_routes.cpython-313.pyc | Bin 0 -> 17555 bytes homelab/vpn-route-check/check_routes.py | 379 ++++ homelab/vpn-route-check/deploy-on-proxmox.sh | 18 + homelab/vpn-route-check/docker-compose.yml | 17 + homelab/vpn-route-check/domains.txt | 56 + homelab/vpn-route-check/entrypoint.sh | 11 + homelab/vpn-route-check/homepage-widget.yaml | 7 + homelab/vpn-route-check/serve_no_cache.py | 22 + 53 files changed, 6943 insertions(+) create mode 100644 homelab/VPN-KEENETIC-CONTEXT-PROMPT.md create mode 100644 homelab/architecture.md create mode 100644 homelab/containers-context.md create mode 100644 homelab/docs/storage-mount-layout.md create mode 100644 homelab/gitea/.env.example create mode 100644 homelab/gitea/README.md create mode 100644 homelab/gitea/create-lxc-103.sh create mode 100644 homelab/gitea/docker-compose-103.yml create mode 100644 homelab/gitea/homepage-services-snippet.yaml create mode 100644 homelab/immich/proxmox-config.md create mode 100644 homelab/immich/setup-swap.sh create mode 100644 homelab/nextcloud/docker-compose-101.yml create mode 100644 homelab/nextcloud/docker-nextcloud.service create mode 100644 homelab/nextcloud/php-uploads.ini create mode 100755 homelab/npm-add-proxy.sh create mode 100644 homelab/npm-cert-cloud.sh create mode 100644 homelab/npm-log-dashboard/__pycache__/gen-dashboard.cpython-313.pyc create mode 100644 homelab/npm-log-dashboard/gen-dashboard.py create mode 100644 homelab/npm-log-dashboard/verify-dashboard.py create mode 100644 homelab/paperless-ngx/docker-compose-104.yml create mode 100644 homelab/paperless-ngx/docker-compose.env create mode 100644 homelab/paperless-ngx/docker-compose.yml create mode 100644 homelab/paperless-ngx/docker-paperless.service create mode 100644 homelab/paperless-ollama-README.md create mode 100644 homelab/paperless-ollama-ask.py create mode 100644 homelab/scripts/README-reorganize-games-common.md create mode 100644 homelab/scripts/README-reorganize-games.md create mode 100644 homelab/scripts/__pycache__/steam_library_size.cpython-313.pyc create mode 100644 homelab/scripts/check_size_sources.py create mode 100644 homelab/scripts/merge_names_into_sizes.py create mode 100644 homelab/scripts/reorganize-games-common.sh create mode 100644 homelab/scripts/reorganize-games.sh create mode 100644 homelab/scripts/resolve_steam_appid.py create mode 100644 homelab/scripts/steam-library-to-json.py create mode 100644 homelab/scripts/steam-to-cloud-options.md create mode 100644 homelab/scripts/steam_app_sizes.json create mode 100644 homelab/scripts/steam_library.json create mode 100644 homelab/scripts/steam_library_size.py create mode 100644 homelab/scripts/steam_library_with_sizes.json create mode 100644 homelab/us-router.conf create mode 100644 homelab/vpn-keenetic-us-second-connection.md create mode 100644 homelab/vpn-route-check/.gitignore create mode 100644 homelab/vpn-route-check/Dockerfile create mode 100644 homelab/vpn-route-check/PROMPT-deploy-to-server.md create mode 100644 homelab/vpn-route-check/README.md create mode 100644 homelab/vpn-route-check/__pycache__/check_routes.cpython-313.pyc create mode 100644 homelab/vpn-route-check/check_routes.py create mode 100755 homelab/vpn-route-check/deploy-on-proxmox.sh create mode 100644 homelab/vpn-route-check/docker-compose.yml create mode 100644 homelab/vpn-route-check/domains.txt create mode 100755 homelab/vpn-route-check/entrypoint.sh create mode 100644 homelab/vpn-route-check/homepage-widget.yaml create mode 100644 homelab/vpn-route-check/serve_no_cache.py diff --git a/homelab/VPN-KEENETIC-CONTEXT-PROMPT.md b/homelab/VPN-KEENETIC-CONTEXT-PROMPT.md new file mode 100644 index 0000000..61eaac8 --- /dev/null +++ b/homelab/VPN-KEENETIC-CONTEXT-PROMPT.md @@ -0,0 +1,71 @@ +# Контекст: VPN на Keenetic, два AmneziaWG-сервера (DE и US) + +**Использование:** скопируй этот блок в начало запроса или прикрепи файл (@VPN-KEENETIC-CONTEXT-PROMPT.md), чтобы ассистент сразу имел все вводные. + +--- + +## Цель + +На роутере Keenetic настроены два WireGuard (AmneziaWG)-подключения к разным VPS. Оба используют один и тот же адрес клиента **10.8.1.2** и шлюз **10.8.1.2**. Трафик к определённым сетям (YouTube и др.) идёт через выбранный VPN. Переключение DE ↔ US — только включением/выключением нужного подключения в веб-интерфейсе, без правки маршрутов. + +--- + +## Серверы и доступ + +| Сервер | IP | SSH | AmneziaWG порт | Контейнер | +|--------|-----|-----|----------------|-----------| +| **DE** | 185.103.253.99 | `ssh root@185.103.253.99` | 33118 | amnezia-awg, конфиг `/opt/amnezia/awg/wg0.conf` | +| **US** | 147.45.124.117 | `ssh root@147.45.124.117` | 37135 | amnezia-awg2, конфиг `/opt/amnezia/awg/awg0.conf` | + +- Мой статический IP: **185.35.193.144**. +- На обоих VPS установлен AmneziaVPN (AmneziaWG в Docker). Для управления/создания пользователей используется приложение AmneziaVPN на macOS; при отключённом входе по паролю на сервере нужен доступ по SSH-ключу, иначе ошибка 300 (SshRequestDeniedError). + +--- + +## Общие параметры AmneziaWG (одинаковые на DE и US) + +- Подсеть туннеля: **10.8.1.0/24**, сервер — 10.8.1.0. +- Параметры обфускации (asc), единые для обоих серверов и для конфигов роутера: + - **Jc = 6**, Jmin = 10, Jmax = 50 + - **S1 = 90**, **S2 = 62** + - **H1 = 1455064900**, **H2 = 852483043**, **H3 = 2078090415**, **H4 = 1981181588** + +Роутер (клиент) всегда имеет адрес **10.8.1.2/32**; на каждом сервере в конфиге есть peer с AllowedIPs = 10.8.1.2/32 (свой публичный ключ роутера/клиента). + +--- + +## Роутер Keenetic + +- Адрес веб-интерфейса: **192.168.1.1**. Telnet доступен (для отладки). +- **Два WireGuard-подключения:** + - **netcraze_amnezia** — к DE (185.103.253.99:33118), peer 10.8.1.2 на DE (ключ HSgydyy...). + - **router_us** — к US (147.45.124.117:37135), peer 10.8.1.2 на US (ключ 2a6d52kL...). Конфиг для роутера лежит в репозитории: **homelab/us-router.conf** (Address 10.8.1.2/32, те же asc). +- Включено только одно из двух подключений; маршруты с шлюзом 10.8.1.2 тогда идут через активный туннель. + +**Приоритеты подключений (политики):** +- **Политика по умолчанию:** Ethernet, netcraze_amnezia, router_us (все с галочками; порядок приоритета: Ethernet → netcraze_amnezia → router_us). +- **Политика netcraze_amnezia:** только netcraze_amnezia (для устройств, которые должны идти только через DE). +- **Политика us:** только router_us (для устройств, которые должны идти только через US). + +Чтобы новое WireGuard-подключение появилось в списке при настройке политик, у него должна быть включена галочка **«Использовать для выхода в интернет»** (в настройках подключения в разделе **Интернет → Другие подключения → Wireguard**). + +**Маршрутизация:** +- Пользовательские маршруты (шлюз 10.8.1.2) заданы **по интерфейсу**: часть — **netcraze_amnezia**, часть — **router_us** (одинаковые сети, один шлюз 10.8.1.2, разный интерфейс). Так сделано специально: при включённом только одном VPN активны только маршруты этого интерфейса. +- Импорт маршрутов: формат **.bat** (Windows), строки вида `route add mask 10.8.1.2`. Интерфейс **не в файле** — выбирается при загрузке в выпадающем списке (Импорт → выбор интерфейса: netcraze_amnezia или router_us). Один и тот же файл можно загрузить дважды, указав разный интерфейс, чтобы получить маршруты для обоих VPN. Часть импортированных маршрутов может отображаться в блоке «Действующие маршруты IPv4», а не в «Пользовательские» — это нормально, трафик при этом идёт корректно. +- Инструкция по второму (US) подключению и конфигам: **homelab/vpn-keenetic-us-second-connection.md**. Общая схема homelab: **homelab/architecture.md**. + +--- + +## Добавление третьего VPS + +Конфиг нового сервера можно собрать по образцу DE/US: та же подсеть 10.8.1.0/24, те же asc, свой ListenPort и свой PrivateKey сервера; добавить peer 10.8.1.2/32 с публичным ключом роутера (текущий router_us: 2a6d52kL..., либо новый ключ при создании нового пользователя в Amnezia на третьем сервере). Для этого нужны: IP третьего VPS, SSH-доступ (root или sudo), установленный AmneziaWG в Docker (или возможность его установить). + +--- + +## Краткая сводка для копипаста + +- **DE:** 185.103.253.99, ssh root@185.103.253.99, порт 33118, контейнер amnezia-awg, wg0.conf. +- **US:** 147.45.124.117, ssh root@147.45.124.117, порт 37135, контейнер amnezia-awg2, awg0.conf. +- **Роутер:** 192.168.1.1, два WG: netcraze_amnezia (DE), router_us (US); оба 10.8.1.2/32; переключение — включить один, выключить другой. +- **Маршруты:** шлюз 10.8.1.2, интерфейс задаётся при импорте .bat (netcraze_amnezia или router_us); два набора маршрутов (по одному на интерфейс). +- **Конфиг роутера для US:** homelab/us-router.conf. **ASC везде одинаковые:** Jc=6, Jmin=10, Jmax=50, S1=90, S2=62, H1–H4 как выше. diff --git a/homelab/architecture.md b/homelab/architecture.md new file mode 100644 index 0000000..a298562 --- /dev/null +++ b/homelab/architecture.md @@ -0,0 +1,58 @@ +# Архитектура домашних сервисов (homelab) + +Краткое описание по состоянию настройки Invidious и сопутствующих сервисов. + +--- + +## Сеть и доступ + +- **Внешний IP:** 185.35.193.144 +- **Домашний сервер (Proxmox):** 192.168.1.150 (LAN), доступ: `ssh root@192.168.1.150` +- **DNS домена katykhin.ru:** Beget.com +- **Reverse proxy и SSL:** Nginx Proxy Manager (NPM) на контейнере 100. Домены: video.katykhin.ru, call.katykhin.ru, home.katykhin.ru, wallos.katykhin.ru, cloud.katykhin.ru, docs.katykhin.ru, immich.katykhin.ru. + +--- + +## Гипервизор + +- **Proxmox VE.** Гости — в основном **LXC-контейнеры**, одна **KVM VM** (Immich, ID 200). +- Управление LXC: `pct` (например `pct exec -- bash`). Управление VM: `qm`. +- IP задаётся статически (в конфиге LXC/VM или резерв на роутере). Схема **ID = последний октет** (100→.100, 101→.101, …, 108→.108, 200→.200). + +--- + +## Ключевые контейнеры и VM + +| ID | Назначение | IP | Заметки | +|-----|-------------------------|----------------|--------| +| 100 | NPM, Homepage, AdGuard, Wallos | 192.168.1.100 | Docker (NPM, Homepage, AdGuard, Wallos). Данные NPM: `/opt/docker/nginx-proxy/data`. Certbot для Let's Encrypt (в т.ч. DNS-01 через Beget). | +| 101 | Nextcloud | 192.168.1.101 | cloud.katykhin.ru. Docker Compose: Nextcloud + PostgreSQL + Redis, порт 8080. | +| 103 | Gitea + Actions | 192.168.1.103 | 1 core, 2 GB RAM, 15 GB (local-lvm). Gitea + PostgreSQL + act_runner, порты 3000 (HTTP), 2222 (SSH). Только LAN. | +| 104 | Paperless-ngx | 192.168.1.104 | docs.katykhin.ru, порт 8000. | +| 107 | Invidious (Misc) | 192.168.1.107 | video.katykhin.ru. 1 core, 2 GB RAM. Docker Compose: Invidious + Companion + PostgreSQL, порт 3000. | +| 108 | Galene (видеозвонки) | 192.168.1.108 | call.katykhin.ru → 192.168.1.108:8443. | +| 200 | Immich (KVM VM) | 192.168.1.200 | immich.katykhin.ru. Фото, ML на GPU (RTX 4060 Ti). Не LXC. | + +--- + +## Поток запросов (упрощённо) + +1. Запрос с интернета: `https://video.katykhin.ru` → роутер (порты 80/443 на 185.35.193.144) → проброс на 192.168.1.100. +2. NPM (контейнер 100) принимает HTTPS, проверяет Host, смотрит proxy_host → upstream: например 192.168.1.107:3000 для video.katykhin.ru, 192.168.1.200:2283 для immich.katykhin.ru и т.д. +3. Invidious (контейнер 107) отдаёт страницу и ссылки на стримы; при «без прокси» видео стримится напрямую с YouTube в браузер. + +--- + +## SSL-сертификаты + +- **Let's Encrypt:** через certbot на контейнере 100. Для доменов, где HTTP-01 недоступен, используется **DNS-01** (плагин certbot-dns-beget-api, учётные данные Beget в `/root/.secrets/certbot/beget.ini`). После выпуска/продления сертификаты копируются в NPM (custom_ssl) и перезагружается nginx в контейнере NPM. +- **Самоподписные:** при необходимости добавляются вручную в NPM (БД + файлы в custom_ssl). + +--- + +## Дополнительно + +- **Homepage:** на контейнере 100, конфиг сервисов в `/opt/docker/homepage/config/services.yaml` (ссылки на NPM, Invidious, AdGuard, Immich, Galene и т.д.). +- **VPS 185.103.253.99 (DE):** AmneziaWG для обхода блокировок; маршрутизация выбранных сетей через роутер (WireGuard netcraze_amnezia, 10.8.1.2). +- **VPS 147.45.124.117 (US):** второй AmneziaWG; на роутере можно добавить второе подключение с тем же адресом 10.8.1.2 и переключать DE/US без смены маршрутов. Подробно: [vpn-keenetic-us-second-connection.md](vpn-keenetic-us-second-connection.md). +- Подробнее по контейнерам и доступу: см. [containers-context.md](containers-context.md). diff --git a/homelab/containers-context.md b/homelab/containers-context.md new file mode 100644 index 0000000..b0302fb --- /dev/null +++ b/homelab/containers-context.md @@ -0,0 +1,134 @@ +# Контекст по контейнерам (Proxmox LXC) + +## Общая схема + +| ID | Назначение | IP | Заметки | +|-----|-------------------|----------------|---------| +| 100 | NPM, Homepage, лог-дашборд | 192.168.1.100 | Шлюз/дашборды | +| 101 | **Nextcloud** | 192.168.1.101 | — | +| 103 | **Gitea** | 192.168.1.103 | Gitea + PostgreSQL + act_runner (Gitea Actions). Порт 3000, SSH 2222. | +| 104 | Paperless-ngx | 192.168.1.104 | docs.katykhin.ru | +| 107 | Invidious | 192.168.1.107 | video.katykhin.ru | +| 108 | Galene | 192.168.1.108 | call.katykhin.ru | +| 200 | Immich (VM) | 192.168.1.200 | KVM, immich.katykhin.ru | + +**Хост Proxmox:** `192.168.1.150` +Вход на контейнеры — с хоста через `pct exec -- ...` или `pct enter `. + +--- + +## CT 101 — Nextcloud + +### Как попасть + +1. **С твоей машины на хост Proxmox:** + ```bash + ssh root@192.168.1.150 + ``` + *(пароль хоста — у тебя)* + +2. **С хоста — в контейнер 101:** + ```bash + pct enter 101 + ``` + Либо выполнить одну команду без входа в shell: + ```bash + pct exec 101 -- <команда> + ``` + *(пароль root в CT 101 — если включён и задан; часто тот же, что на хосте, или без пароля при `pct enter` с хоста)* + +3. **По сети (веб):** + - Внутри сети: `http://192.168.1.101:8080` + - Снаружи: `https://cloud.katykhin.ru` (прокси через NPM на 100). + +### Что внутри CT 101 + +- **Docker Compose** с тремя сервисами: + - **nextcloud** — порт `8080:80` + - **db** — PostgreSQL 16 + - **redis** — кэш/очереди + +- **Рабочая директория Compose:** `/opt/nextcloud` + Файл в репо: `homelab/nextcloud/docker-compose-101.yml` — на сервере обычно как `docker-compose.yml` в `/opt/nextcloud`. + +- **Systemd-сервис:** `docker-nextcloud.service` + Запуск/остановка: из контейнера 101: + ```bash + systemctl start docker-nextcloud # или restart/stop + ``` + Либо вручную: + ```bash + cd /opt/nextcloud && docker compose up -d + ``` + +### Креды (из репозитория) + +- **PostgreSQL (внутри CT 101):** + - DB: `nextcloud` + - User: `nextcloud` + - Password: `nextcloud` + *(из `docker-compose-101.yml`)* + +- **Nextcloud (приложение):** + Подключается к БД теми же кредыми. Админ-пользователь Nextcloud (логин/пароль веб-интерфейса) задаётся при первом запуске в UI — в репо не хранятся. + +- **Redis:** без пароля (только внутри Docker-сети). + +### Пути и конфиги внутри CT 101 + +- Данные PostgreSQL: `/mnt/nextcloud-data/pgdata` +- Данные Nextcloud (файлы, конфиг): `/mnt/nextcloud-data/html` +- Доп. том: `/mnt/nextcloud-extra` +- PHP uploads: `/opt/nextcloud/php-uploads.ini` (лимиты 64G, 2G memory и т.д. — см. `homelab/nextcloud/php-uploads.ini`) + +### Полезные команды (на хосте 192.168.1.150) + +```bash +# Войти в CT 101 +pct enter 101 + +# Логи Nextcloud +pct exec 101 -- bash -c 'cd /opt/nextcloud && docker compose logs -f nextcloud' + +# Статус контейнеров +pct exec 101 -- docker ps + +# Перезапуск стека Nextcloud +pct exec 101 -- bash -c 'cd /opt/nextcloud && docker compose restart' +``` + +### Чего нет в репо (нужны тебе локально) + +- Пароль `root` на Proxmox (192.168.1.150) +- Пароль `root` в CT 101 (если включён логин по паролю) +- Логин/пароль админа Nextcloud (веб-интерфейс) + +--- + +## CT 103 — Gitea + +### Как попасть + +1. С хоста Proxmox: `pct enter 103` или `pct exec 103 -- <команда>`. +2. По сети (веб): `http://192.168.1.103:3000` (только LAN, домен не настроен). + +### Что внутри CT 103 + +- **Docker Compose** в `/opt/gitea` (файл в репо: `homelab/gitea/docker-compose-103.yml`): + - **server** — Gitea, порты 3000 (HTTP), 2222 (SSH) + - **db** — PostgreSQL 16 (Alpine) + - **runner** — act_runner (Gitea Actions), использует Docker хоста + +- Токен регистрации раннера задаётся в `.env` (см. `homelab/gitea/.env.example`). Получить: Администрирование → Actions → Runners → Registration token. + +### Полезные команды (на хосте 192.168.1.150) + +```bash +pct enter 103 +pct exec 103 -- bash -c 'cd /opt/gitea && docker compose ps' +pct exec 103 -- bash -c 'cd /opt/gitea && docker compose logs -f runner' +``` + +### Создание контейнера 103 + +Инструкция и параметры (LXC 103, 1 core, 2 GB RAM, 15 GB на local-lvm, IP 192.168.1.103): см. [homelab/gitea/README.md](gitea/README.md). diff --git a/homelab/docs/storage-mount-layout.md b/homelab/docs/storage-mount-layout.md new file mode 100644 index 0000000..e9aa33f --- /dev/null +++ b/homelab/docs/storage-mount-layout.md @@ -0,0 +1,49 @@ +# Разметка дисков и монтирование (хост Proxmox 192.168.1.150) + +## Текущая схема + +### Хост + +| Устройство | Размер | Раздел | Точка монтирования | Использовано | +|------------|--------|--------|--------------------|--------------| +| **sdd** (WDC WD80EFPX 8 TB) | 7.28 TiB | sdd1 **4 TB** ext4 | `/mnt/nextcloud-hdd` | 3.4 TB | +| **sdb** (SATA SSD) | 1.9 TB | sdb1 ext4 | `/mnt/ssd-storage` | 932 GB | + +- На диске **sdd** занята только часть: раздел sdd1 = 4 TB. Свободно ~3.3 TB неразмеченного места. +- **LVM не используется** для этих дисков — обычные разделы ext4. + +### Контейнер 101 (Nextcloud) + +| Хост (источник) | В контейнере (mp) | +|------------------|-------------------| +| `/mnt/ssd-storage/nextcloud-101` | `/mnt/nextcloud-data` | +| `/mnt/nextcloud-hdd` | `/mnt/nextcloud-extra` | + +- **«HDD 4 TB»** в Nextcloud = `/mnt/nextcloud-extra` в CT = хост `/mnt/nextcloud-hdd` (диск sdd, 4 TB). +- **«Игры»** = папка внутри данных Nextcloud на SSD: + - В CT: `/mnt/nextcloud-data/html/data/kerrad/files/Игры` (≈928 GB) + - На хосте: `/mnt/ssd-storage/nextcloud-101/html/data/kerrad/files/Игры` + +### Раздел sdd (8 TB диск) + +``` +Disk /dev/sdd: 7.28 TiB +Disklabel type: gpt +/dev/sdd1 Start 2048 End 8589936639 Size 4T (Linux filesystem) + ↑ свободно до конца диска ~3.3 TB +``` + +Расширение до 6.5 TB: увеличить конец раздела sdd1, затем `resize2fs`. Это не LVM, а рост обычного раздела + файловой системы. + +--- + +## План: один том «Игры» 7.5 TB + том «Прочее» 200 GB + +1. **Расширить раздел и ФС на sdd до 7.5 TB** (на хосте, при остановленном использовании тома). ✅ Сделано. +2. **Перенести содержимое «Игры»** (928 GB) в `/mnt/nextcloud-hdd` (то есть в текущий nextcloud-extra) и удалить том Игры. +3. **Размонтировать/удалить** только папку «Игры» из файлов (данные Игры убираем с SSD; место освобождается). +4. **В Nextcloud:** убрать отображение папки «Игры», оставить одно хранилище и переименовать его в Игры. +5. **Смонтировать новый раздел** с SSD диска на 200 гб, назвать его Прочее и подключить как внешнюю папку + +Итого: одна сетевая папка Игры = смонтированный расширенный том **7.5 TB** на sdd. +Вторая сетевая папка Прочее = смонтированный как внешняя папка том 200 GB на SSD. \ No newline at end of file diff --git a/homelab/gitea/.env.example b/homelab/gitea/.env.example new file mode 100644 index 0000000..004708f --- /dev/null +++ b/homelab/gitea/.env.example @@ -0,0 +1,4 @@ +# Скопировать в .env и подставить токен после первой настройки Gitea. +# Токен: Администрирование → Actions → Runners → Registration token. + +GITEA_RUNNER_REGISTRATION_TOKEN= diff --git a/homelab/gitea/README.md b/homelab/gitea/README.md new file mode 100644 index 0000000..d8112ed --- /dev/null +++ b/homelab/gitea/README.md @@ -0,0 +1,138 @@ +# Gitea + Gitea Actions на CT 103 + +LXC 103: 192.168.1.103, 1 core, 2 GB RAM, 15 GB диск (local-lvm). +Доступ: только LAN, без домена. + +## 1. Создание LXC 103 на Proxmox + +Выполнять на хосте **192.168.1.150** (ssh root@192.168.1.150). + +### 1.1. Шаблон (если ещё нет) + +Проверить доступные шаблоны и при необходимости скачать Debian 12: + +```bash +pveam available | grep debian-12 +pveam download local debian-12-standard_12.7-1_amd64.tar.zst +# или актуальный из списка pveam available +pveam list local +``` + +Имя шаблона для `pct create` — как в `pveam list local`, например: `local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst`. + +### 1.2. Создать контейнер + +**Важно:** шлюз `gw=192.168.1.1` — поправь, если у тебя другой (например роутер на .1). + +```bash +pct create 103 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \ + --hostname gitea \ + --cores 1 \ + --memory 2048 \ + --swap 512 \ + --rootfs local-lvm:15 \ + --net0 name=eth0,bridge=vmbr0,ip=192.168.1.103/24,gw=192.168.1.1 \ + --features nesting=1 \ + --onboot 1 \ + --start 1 +``` + +`nesting=1` нужен для Docker внутри LXC. + +### 1.3. Проверить хранилище + +Если твоё хранилище называется иначе (не `local-lvm`): + +```bash +pvesm status +``` + +Если корневой диск на другом storage — замени в команде `--rootfs local-lvm:15` на свой, например `--rootfs SSD:15`. + +--- + +## 2. В контейнере 103: Docker и Gitea + +### 2.1. Войти в контейнер + +```bash +pct enter 103 +``` + +### 2.2. Установить Docker + +```bash +apt update && apt install -y ca-certificates curl +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc +chmod a+r /etc/apt/keyrings/docker.asc +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list +apt update && apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin +``` + +### 2.3. Развернуть Gitea + +Файлы из репозитория (`homelab/gitea/`) нужно положить в контейнер в `/opt/gitea/`. С хоста Proxmox, если репо уже есть на хосте: + +```bash +pct exec 103 -- mkdir -p /opt/gitea +pct push 103 /path/на/хосте/к/homelab/gitea/docker-compose-103.yml /opt/gitea/docker-compose.yml +pct push 103 /path/на/хосте/к/homelab/gitea/.env.example /opt/gitea/.env.example +``` + +В контейнере: + +```bash +mkdir -p /opt/gitea +cd /opt/gitea +# положить docker-compose-103.yml как docker-compose.yml и .env.example как .env +cp .env.example .env +# пока токен пустой — раннер не зарегистрируется, это нормально +docker compose up -d +``` + +### 2.4. Первый запуск Gitea + +1. В браузере: **http://192.168.1.103:3000** +2. Пройти установку (админ, пароль, остальное по желанию). +3. После входа: **Администрирование** → **Actions** → **Runners** → скопировать **Registration token**. +4. В контейнере в `/opt/gitea/.env` прописать: + ```bash + GITEA_RUNNER_REGISTRATION_TOKEN=вставь_токен_сюда + ``` +5. Перезапустить раннер: + ```bash + cd /opt/gitea && docker compose up -d runner + ``` + +Раннер должен появиться в **Actions → Runners** со статусом «Online». + +--- + +## 3. Полезные команды (с хоста 192.168.1.150) + +```bash +pct enter 103 +pct exec 103 -- bash -c 'cd /opt/gitea && docker compose ps' +pct exec 103 -- bash -c 'cd /opt/gitea && docker compose logs -f server' +``` + +--- + +## 4. Homepage (контейнер 100) + +Добавить в `/opt/docker/homepage/config/services.yaml` запись для Gitea (в нужную группу), например: + +```yaml +- Gitea: + icon: gitea.png + href: http://192.168.1.103:3000 + description: Git-сервер (LAN) + widget: + type: iframe + url: http://192.168.1.103:3000 +``` + +Иконку `gitea.png` при необходимости положить в каталог иконок Homepage. + +Готовый фрагмент для вставки: [homepage-services-snippet.yaml](homepage-services-snippet.yaml). diff --git a/homelab/gitea/create-lxc-103.sh b/homelab/gitea/create-lxc-103.sh new file mode 100644 index 0000000..c49e180 --- /dev/null +++ b/homelab/gitea/create-lxc-103.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Создание LXC 103 для Gitea на Proxmox. +# Запускать на хосте: ssh root@192.168.1.150 'bash -s' < create-lxc-103.sh +# Или скопировать на хост и выполнить там. +# +# Перед запуском: скачать шаблон (если нет): +# pveam download local debian-12-standard_12.7-1_amd64.tar.zst +# pveam list local +# Подставить актуальное имя шаблона в TEMPLATE ниже. +# Поправить GW= если шлюз не 192.168.1.1. + +set -e +VMID=103 +TEMPLATE="local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst" +GW="${GW:-192.168.1.1}" + +echo "Creating CT ${VMID} (Gitea), template=${TEMPLATE}, gw=${GW}" +pct create ${VMID} "${TEMPLATE}" \ + --hostname gitea \ + --cores 1 \ + --memory 2048 \ + --swap 512 \ + --rootfs local-lvm:15 \ + --net0 name=eth0,bridge=vmbr0,ip=192.168.1.103/24,gw="${GW}" \ + --features nesting=1 \ + --onboot 1 \ + --start 1 + +echo "CT ${VMID} created and started. Next: pct enter ${VMID}, install Docker, deploy compose (see README.md)." diff --git a/homelab/gitea/docker-compose-103.yml b/homelab/gitea/docker-compose-103.yml new file mode 100644 index 0000000..d7e083c --- /dev/null +++ b/homelab/gitea/docker-compose-103.yml @@ -0,0 +1,71 @@ +# Gitea + PostgreSQL + act_runner (Gitea Actions) +# Контейнер 103, IP 192.168.1.103 +# Запуск: в /opt/gitea на CT 103 + +services: + db: + image: docker.io/library/postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: gitea + POSTGRES_PASSWORD: gitea + POSTGRES_DB: gitea + volumes: + - gitea-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gitea"] + interval: 10s + timeout: 5s + retries: 5 + + server: + image: docker.gitea.com/gitea:1.25 + container_name: gitea + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + USER_UID: 1000 + USER_GID: 1000 + GITEA__database__DB_TYPE: postgres + GITEA__database__HOST: db:5432 + GITEA__database__NAME: gitea + GITEA__database__USER: gitea + GITEA__database__PASSWD: gitea + GITEA__server__DOMAIN: 192.168.1.103 + GITEA__server__ROOT_URL: http://192.168.1.103:3000/ + GITEA__server__SSH_PORT: 2222 + volumes: + - gitea-data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3000:3000" + - "2222:22" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s + + runner: + image: docker.io/gitea/act_runner:latest + restart: unless-stopped + depends_on: + server: + condition: service_healthy + volumes: + - runner-data:/data + - /var/run/docker.sock:/var/run/docker.sock + environment: + GITEA_INSTANCE_URL: http://server:3000 + GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:-} + GITEA_RUNNER_NAME: gitea-103-runner + GITEA_RUNNER_LABELS: docker:docker://alpine:latest + +volumes: + gitea-data: + gitea-postgres: + runner-data: diff --git a/homelab/gitea/homepage-services-snippet.yaml b/homelab/gitea/homepage-services-snippet.yaml new file mode 100644 index 0000000..b49d04d --- /dev/null +++ b/homelab/gitea/homepage-services-snippet.yaml @@ -0,0 +1,7 @@ +# Вставить в /opt/docker/homepage/config/services.yaml на контейнере 100 +# в нужную группу (например «Разработка» или «Сервисы»). + +- Gitea: + icon: gitea.png + href: http://192.168.1.103:3000 + description: Git-сервер и Gitea Actions (LAN) diff --git a/homelab/immich/proxmox-config.md b/homelab/immich/proxmox-config.md new file mode 100644 index 0000000..18a06fe --- /dev/null +++ b/homelab/immich/proxmox-config.md @@ -0,0 +1,75 @@ +# Конфигурация Immich (LXC) + +## Текущее → Целевое + +| Ресурс | Было | Стало | +|--------|------|-------| +| CPU | 8 cores | 3 cores | +| RAM | 10 GB | 8 GB | +| Swap | — | 4 GB (файл) | + +--- + +## 1. Настройка ресурсов Proxmox + +На хосте Proxmox (192.168.1.150): + +```bash +# Замени на реальный ID контейнера Immich +pct set --cores 3 +pct set --memory 8192 +``` + +--- + +## 2. Файл подкачки внутри контейнера + +Войти в контейнер: + +```bash +pct enter +``` + +Создать swap 4 GB: + +```bash +# Создать файл 4 GB +fallocate -l 4G /swapfile + +# Безопасность +chmod 600 /swapfile + +# Инициализировать swap +mkswap /swapfile + +# Включить +swapon /swapfile + +# Сделать постоянным (добавить в fstab) +echo '/swapfile none swap sw 0 0' >> /etc/fstab +``` + +Проверить: + +```bash +free -h +``` + +Должно быть: `Swap: 4.0Gi`. + +--- + +## 3. Опционально: swappiness + +Чтобы swap использовался не слишком агрессивно: + +```bash +# Текущее (обычно 60) +cat /proc/sys/vm/swappiness + +# Уменьшить до 10 (рекомендуется для серверов) +echo 'vm.swappiness=10' >> /etc/sysctl.conf +sysctl -p +``` + +Для LXC это может не сработать (настраивается на хосте). Если контейнер не видит изменение — не критично. diff --git a/homelab/immich/setup-swap.sh b/homelab/immich/setup-swap.sh new file mode 100644 index 0000000..4319a14 --- /dev/null +++ b/homelab/immich/setup-swap.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Создание swap-файла 4 GB внутри LXC-контейнера Immich +# Запускать внутри контейнера (pct enter ) + +set -e + +SWAP_SIZE="${1:-4G}" + +echo "Создаю swap-файл ${SWAP_SIZE}..." +fallocate -l "$SWAP_SIZE" /swapfile +chmod 600 /swapfile +mkswap /swapfile +swapon /swapfile + +if ! grep -q '/swapfile' /etc/fstab; then + echo '/swapfile none swap sw 0 0' >> /etc/fstab + echo "Добавлено в /etc/fstab" +fi + +echo "Готово. Swap:" +free -h | grep -E "Mem|Swap" diff --git a/homelab/nextcloud/docker-compose-101.yml b/homelab/nextcloud/docker-compose-101.yml new file mode 100644 index 0000000..824c721 --- /dev/null +++ b/homelab/nextcloud/docker-compose-101.yml @@ -0,0 +1,46 @@ +services: + db: + image: docker.io/library/postgres:16 + restart: unless-stopped + volumes: + - /mnt/nextcloud-data/pgdata:/var/lib/postgresql/data + environment: + POSTGRES_DB: nextcloud + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nextcloud"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: docker.io/library/redis:7-alpine + restart: unless-stopped + command: redis-server --appendonly yes + + nextcloud: + image: docker.io/nextcloud:latest + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + ports: + - "8080:80" + volumes: + - /mnt/nextcloud-data/html:/var/www/html + - /mnt/nextcloud-extra:/mnt/nextcloud-extra + - /opt/nextcloud/php-uploads.ini:/usr/local/etc/php/conf.d/zz-uploads.ini:ro + environment: + APACHE_BODY_LIMIT: "0" + NEXTCLOUD_TRUSTED_DOMAINS: cloud.katykhin.ru 192.168.1.101 + OVERWRITEPROTOCOL: https + OVERWRITEHOST: cloud.katykhin.ru + OVERWRITECLIURL: https://cloud.katykhin.ru + REDIS_HOST: redis + POSTGRES_HOST: db + POSTGRES_DB: nextcloud + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud diff --git a/homelab/nextcloud/docker-nextcloud.service b/homelab/nextcloud/docker-nextcloud.service new file mode 100644 index 0000000..31bb6e0 --- /dev/null +++ b/homelab/nextcloud/docker-nextcloud.service @@ -0,0 +1,15 @@ +[Unit] +Description=Docker Compose Nextcloud +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/nextcloud +ExecStart=/usr/bin/docker compose up -d +ExecStop=/usr/bin/docker compose down +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target diff --git a/homelab/nextcloud/php-uploads.ini b/homelab/nextcloud/php-uploads.ini new file mode 100644 index 0000000..e337ea9 --- /dev/null +++ b/homelab/nextcloud/php-uploads.ini @@ -0,0 +1,6 @@ +; Large file uploads for Nextcloud (games, etc) +upload_max_filesize = 64G +post_max_size = 64G +memory_limit = 2G +max_execution_time = 7200 +max_input_time = 7200 diff --git a/homelab/npm-add-proxy.sh b/homelab/npm-add-proxy.sh new file mode 100755 index 0000000..78ca81e --- /dev/null +++ b/homelab/npm-add-proxy.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Add docs.katykhin.ru → 192.168.1.104:8000 via NPM API +# Usage: NPM_EMAIL=admin@example.com NPM_PASSWORD=xxx ./npm-add-proxy.sh + +set -e +NPM_URL="${NPM_URL:-http://192.168.1.100:81}" +API="$NPM_URL/api" + +if [ -z "$NPM_EMAIL" ] || [ -z "$NPM_PASSWORD" ]; then + echo "Set NPM_EMAIL and NPM_PASSWORD" + exit 1 +fi + +echo "Getting token..." +TOKEN=$(curl -s -X POST "$API/tokens" \ + -H "Content-Type: application/json" \ + -d "{\"identity\":\"$NPM_EMAIL\",\"secret\":\"$NPM_PASSWORD\"}" \ + | jq -r '.token // empty') + +if [ -z "$TOKEN" ]; then + echo "Failed to get token" + exit 1 +fi + +echo "Finding certificate for docs.katykhin.ru..." +CERT_ID=$(curl -s -H "Authorization: Bearer $TOKEN" "$API/nginx/certificates" \ + | jq -r '.[] | select(.domain_names[]? == "docs.katykhin.ru") | .id' | head -1) + +PAYLOAD=$(jq -n \ + --arg cert "$CERT_ID" \ + '{ + domain_names: ["docs.katykhin.ru"], + forward_host: "192.168.1.104", + forward_port: "8000", + forward_scheme: "http", + enabled: true, + allow_websocket_upgrade: true, + http2_support: true, + block_exploits: true, + certificate_id: (if $cert != "" and $cert != "null" then ($cert | tonumber) else null end), + ssl_forced: ($cert != "" and $cert != "null") + }') + +echo "Creating proxy host..." +RESP=$(curl -s -w "\n%{http_code}" -X POST "$API/nginx/proxy-hosts" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") +HTTP_CODE=$(echo "$RESP" | tail -1) +BODY=$(echo "$RESP" | sed '$d') + +if [ "$HTTP_CODE" = "201" ]; then + echo "Proxy host created: docs.katykhin.ru -> 192.168.1.104:8000" + echo "$BODY" | jq . +else + echo "Failed (HTTP $HTTP_CODE):" + echo "$BODY" | jq . 2>/dev/null || echo "$BODY" + exit 1 +fi diff --git a/homelab/npm-cert-cloud.sh b/homelab/npm-cert-cloud.sh new file mode 100644 index 0000000..627af37 --- /dev/null +++ b/homelab/npm-cert-cloud.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Выпуск сертификата cloud.katykhin.ru через certbot DNS-01 Beget и подключение к NPM +# Usage: BEGET_USER=логин BEGET_PASS=пароль ./npm-cert-cloud.sh + +set -e +DOMAIN="cloud.katykhin.ru" +EMAIL="j3tears100@gmail.com" +# Запуск: ssh root@PROXMOX "pct exec 100 -- bash -s" < npm-cert-cloud.sh +# Или: BEGET_USER=xxx BEGET_PASS=xxx pct exec 100 -- bash -c 'eval "$(cat)"' < npm-cert-cloud.sh +NPM_URL="${NPM_URL:-http://127.0.0.1:81}" +API="$NPM_URL/api" +NPM_EMAIL="j3tears100@gmail.com" +NPM_PASSWORD="kqEUubVq02DJTS8" + +if [ -z "$BEGET_USER" ] || [ -z "$BEGET_PASS" ]; then + echo "Укажите BEGET_USER и BEGET_PASS (логин и пароль Beget API)" + exit 1 +fi + +echo "1. Создание credentials для certbot..." +CRED_DIR="/root/.secrets/certbot" +mkdir -p "$CRED_DIR" +cat > "$CRED_DIR/beget.ini" << EOF +dns_beget_api_username = $BEGET_USER +dns_beget_api_password = $BEGET_PASS +EOF +chmod 600 "$CRED_DIR/beget.ini" + +echo "2. Запрос сертификата Let's Encrypt (DNS-01)..." +certbot certonly \ + --authenticator dns-beget-api \ + --dns-beget-api-credentials "$CRED_DIR/beget.ini" \ + --dns-beget-api-propagation-seconds 120 \ + -d "$DOMAIN" \ + --non-interactive \ + --agree-tos \ + --email "$EMAIL" + +CERT_DIR="/etc/letsencrypt/live/$DOMAIN" +CERT=$(cat "$CERT_DIR/fullchain.pem") +KEY=$(cat "$CERT_DIR/privkey.pem") + +echo "3. Добавление сертификата в NPM..." +TOKEN=$(curl -s -X POST "$API/tokens" \ + -H "Content-Type: application/json" \ + -d "{\"identity\":\"$NPM_EMAIL\",\"secret\":\"$NPM_PASSWORD\"}" \ + | jq -r '.token // empty') + +if [ -z "$TOKEN" ]; then + echo "Ошибка: не удалось получить токен NPM" + exit 1 +fi + +# Экранируем для JSON +CERT_ESC=$(echo "$CERT" | jq -Rs .) +KEY_ESC=$(echo "$KEY" | jq -Rs .) + +RESP=$(curl -s -w "\n%{http_code}" -X POST "$API/nginx/certificates" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"provider\":\"other\",\"domain_names\":[\"$DOMAIN\"],\"nice_name\":\"$DOMAIN\",\"meta\":{\"certificate\":$CERT_ESC,\"certificate_key\":$KEY_ESC}}") + +HTTP_CODE=$(echo "$RESP" | tail -1) +BODY=$(echo "$RESP" | sed '$d') + +if [ "$HTTP_CODE" != "201" ]; then + echo "Ошибка добавления сертификата (HTTP $HTTP_CODE):" + echo "$BODY" | jq . 2>/dev/null || echo "$BODY" + exit 1 +fi + +CERT_ID=$(echo "$BODY" | jq -r '.id') +echo "Сертификат добавлен, ID: $CERT_ID" + +echo "4. Подключение сертификата к proxy host cloud.katykhin.ru..." +PROXY_ID=$(curl -s -H "Authorization: Bearer $TOKEN" "$API/nginx/proxy-hosts" \ + | jq -r '.[] | select(.domain_names[]? == "cloud.katykhin.ru") | .id') + +if [ -z "$PROXY_ID" ]; then + echo "Proxy host для $DOMAIN не найден" + exit 1 +fi + +PROXY=$(curl -s -H "Authorization: Bearer $TOKEN" "$API/nginx/proxy-hosts/$PROXY_ID") +UPD=$(echo "$PROXY" | jq --argjson cid "$CERT_ID" ' + .certificate_id = $cid | + .ssl_forced = true | + del(.owner, .certificate, .access_list) +') +# domain_names должен быть массив +UPD=$(echo "$UPD" | jq '.domain_names = ["cloud.katykhin.ru"]') + +curl -s -X PUT "$API/nginx/proxy-hosts/$PROXY_ID" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$UPD" | jq . + +echo "Готово. Сертификат подключён к https://$DOMAIN" diff --git a/homelab/npm-log-dashboard/__pycache__/gen-dashboard.cpython-313.pyc b/homelab/npm-log-dashboard/__pycache__/gen-dashboard.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..870219439631e17ac17631fad69ea45f8e3d8023 GIT binary patch literal 22027 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 literal 0 HcmV?d00001 diff --git a/homelab/npm-log-dashboard/gen-dashboard.py b/homelab/npm-log-dashboard/gen-dashboard.py new file mode 100644 index 0000000..cddecba --- /dev/null +++ b/homelab/npm-log-dashboard/gen-dashboard.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python3 +""" +Генерирует HTML-дашборд по access-логам Nginx Proxy Manager. +- Сводка по доменам (2 суток) + по IP (топ-5 по каждому домену + остальные) с геолокацией. +- Лента запросов с пагинацией и фильтром по домену (клиентский JS). +Геолокация: ip-api.com с кэшем (локальная сеть для 10.x, 192.168.x, 127.x). +""" +import base64 +import json +import re +import sys +import time +import urllib.request +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from pathlib import Path + +LOG_DIR = Path("/opt/docker/nginx-proxy/data/logs") +CACHE_FILE = Path("/opt/docker/log-dashboard/ip_cache.json") +FEED_MAX = 2_000 # макс. записей в ленте (встроено в HTML, ~300 KB) +GEO_MAX_NEW_PER_RUN = 40 # ip-api.com limit 45/min +API_URL = "http://ip-api.com/json/{ip}?fields=status,country,city,isp" + +LINE_RE = re.compile( + r'\[(\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2}\s+\+\d{4})\]\s+-\s+(\d+)\s+\d+\s+-\s+(\w+)\s+\w+\s+([^\s]+)\s+"([^"]*)"\s+\[Client\s+([^\]]+)\]' +) + + +def parse_date(s: str) -> datetime | None: + try: + return datetime.strptime(s.strip(), "%d/%b/%Y:%H:%M:%S %z") + except Exception: + return None + + +def parse_line(line: str) -> dict | None: + m = LINE_RE.search(line) + if not m: + return None + date_s, status, method, domain, path, client = m.groups() + dt = parse_date(date_s) + if not dt: + return None + return { + "time": dt, + "date_s": date_s, + "status": status, + "method": method, + "domain": domain, + "path": (path or "/")[:80], + "client": client.strip(), + } + + +def is_private_ip(ip: str) -> bool: + if not ip or ip == "-": + return True + parts = ip.split(".") + if len(parts) != 4: + return True + try: + a, b, c, d = (int(x) for x in parts) + if a == 10: + return True + if a == 172 and 16 <= b <= 31: + return True + if a == 192 and b == 168: + return True + if a == 127: + return True + except ValueError: + return True + return False + + +def load_geo_cache() -> dict: + if CACHE_FILE.exists(): + try: + with open(CACHE_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {} + + +def save_geo_cache(cache: dict) -> None: + try: + CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(CACHE_FILE, "w", encoding="utf-8") as f: + json.dump(cache, f, ensure_ascii=False, indent=0) + except Exception: + pass + + +def fetch_geo(ip: str) -> str: + if is_private_ip(ip): + return "Локальная сеть" + try: + req = urllib.request.Request(API_URL.format(ip=ip), headers={"User-Agent": "NPM-Log-Dashboard/1"}) + with urllib.request.urlopen(req, timeout=3) as r: + data = json.loads(r.read().decode()) + if data.get("status") != "success": + return "—" + parts = [data.get("country") or "", data.get("city") or ""] + loc = ", ".join(p for p in parts if p).strip() or "—" + isp = (data.get("isp") or "").strip() + if isp: + loc = f"{loc} ({isp})" if loc != "—" else isp + return loc or "—" + except Exception: + return "—" + + +def ensure_geo_for_ips(cache: dict, ips: list[str], max_new: int) -> None: + to_fetch = [ip for ip in ips if ip and not is_private_ip(ip) and ip not in cache] + to_fetch = to_fetch[:max_new] + for ip in to_fetch: + cache[ip] = fetch_geo(ip) + time.sleep(1.35) # ~44/min to stay under 45 + + +def main(): + out_path = sys.argv[1] if len(sys.argv) > 1 else None + try: + now = datetime.now(timezone.utc) + except Exception: + now = datetime.utcnow() + two_days_ago = now - timedelta(days=2) + try: + t_cut = two_days_ago.timestamp() + except Exception: + t_cut = (two_days_ago - datetime(1970, 1, 1)).total_seconds() + + counts_by_domain: dict[str, int] = defaultdict(int) + counts_by_ip: dict[str, int] = defaultdict(int) + counts_by_domain_ip: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + all_entries: list[dict] = [] + + log_files = sorted(LOG_DIR.glob("proxy-host-*_access.log")) + for log_path in log_files: + try: + with open(log_path, "r", encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + except Exception: + continue + for line in lines: + entry = parse_line(line) + if not entry: + continue + domain = entry["domain"] + client = entry["client"] + try: + et = entry["time"].timestamp() + except Exception: + try: + et = entry["time"].replace(tzinfo=timezone.utc).timestamp() + except Exception: + et = 0 + if et >= t_cut: + counts_by_domain[domain] += 1 + counts_by_ip[client] += 1 + counts_by_domain_ip[domain][client] += 1 + all_entries.append(entry) + + # Лента = все запросы за последние 2 суток (с лимитом FEED_MAX для размера HTML) + def entry_ts(e: dict) -> float: + try: + return e["time"].timestamp() + except Exception: + try: + return e["time"].replace(tzinfo=timezone.utc).timestamp() + except Exception: + return 0.0 + feed_2d = [e for e in all_entries if entry_ts(e) >= t_cut] + feed_2d.sort(key=lambda x: x["time"], reverse=True) + total_2d = len(feed_2d) + feed = feed_2d[:FEED_MAX] + feed_capped = total_2d > FEED_MAX + feed_serial = [ + {"t": e["date_s"][:20], "d": e["domain"], "m": e["method"], "p": e["path"], "s": e["status"], "c": e["client"]} + for e in feed + ] + + # Порядок доменов как в сводке (по убыванию запросов) + domain_order = sorted(counts_by_domain.keys(), key=lambda d: -counts_by_domain[d]) + # Геокэш: топ-5 IP по каждому домену + часть уникальных из ленты + geo_cache = load_geo_cache() + ips_for_geo = [] + for d in domain_order: + top5_for_d = [ip for ip, _ in sorted(counts_by_domain_ip[d].items(), key=lambda x: -x[1])[:5]] + ips_for_geo.extend(top5_for_d) + ips_for_geo = list(dict.fromkeys(ips_for_geo)) + feed_ips = list(dict.fromkeys(e["client"] for e in feed[:500])) + ensure_geo_for_ips(geo_cache, ips_for_geo + feed_ips, GEO_MAX_NEW_PER_RUN) + save_geo_cache(geo_cache) + + def geo_label(ip: str) -> str: + if is_private_ip(ip): + return "Локальная сеть" + return geo_cache.get(ip, "—") + + # Сводка по доменам (таблица) + summary_domain_rows = [] + for domain in domain_order: + summary_domain_rows.append(f"{domain}{counts_by_domain[domain]}") + summary_domain_table = "\n".join(summary_domain_rows) + + # Сводка по IP: топ-5 по каждому домену + остальные + summary_ip_parts = [] + for domain in domain_order: + sorted_ips_d = sorted(counts_by_domain_ip[domain].items(), key=lambda x: -x[1]) + top5_d = sorted_ips_d[:5] + rest_d = sum(c for _, c in sorted_ips_d[5:]) + rows = [] + for ip, cnt in top5_d: + geo = geo_label(ip) + link_2ip = f'{ip}' + rows.append(f"{link_2ip}{cnt}{geo}") + if rest_d: + rows.append(f'Остальные IP{rest_d}—') + summary_ip_parts.append( + f'{domain}\n' + "\n".join(rows) + ) + summary_ip_table = "\n".join(summary_ip_parts) + + # Гео для всех IP в кэше (для вывода в ленте в JS) + geo_map = {ip: geo_label(ip) for ip in set(e["client"] for e in feed)} + geo_map_json = json.dumps(geo_map, ensure_ascii=False) + + domains_list = domain_order + feed_note = f" (показаны последние {FEED_MAX:,} из {total_2d:,})" if feed_capped else "" + feed_count = len(feed_serial) + + _gen_time = datetime.now().strftime("%d.%m.%Y %H:%M") + # Favicon: мини-терминал с логами (тёмный фон, cyan строки) + favicon_svg = """ + + + + + + +""" + favicon_data_url = "data:image/svg+xml;base64," + base64.b64encode(favicon_svg.encode("utf-8")).decode("ascii") + html_start = f""" + + + + + + Обращения к внешним URL (NPM) + + + + +

Обращения к внешним URL (Nginx Proxy Manager)

+

Сводка за 2 суток; лента — последние {feed_count:,} запросов. Расчёт по крону раз в 15 мин. Сгенерировано: {_gen_time}

+ +

Запросы по доменам (2 суток)

+ + + {summary_domain_table} +
ДоменЗапросов
+ +

Запросы по IP (топ-5 по каждому домену + остальные)

+ + + {summary_ip_table} +
IPЗапросовМестоположение
+ +

Лента запросов (последние {feed_count:,})

+
+ + +
+ + + +
ВремяДоменМетодПутьСтатусClientМестоположение
+ + + + + + +""" + # JSON для ленты: экранируем чтобы не закрыть тег + payload = {"feed": feed_serial, "geo": geo_map, "domains": domains_list} + feed_json_raw = json.dumps(payload, ensure_ascii=False) + feed_json_safe = feed_json_raw.replace("<", "\\u003c").replace(">", "\\u003e") + + if out_path: + out_dir = Path(out_path).parent + out_dir.mkdir(parents=True, exist_ok=True) + html_full = html_start.replace('', + '') + with open(out_path, "w", encoding="utf-8") as f: + f.write(html_full) + else: + html_full = html_start.replace('', + '') + print(html_full) + + +if __name__ == "__main__": + main() diff --git a/homelab/npm-log-dashboard/verify-dashboard.py b/homelab/npm-log-dashboard/verify-dashboard.py new file mode 100644 index 0000000..dfcac9b --- /dev/null +++ b/homelab/npm-log-dashboard/verify-dashboard.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Проверка сгенерированного index.html: JSON в feed-data и логика ленты.""" +import json +import re +import sys + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else "/opt/docker/log-dashboard/html/index.html" + with open(path, "r", encoding="utf-8") as f: + html = f.read() + + m = re.search(r'', html, re.DOTALL) + if not m: + print("FAIL: feed-data block not found") + return 1 + + raw = m.group(1).strip() + try: + data = json.loads(raw) + except Exception as e: + print("FAIL: JSON parse error:", e) + return 1 + + for key in ("feed", "geo", "domains"): + if key not in data: + print("FAIL: missing key", key) + return 1 + + feed, geo, domains = data["feed"], data["geo"], data["domains"] + if not isinstance(feed, list) or not isinstance(geo, dict) or not isinstance(domains, list): + print("FAIL: wrong types") + return 1 + + if len(feed) == 0: + print("WARN: feed is empty") + else: + e = feed[0] + for k in ("t", "d", "m", "p", "s", "c"): + if k not in e: + print("FAIL: feed entry missing", k) + return 1 + + # Симуляция getFiltered() + render() для страницы 1 + domain = domains[0] if domains else "" + filtered = [x for x in feed if x["d"] == domain] if domain else feed + page_size = 100 + current_page = 1 + total_pages = max(1, (len(filtered) + page_size - 1) // page_size) + start = (current_page - 1) * page_size + page = filtered[start : start + page_size] + + rows = [] + for e in page: + geo_val = geo.get(e.get("c"), "—") + rows.append((e.get("t"), e.get("d"), e.get("m"), e.get("p"), e.get("s"), e.get("c"), geo_val)) + + if not page and filtered: + print("FAIL: page is empty but filtered has items") + return 1 + if len(rows) != len(page): + print("FAIL: row count mismatch") + return 1 + + print("OK: feed entries:", len(feed)) + print("OK: geo entries:", len(geo)) + print("OK: domains:", len(domains)) + print("OK: page 1 rows:", len(rows), "| sample:", rows[0][:3] if rows else "n/a") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/homelab/paperless-ngx/docker-compose-104.yml b/homelab/paperless-ngx/docker-compose-104.yml new file mode 100644 index 0000000..8f8f114 --- /dev/null +++ b/homelab/paperless-ngx/docker-compose-104.yml @@ -0,0 +1,37 @@ +services: + broker: + image: docker.io/library/redis:8 + restart: unless-stopped + volumes: + - redisdata:/data + + db: + image: docker.io/library/postgres:18 + restart: unless-stopped + volumes: + - /mnt/paperless-data/pgdata:/var/lib/postgresql + environment: + POSTGRES_DB: paperless + POSTGRES_USER: paperless + POSTGRES_PASSWORD: paperless + + webserver: + image: ghcr.io/paperless-ngx/paperless-ngx:latest + restart: unless-stopped + depends_on: + - db + - broker + ports: + - "8000:8000" + volumes: + - /mnt/paperless-data/data:/usr/src/paperless/data + - /mnt/paperless-data/media:/usr/src/paperless/media + - ./export:/usr/src/paperless/export + - ./consume:/usr/src/paperless/consume + env_file: docker-compose.env + environment: + PAPERLESS_REDIS: redis://broker:6379 + PAPERLESS_DBHOST: db + +volumes: + redisdata: diff --git a/homelab/paperless-ngx/docker-compose.env b/homelab/paperless-ngx/docker-compose.env new file mode 100644 index 0000000..13d777c --- /dev/null +++ b/homelab/paperless-ngx/docker-compose.env @@ -0,0 +1,8 @@ +# Paperless-ngx settings +# See http://docs.paperless-ngx.com/configuration/ + +PAPERLESS_URL=https://docs.katykhin.ru +PAPERLESS_SECRET_KEY=a8e2c118da39d15fc7f2691f969ddc0e807907bcfdd742b8af943a0cc2a61773 +PAPERLESS_TIME_ZONE=Europe/Moscow +PAPERLESS_OCR_LANGUAGE=rus+eng +PAPERLESS_OCR_LANGUAGES=rus diff --git a/homelab/paperless-ngx/docker-compose.yml b/homelab/paperless-ngx/docker-compose.yml new file mode 100644 index 0000000..8566d2c --- /dev/null +++ b/homelab/paperless-ngx/docker-compose.yml @@ -0,0 +1,40 @@ +services: + broker: + image: docker.io/library/redis:8 + restart: unless-stopped + volumes: + - redisdata:/data + + db: + image: docker.io/library/postgres:18 + restart: unless-stopped + volumes: + - pgdata:/var/lib/postgresql + environment: + POSTGRES_DB: paperless + POSTGRES_USER: paperless + POSTGRES_PASSWORD: paperless + + webserver: + image: ghcr.io/paperless-ngx/paperless-ngx:latest + restart: unless-stopped + depends_on: + - db + - broker + ports: + - "8000:8000" + volumes: + - data:/usr/src/paperless/data + - media:/usr/src/paperless/media + - ./export:/usr/src/paperless/export + - ./consume:/usr/src/paperless/consume + env_file: docker-compose.env + environment: + PAPERLESS_REDIS: redis://broker:6379 + PAPERLESS_DBHOST: db + +volumes: + data: + media: + pgdata: + redisdata: diff --git a/homelab/paperless-ngx/docker-paperless.service b/homelab/paperless-ngx/docker-paperless.service new file mode 100644 index 0000000..b341163 --- /dev/null +++ b/homelab/paperless-ngx/docker-paperless.service @@ -0,0 +1,15 @@ +[Unit] +Description=Docker Compose Paperless-ngx +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/paperless +ExecStart=/usr/bin/docker compose up -d +ExecStop=/usr/bin/docker compose down +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target diff --git a/homelab/paperless-ollama-README.md b/homelab/paperless-ollama-README.md new file mode 100644 index 0000000..01de959 --- /dev/null +++ b/homelab/paperless-ollama-README.md @@ -0,0 +1,44 @@ +# Paperless + Ollama: вопросы по документам + +Скрипт ищет по документам в Paperless-ngx (full-text по OCR) и отвечает на вопрос, подставляя найденный текст в модель Ollama (Saiga). + +## Требования + +- Python 3.9+ +- Доступ к API Paperless-ngx (контейнер 104 → укажи его URL) +- Доступ к Ollama (например на VM 192.168.1.200:11434) + +## Переменные окружения + +| Переменная | Обязательная | По умолчанию | Пример | +|------------|--------------|--------------|--------| +| `PAPERLESS_URL` | да | — | `http://192.168.1.104:8000` | +| `PAPERLESS_TOKEN` | да | — | токен из Paperless (см. ниже) | +| `OLLAMA_URL` | нет | `http://localhost:11434` | `http://192.168.1.200:11434` если скрипт на другой машине | +| `OLLAMA_MODEL` | нет | `saiga` | имя модели в Ollama | +| `PAPERLESS_MAX_DOCS` | нет | `5` | сколько документов подставлять в промпт | + +## Токен Paperless + +В веб-интерфейсе Paperless: **My Profile** (меню пользователя) → кнопка обновления токена (circular arrow) → скопировать токен. Либо создать токен в Django admin. + +## Запуск + +```bash +cd homelab +export PAPERLESS_URL="http://192.168.1.104:8000" # или IP/порт где крутится контейнер 104 +export PAPERLESS_TOKEN="твой_токен" + +# Если Ollama на другой машине: +export OLLAMA_URL="http://192.168.1.200:11434" + +python3 paperless-ollama-ask.py "номер паспорта?" +python3 paperless-ollama-ask.py "когда истекает договор?" +``` + +Скрипт: ищет в Paperless по фразе (по OCR), берёт до 5 документов, подставляет их текст в промпт и отправляет в Ollama. Ответ выводится в stdout. + +## Где запускать + +- **На VM с Ollama (192.168.1.200):** `OLLAMA_URL=http://localhost:11434`, `PAPERLESS_URL` — адрес контейнера 104 (например `http://192.168.1.104:8000` или `http://host-104:8000` если есть DNS). +- **С своей машины:** оба URL с IP (Ollama и Paperless), порт 11434 и 8000 соответственно. diff --git a/homelab/paperless-ollama-ask.py b/homelab/paperless-ollama-ask.py new file mode 100644 index 0000000..6fe2ca9 --- /dev/null +++ b/homelab/paperless-ollama-ask.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Спрашивает по документам Paperless-ngx через поиск по OCR и отвечает через Ollama. +Использование: PAPERLESS_URL=... PAPERLESS_TOKEN=... python paperless-ollama-ask.py "номер паспорта?" +""" +import json +import os +import sys +import urllib.parse +import urllib.request + + +def env(name: str, default: str = "") -> str: + v = os.environ.get(name, default).strip() + if not v and name.startswith("PAPERLESS_"): + raise SystemExit(f"Set {name} (e.g. PAPERLESS_URL=http://192.168.1.104:8000 PAPERLESS_TOKEN=...)") + return v + + +# Слова, которые редко есть в OCR тексте документов — выкидываем при запасном поиске +_STOP_WORDS = frozenset( + "кем какой какая какие как где когда что кто чей чья чьи откуда куда зачем почему сколько".split() +) + + +def search_query_for_paperless(question: str) -> str: + """Убираем пунктуацию, которая может ломать full-text поиск Paperless (?, ! и т.д.).""" + s = question.strip().rstrip("?!.;") + return s if s else question + + +def fallback_search_query(question: str, max_words: int = 4) -> str: + """Короткий запрос для повторного поиска: ключевые слова без вопросительных слов.""" + q = search_query_for_paperless(question) + words = [w for w in q.split() if len(w) >= 2 and w.lower() not in _STOP_WORDS] + return " ".join(words[:max_words]) if words else q + + +def paperless_search(base_url: str, token: str, query: str, max_docs: int = 5) -> list[dict]: + """Full-text поиск по документам, возвращает список с полем content (OCR).""" + base_url = base_url.rstrip("/") + search_q = search_query_for_paperless(query) + query_encoded = urllib.parse.quote(search_q, encoding="utf-8", safe="") + url = f"{base_url}/api/documents/?query={query_encoded}&page_size={max_docs}" + req = urllib.request.Request(url) + req.add_header("Authorization", f"Token {token}") + req.add_header("Accept", "application/json; version=6") + if os.environ.get("PAPERLESS_DEBUG"): + print(f"[DEBUG] GET {url}", file=sys.stderr) + with urllib.request.urlopen(req, timeout=30) as r: + data = r.read().decode("utf-8") + out = json.loads(data) + results = out.get("results") or [] + if os.environ.get("PAPERLESS_DEBUG"): + print(f"[DEBUG] count={out.get('count', 0)} results={len(results)}", file=sys.stderr) + return results + + +def ollama_generate(ollama_url: str, model: str, prompt: str, timeout: int = 120) -> str: + """Один запрос к Ollama /api/generate, возвращает response.""" + ollama_url = ollama_url.rstrip("/") + url = f"{ollama_url}/api/generate" + body = {"model": model, "prompt": prompt, "stream": False} + data = json.dumps(body).encode() + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req, timeout=timeout) as r: + out = json.loads(r.read().decode("utf-8")) + return (out.get("response") or "").strip() + + +def main(): + question = " ".join(sys.argv[1:]).strip() + if not question: + print("Usage: PAPERLESS_URL=... PAPERLESS_TOKEN=... python paperless-ollama-ask.py 'ваш вопрос'", file=sys.stderr) + sys.exit(1) + + base_url = env("PAPERLESS_URL", "http://localhost:8000") + token = env("PAPERLESS_TOKEN") + ollama_url = env("OLLAMA_URL", "http://localhost:11434") + model = env("OLLAMA_MODEL", "saiga") + max_docs = int(env("PAPERLESS_MAX_DOCS", "5")) + + # Поиск по OCR: сначала полный вопрос, при пустом ответе — по ключевым словам + try: + docs = paperless_search(base_url, token, question, max_docs=max_docs) + if not docs: + fallback = fallback_search_query(question) + if fallback and fallback != search_query_for_paperless(question): + docs = paperless_search(base_url, token, fallback, max_docs=max_docs) + if os.environ.get("PAPERLESS_DEBUG") and docs: + print(f"[DEBUG] fallback query '{fallback}' found {len(docs)} docs", file=sys.stderr) + except urllib.error.HTTPError as e: + print(f"Paperless API error: {e.code} {e.reason}", file=sys.stderr) + if e.code == 401: + print("Check PAPERLESS_TOKEN (My Profile → API token in Paperless UI).", file=sys.stderr) + sys.exit(2) + except Exception as e: + print(f"Paperless request failed: {e}", file=sys.stderr) + sys.exit(2) + + if not docs: + print("По документам ничего не найдено.") + return + + # Собираем текст из документов (OCR content) + parts = [] + for i, d in enumerate(docs, 1): + title = d.get("title") or f"Документ {d.get('id')}" + content = (d.get("content") or "").strip() + if not content: + content = "(текст пустой)" + parts.append(f"[Документ {i}: {title}]\n{content[:8000]}") # ограничиваем длину + + docs_text = "\n\n---\n\n".join(parts) + + prompt = f"""Ниже текст из документов (OCR). Ответь на вопрос пользователя, опираясь только на этот текст. +Если в тексте нет ответа — напиши "В документах не найдено". +Отвечай кратко, по делу. + +Документы: + +{docs_text} + +Вопрос: {question} + +Ответ:""" + + try: + answer = ollama_generate(ollama_url, model, prompt) + except urllib.error.URLError as e: + print(f"Ollama недоступна ({ollama_url}): {e}", file=sys.stderr) + sys.exit(3) + except Exception as e: + print(f"Ollama error: {e}", file=sys.stderr) + sys.exit(3) + + print(answer) + + +if __name__ == "__main__": + main() diff --git a/homelab/scripts/README-reorganize-games-common.md b/homelab/scripts/README-reorganize-games-common.md new file mode 100644 index 0000000..3ae1f35 --- /dev/null +++ b/homelab/scripts/README-reorganize-games-common.md @@ -0,0 +1,47 @@ +# Реорганизация common/ (HDD 4 TB) + +Скрипт приводит папки игр в каталоге **common/** к виду: `GameName/GameName/` (файлы внутри) и кладёт `appmanifest_.acf` в корень папки игры. AppId берётся из **steam_library_with_sizes.json** по имени папки (сопоставление по нормализованному имени, не 100% точное). + +## Что нужно + +- В CT 101 должны быть: `reorganize-games-common.sh`, `resolve_steam_appid.py`, `steam_library_with_sizes.json` (например в `/opt/scripts/`). +- Корневая директория: `/mnt/nextcloud-extra/common/` (или путь 1-м аргументом). + +## Подготовка (один раз) + +С хоста Proxmox (из репозитория plantUML): + +```bash +# Создать каталог в контейнере и залить файлы +pct exec 101 -- mkdir -p /opt/scripts +pct push 101 homelab/scripts/reorganize-games-common.sh /opt/scripts/ +pct push 101 homelab/scripts/resolve_steam_appid.py /opt/scripts/ +pct push 101 homelab/scripts/steam_library_with_sizes.json /opt/scripts/ +``` + +## Dry-run (ничего не меняет) + +Показывает, что будет сделано, и в конце выводит **папки без совпадений** (по ним appId не найден в JSON — их можно проставить вручную и перезапустить): + +```bash +ssh root@192.168.1.150 'pct exec 101 -- bash /opt/scripts/reorganize-games-common.sh /mnt/nextcloud-extra/common 1 /opt/scripts/resolve_steam_appid.py /opt/scripts/steam_library_with_sizes.json' +``` + +## Реальное выполнение + +Второй аргумент `0`: + +```bash +ssh root@192.168.1.150 'pct exec 101 -- bash /opt/scripts/reorganize-games-common.sh /mnt/nextcloud-extra/common 0 /opt/scripts/resolve_steam_appid.py /opt/scripts/steam_library_with_sizes.json' +``` + +## Папки без совпадений + +Если в конце dry-run в блоке **«Папки, для которых не найден appId»** что-то есть — имена папок не совпали ни с одной записью в `steam_library_with_sizes.json`. Варианты: + +1. Переименовать папку в common/ так, чтобы оно было ближе к названию в JSON, и снова запустить dry-run. +2. Добавить вручную маппинг: можно расширить `resolve_steam_appid.py` (словарь папка → appid) или положить рядом с папкой файл с appid и доработать скрипт — при необходимости можно добавить такой режим. + +## Манифесты + +Скрипт ищет `appmanifest_.acf` в корне common/ или на уровень выше (steamapps/). Если манифеста нет, игра попадёт в блок **«Нет файла appmanifest»**; манифест нужно будет добавить вручную (скачать или создать). diff --git a/homelab/scripts/README-reorganize-games.md b/homelab/scripts/README-reorganize-games.md new file mode 100644 index 0000000..10153c2 --- /dev/null +++ b/homelab/scripts/README-reorganize-games.md @@ -0,0 +1,35 @@ +# Скрипт reorganize-games.sh + +Упорядочивает папки игр в двух корневых директориях в CT 101 (Nextcloud): + +- **Игры:** `/mnt/nextcloud-data/html/data/kerrad/files/Игры/` +- **nextcloud-extra:** `/mnt/nextcloud-extra/` + +Целевая структура: `GameName/GameName/` (внутри — файлы игры), в корне папки игры — `appmanifest_.acf` по таблице из `readme.md` / `Readme.md`. + +## Запуск dry-run (ничего не меняет) + +С хоста (из репозитория plantUML): + +```bash +# Игры (nextcloud-data) +ssh root@192.168.1.150 'pct exec 101 -- bash -s "/mnt/nextcloud-data/html/data/kerrad/files/Игры" 1' < homelab/scripts/reorganize-games.sh + +# nextcloud-extra (4 TB) +ssh root@192.168.1.150 'pct exec 101 -- bash -s /mnt/nextcloud-extra 1' < homelab/scripts/reorganize-games.sh +``` + +В конце вывода будут блоки: +- **Папки без соответствия в readme** — директории на диске, для которых нет строки в таблице readme. +- **Не найдены appmanifest** — игры из readme, для которых в корне нет нужного `appmanifest_XXXX.acf` (нужно добавить файл или поправить readme). + +## Реальное выполнение + +Второй аргумент `0`: + +```bash +ssh root@192.168.1.150 'pct exec 101 -- bash -s "/mnt/nextcloud-data/html/data/kerrad/files/Игры" 0' < homelab/scripts/reorganize-games.sh +ssh root@192.168.1.150 'pct exec 101 -- bash -s /mnt/nextcloud-extra 0' < homelab/scripts/reorganize-games.sh +``` + +Рекомендуется перед этим сделать резервную копию или убедиться, что dry-run выводит ожидаемые действия. diff --git a/homelab/scripts/__pycache__/steam_library_size.cpython-313.pyc b/homelab/scripts/__pycache__/steam_library_size.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff9b88cc018ce2fb55b54262d0c834c6edfe758e GIT binary patch literal 10528 zcmcgSX>c3Yd2g``U~!WKcKSTN%M|riS8lN{u^HI*DpJKJ~+Oe(nmi39lKCr*%7{fAmc_s^7Ay{Rnz{@Vd*5elJJ+>Z?8767w z1jj1!BqtXM&Yt2W^)6ap@=$*_t@l1u@1gZRp{U0Sa7ug1=HVC9`tteuGMKT1)>P0M zZ%>(E7fj6-!AEN=h1EhStgsBea=OyuW?rbkwkH{Rm9R#rr2SU+tPxhVb`cWd3F%qA zk+jSonVTBv0d~ZVhj&@bOqyAQ%@(YPbXvnX=3*FVf{M!Whl07D26BNXjwt3MQvHqySmL*Yg#lJIYa);j?0P3^}N-kl|D(XQcK zcW}}`qhGxV9pA;hw8Az3=+ks@Zy5uxQdqCSo_Hi))nEB>udqwp;1sO-&iqa` zYtPbSxr!TlJwHJGCzuSjg_C6<`#PKztPG}^)UM)UeyjaN8-bZq+7Gn9fvTsqF+WU^ z-+m|_4=T~&h%$cw_whDP1Q?zBb|^6bDStAah{mMQ;Bd*q2R7_m3Ci%=B|U?eqKSb; zll1@WMGPPB)6Fn@G^|_XNFpgqy>Rb^V1XP9-XUivcXUkO@djl>@=B(#C8MP=0hhX14b#IZX_bx=~`Nc zB?HMjJ&$f{ncu&}30C}TnctnCoaH*b504XgV}xL9y+lYD&P=e2+%A9JZI~pqd2-LX|9nQ?iPd&T<3NLTc>SOAb#Hl7yYa zM@3$6?Sd{LC>@70>aH~Ui?(hcr1@a%+L%9#y{q94Y%A?Ta2sph!z>=*CtHbR7ws#K z@d!o6ERsVg79E{yjoBAcV+VBKxQ4z*uA#(OgX9#Q#wzH!c|~Vo{EuL~Q}90OJ_KI@ zic54EQ0VSoAq22~y|FgYRp{w{#12ar?a(873}{GlMUPO1*g|hXC8X=G8sIJ@>+z2W zaS%Tdt%8%1=R5{kh4KamA=P99sV4@UFtqiV{A4}!_md9h#Hl^(deX;iAl*gADy<|0 zFBkjtTl+|%-EXvaZ!~hvCg=;tf_*!f%GwwZ;A;R7!216sb;G}8bVTYOj4A`Lwr6SmYe=7wycake zkZHOiq}@M;y#mj;;|H$x4uAxXqfRy2hc^r)5^<%qscHG{o1S{CDJu2FnsiHBZ*L@? z___!v>|nGvge1HPS=?<#w+=)?;fSo<_rVQdIT0O-#FB{gL=D}Df411Ab+qjqBSU95F+Are)!LjIU zeU8cgQ% zfg%}Sw$8%bItxp}`INX^jFmicSv*R)1dEPDED;(EUbvFL^P-0v1U6`H933UEgC+&vntxf)4>ySlTy9fH3d@lvFNP9V2BK2a*_eE>DGq0u5RV^g z8i)%hA~JDSwYQ`W%$dw)->lm=CXGrn?rP0loe?H?sC!PT+rF*3t5x@@5q8$)9XmgI ze#TX$xvDY;CQDSzHBWJ~-twER&$p(-FGpXDzI1VB-BxYg*6g}%S?~6dgI}@4-7?1$ z-udk1r!SB5S!-qbiLAADqIQbQ*6q%DyzoDMX!LtCp7ok%{V&-}`z!XWXG`kvrw;G< z<;>-5c@uVWm5vW+uJx%yxe{;c@!aYSFYkVF_uuXv^^EX2hgU7{$~w9}cKT*LmFb#a z`er<}nx_`Zx#F^$mDelNJF>;qGsSfirV&f7xcug->#Nd-GZ(VO0Vq3s=|e9cd-2#y zC$f$@9OJ9JdF1+$^u?^NZp8Mfry_lL;z)K)3pRPzq`Ne4?Toih^VUs#E9>1h!hh;1 zho(6bYb%|tg!Y%#jvSq{5Z|hst=C)APfl#idK;jZ!<*(aN3sJI6x$hX>81c-7Ba zRZlI{+e$xy=~+m>R@&f(pWWNxBX`Z)J521|9X#a!+2rV`Hho~ScNAMcaI#SIK{1Ve z_Kpqg2Yv?Q>Y|RF<_~r-5bInbayhX?xWktM%z=D)4zTB^L|npH`a#s|g!<(HMYsan zCf!JkXz1jO+(NpM2%NaM4+t^KLkq3|g2adg(IEuPr`yosh{$vT>suZKv%!9iw0iMO zE3d}#C#?p=lK=Z^1Z$pQ_cH-Lc>z|hrh#|<3U~+VM3bn~EYVk%GE`)Xlm(&sv|M4) zo&k17+3F&NMSY|3gwE9KOyjU`b5D57=6$U_jgSWR1$vaaj{W!XE>~(gzCTLfv;r0+ z;Xs>!+M47DFri3D?j6wWM>})^KCbaA3KA_=1hWq7MG9EC=djO=)3@L~Ne!q-T zrS6M|WCbWCl1oag5bfk6(9{l}G7M3Q{JX(dKq;@vFT5n|IDMYo1LizbWVNdgi*o2|r2&V1qpRD4r0tYIN`A=4HEQ0m_QN`f}_%q(~qg0%4tfZ^~!PdN@ zux~S7sUf^l!Q8$`q2WdIqtztAVT_k7qQ!uL0&qXyT~K^POE+?C8jL*|eM7)2K}lLk zfpZ$LAJJYA^qWnfNP)s8S-Vjvph2`6qa|MCdjX4%!yF>7nEW8FqbR>@g(&XP@X!i@ z$}NIxG2m7Mghz}0C-j0)zmm|;7le3sG6~bFV9;4vhX00C1`HN4Ig+=;#8DiJgd2Nf zLtu@0luabiINV~0$}0~>2^zk5IFblO2bF!0jfRyy!}i+2J@ewG%){bk+350-h({8$>ZQDo2JQ5(u%@p{NucN)G7`8RX3&s0>HK0k`bOAsaB-h*1?r z)fm-aREyCjj1YO`07i8f)nn9v5z@1OjmnzxHmul=Q8PwV)HGihjP+hb;q$v7@&ZXr z$f(>ZxPgMGm?xO!E6I0o#IgW=@l$pJny1KT&bIez-Yfr|U9}7UAHtohcj6s1i^j`F_slrfXpS}N+QzJ-DRuBu zO5|s)#pBgK=Vq*HHS5}3Y30qX>s{$+wsiAE^*?UDy?Js^wyrJf+dpz}*1KtfL#gT@ z0L*&U=gL<<@A$$>N)Iq|PU0+jU?z6o_`#o7&Dhpywso_fs?5QxC!q2HAnadNfmHS3 z3R2bAN)sI6$lmsK-*&_=2z2roxObv`+gk*@e&S!+<|h4 z9gz7c>4wY5hXsQT$}Sh+<%Pr(b!b$I9dXP$HfCC~j)2Mq^fI(B zJuR@Rn+z0V zdG1QO_}ceCC$iVh*lOVaYXynbwX!YjtM;ANCC7se6q%fWMksQxBw`yjFQh&g(E79$ zHoh3O79A#+?*;b76~t?>gcc676RFR#V9ezuXG~{Uh22UNWO6{nk}zw$s=FY1izeez zL^fzbCWp*TVI@{Ft#nPu0Q0SV-{8vRE?5}Ipq9vGut_L#4;1qx`8$}~f)T#t07q|3 zJ`Z`Fxu`QwDY$<>ggiMdqIZYlrxNT9fdxbkL;Dr@ln_KI0@Pw`*XXXXJ)?Vap5hVP zlKZT!`}^oGqLa)kgEO@|wAvlhwXM^hUH58RUks;1H`wvE7c6P!d7D<-de5_qUb$wg z`R`BW^B>~fBc1=n=C&60uDznIiNCv%#e9Uo1hjTjyjc0!2<%Q4 ztlp+S_HepN1P(+`7Ti|Lo=yKZxPyQNG>A*`6R;Nf97OkVh2VRzfOQDiX@Fh06)?yo zhvHC!&qt16gg2t#9u}@b2|bn91dOY=2+b+%hwOCzDUBbkUgj&8mb4FeUzsf~b$PLK0>$iOoLuU~8H zygA;~#h_*eE`$Ol@2YA55^10}82(w2!y19>+TRMcA~ezp=92&gYf%6YEM3(NcC5wN zsFlouRW!Saz@wW)u!#)B7LgU~6q>_B1m|`ea06Ge4S=Y(3Q8V0&XB@`OAX>XK;oZn z052N)*(O{4-Fxz3FPyCZJO1XDrfoZR`X6hlp{76??e{vfM`!lR2vukNI@3LD`VJ&b z_YwJr*~YDXI#Uy1@=wSjBcYdvFp_Wt8wcZ>r7t=-7zyj_1yJCSlHhE{t8&KWSU*_O zlpwUqsKh-3pYmOZKr*p9$C)1t!)wafi$|MM2WDMGP{y3TlnG6+ z69;ZvC)v#RC)-rVw^Z(1l=bw&%!N?6?*D{KU(9qC-ivX$E14-Ipu^n-+DVZEj2{ag zCR$^H8o26w$RiLKCQt%h77U77px!_!y9@p%fmxUdJ!!_eKusXsC_2)xK(i&T8^+Jq z7J37snZWPEO4cSK@r%?m&VVP`7O6#rTA=K|n5XQoiVlGTY$o6>adZ}ZK*n5%+JePk zyOOKxed;rU0bM200k+M=yp(Jh4<)j(BAlAHJMC3cGjQ$I$=oDQr#tF047`38%L7{~w3(v5TU6^6fP1w6}nKX!Y zmDE2TVE(Iw%^_lFERL&==V4 zA!v0*Bqb?Ff+3|h8kM8ap7cWmwnJbH)I|93?I`0(U=TP>Bp3u2Q;70$=dmOEJKH*+2p&3etX(e>j+|>3f(P0T z9B$9oI8KVEzb&4o1v!K;%;3|K5>zs$;+@V8f}hD2k&>_sM(5EOZkV{K397BDo>>i~Nu_-T->$ta>M$T{+pPz)659D3Kl3l_Z)M3qOk zQxPto=fP>0&spa&iwZnj8f4#JmTpAMi=hnW)ZT zZUK?x@8epiT(y#qDKGDGL!qc7zXUzU;8PwjRIqY*Zt&Ok&N%#Zwb=)S!6KFhn*qTuvj464B))h$i)u^jLI+`S6C zE$-@6$ElaMvu)pi^h&V zcl5^hr=2x9M~UWGmvj0w=lapZpE)bjr!?ofk>feuGv*$3=So-Qd=xa z)qUu9`_(h&ruptKYKX7)K`k-cf7tOk=Nmsa&22&rVIa+B`tR8qCpQ>k>ZXa7sp5M) z)dHFxSm=mZ&Zau+|3R26f3;`gglgY4&F!Xj&A$<*%75MS-f^wvl-k@0CY)*RG;OKM z9M9}i?c1lhW?F8T7?@Nh&QGTHP89hGV_KU|zvG z0JZWph;(<}m)nRoG;rn0eyp@2!XskS*K0{!iGwaJb2#W3jDCdCvl#sZqb7_FVMHwu zb(lMXk$_SDNut|1Z%0Gjn!e!tyQ41;F4mts< z69s>n0Ddxt`GVsZ?n@_OoSzWeC&cjyDg8aE_&r(unWrk{%y|PUDayGUQjX7xyeZe5 zrJkwD^v)4T;KY-+V-GMpcb;)DMQLS@K$5vUX}4=_8orVPs4=LjSdJ0{z2?|XpR zxou{qF3rvnNHY9H=`GI#%+3{=m^I^xIRZ(B%bdOixCq%fJICx!H_s7BG6w-YncZ`4 z6H|*-kW6qine<`j+Ar)@=Fieqa|9AxCnS^k(vfEdT%j literal 0 HcmV?d00001 diff --git a/homelab/scripts/check_size_sources.py b/homelab/scripts/check_size_sources.py new file mode 100644 index 0000000..89aefb6 --- /dev/null +++ b/homelab/scripts/check_size_sources.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Сверяет кэш размеров (steam_app_sizes.json) с api.steamcmd.net. +Находит игры, у которых размер в кэше сильно меньше, чем по steamcmd — +скорее всего размер был взят из fallback (магазин Steam), а не из steamcmd. +Запуск: из homelab/scripts/ + python3 check_size_sources.py — проверить все игры (~10–15 мин) + python3 check_size_sources.py --sample 50 — только 50 для быстрой оценки +""" + +import argparse +import json +import random +import time +from pathlib import Path + +# используем ту же логику, что и steam_library_size +from steam_library_size import get_app_size_from_steamcmd + +SCRIPT_DIR = Path(__file__).parent +CACHE_FILE = SCRIPT_DIR / "steam_app_sizes.json" +LIBRARY_FILE = SCRIPT_DIR / "steam_library.json" +REQUEST_DELAY = 0.5 + +# если в кэше меньше чем (steamcmd * MIN_RATIO), считаем источник сомнительным +MIN_RATIO = 0.6 +# или если steamcmd даёт больше MIN_GB, а разница больше DIFF_GB +MIN_GB_STEAMCMD = 15.0 +DIFF_GB = 10.0 + + +def main(sample: int | None = None) -> None: + with open(CACHE_FILE, encoding="utf-8") as f: + cache = json.load(f) + cache_sizes = {int(k): v for k, v in cache.items()} + + if LIBRARY_FILE.exists(): + with open(LIBRARY_FILE, encoding="utf-8") as f: + library = json.load(f) + name_by_appid = {g["appid"]: g.get("name", "?") for g in library} + else: + name_by_appid = {} + + items = list(cache_sizes.items()) + if sample is not None and len(items) > sample: + random.seed(42) + items = random.sample(items, sample) + print(f"Режим выборки: проверяем {sample} из {len(cache_sizes)} игр") + total = len(items) + suspicious = [] + + for i, (appid, cached_gb) in enumerate(items): + if cached_gb is None: + continue + if not isinstance(cached_gb, (int, float)): + continue + cached_gb = float(cached_gb) + steamcmd_gb = get_app_size_from_steamcmd(appid) + time.sleep(REQUEST_DELAY) + name = name_by_appid.get(appid, "?") + if i % 20 == 0 or i == total - 1: + print(f"\rПроверено {i + 1}/{total} {name[:40]}", end="", flush=True) + + if steamcmd_gb is None: + continue + if cached_gb >= steamcmd_gb * MIN_RATIO: + continue + if steamcmd_gb >= MIN_GB_STEAMCMD and (steamcmd_gb - cached_gb) >= DIFF_GB: + suspicious.append({ + "appid": appid, + "name": name, + "cached_gb": round(cached_gb, 2), + "steamcmd_gb": round(steamcmd_gb, 2), + }) + + print() + print(f"Проверено: {total}") + print(f"Подозрительных (кэш заметно меньше steamcmd): {len(suspicious)}") + if sample and total < len(cache_sizes): + print(f"(оценка на всю библиотеку: ~{len(suspicious) * len(cache_sizes) // max(1, total)} таких игр)") + print() + if suspicious: + print("Список (кэш ГБ → steamcmd ГБ):") + for s in sorted(suspicious, key=lambda x: -x["steamcmd_gb"]): + print(f" {s['appid']:>8} {s['cached_gb']:>7.1f} → {s['steamcmd_gb']:>7.1f} {s['name']}") + return suspicious + + +def heuristic_round_numbers() -> None: + """Без API: считает записи с «круглым» размером в ГБ (часто из требований магазина).""" + with open(CACHE_FILE, encoding="utf-8") as f: + cache = json.load(f) + if LIBRARY_FILE.exists(): + with open(LIBRARY_FILE, encoding="utf-8") as f: + library = json.load(f) + name_by_appid = {g["appid"]: g.get("name", "?") for g in library} + else: + name_by_appid = {} + # круглые числа: целые или X.0, и типичные для магазина (1, 2, 4, 6, 8, 12, 16, 20, 70...) + round_entries = [] + for k, v in cache.items(): + if v is None: + continue + gb = float(v) + if gb <= 0: + continue + if gb == int(gb) or abs(gb - round(gb)) < 0.01: + round_entries.append((int(k), round(gb, 1), name_by_appid.get(int(k), "?"))) + round_entries.sort(key=lambda x: -x[1]) + print(f"Записей с «круглым» размером (целое число ГБ): {len(round_entries)} из {len(cache)}") + print("Часто так указывают в системных требованиях магазина, а не реальный размер депо.") + print() + print("Топ по размеру (могут быть занижены):") + for appid, gb, name in round_entries[:30]: + print(f" {gb:>6.1f} ГБ {appid:>8} {name}") + if len(round_entries) > 30: + print(f" ... и ещё {len(round_entries) - 30}") + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description="Сверка кэша размеров с steamcmd API") + p.add_argument("--sample", type=int, default=None, help="Проверить только N игр (быстрая оценка)") + p.add_argument("--heuristic", action="store_true", help="Только эвристика по круглым числам (без API)") + args = p.parse_args() + if args.heuristic: + heuristic_round_numbers() + else: + main(sample=args.sample) diff --git a/homelab/scripts/merge_names_into_sizes.py b/homelab/scripts/merge_names_into_sizes.py new file mode 100644 index 0000000..f77b7f8 --- /dev/null +++ b/homelab/scripts/merge_names_into_sizes.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +""" +Однократно подтягивает названия игр из steam_library.json в steam_library_with_sizes.json. +Размеры берутся из кэша steam_app_sizes.json. Запускать из homelab/scripts/. +""" + +import json +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +LIBRARY_FILE = SCRIPT_DIR / "steam_library.json" +CACHE_FILE = SCRIPT_DIR / "steam_app_sizes.json" +OUTPUT_FILE = SCRIPT_DIR / "steam_library_with_sizes.json" + + +def main() -> None: + if not LIBRARY_FILE.exists(): + print(f"Нужен файл {LIBRARY_FILE}") + return + if not CACHE_FILE.exists(): + print(f"Нужен файл {CACHE_FILE}") + return + + with open(LIBRARY_FILE, encoding="utf-8") as f: + library = json.load(f) + with open(CACHE_FILE, encoding="utf-8") as f: + cache = json.load(f) + + # кэш: ключи строковые "appid", значения — число или null + cache_sizes = {int(k): v for k, v in cache.items()} + + results = [] + for game in library: + appid = game["appid"] + name = game.get("name", "Unknown") + raw = cache_sizes.get(appid) + if raw is not None and isinstance(raw, (int, float)): + size_gb = round(float(raw), 2) + else: + size_gb = None + results.append({"appid": appid, "name": name, "size_gb": size_gb}) + + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + json.dump(results, f, ensure_ascii=False, indent=2) + + print(f"Записано {len(results)} записей в {OUTPUT_FILE}") + + +if __name__ == "__main__": + main() diff --git a/homelab/scripts/reorganize-games-common.sh b/homelab/scripts/reorganize-games-common.sh new file mode 100644 index 0000000..94bdc85 --- /dev/null +++ b/homelab/scripts/reorganize-games-common.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# +# Упорядочивание папок игр в common/ (HDD 4 TB): GameName/GameName/ (файлы) + appmanifest в GameName/, +# затем перенос всех папок GameName из common/ на уровень выше (HDD 4 TB). Итог: HDD 4 TB/GameName/GameName/, HDD 4 TB/GameName/appmanifest_*.acf, common/ пустая. +# AppId берётся по имени папки из steam_library_with_sizes.json (резолвер resolve_steam_appid.py). +# Ручные сопоставления (CM3, Darksiders 3, DOOMEternal и др.) — в resolve_steam_appid.py MANUAL_MAP. +# +# Запуск в CT 101. Корневая директория: /mnt/nextcloud-extra/common/ (PARENT = /mnt/nextcloud-extra = HDD 4 TB). +# +# Подготовка: скопировать в контейнер скрипты и JSON (в одну папку, например /opt/scripts/): +# pct push 101 homelab/scripts/reorganize-games-common.sh /opt/scripts/ +# pct push 101 homelab/scripts/resolve_steam_appid.py /opt/scripts/ +# pct push 101 homelab/scripts/steam_library_with_sizes.json /opt/scripts/ +# +# Dry-run (ничего не меняет, только вывод; в конце — папки без совпадений): +# ssh root@192.168.1.150 'pct exec 101 -- bash /opt/scripts/reorganize-games-common.sh /mnt/nextcloud-extra/common 1 /opt/scripts/resolve_steam_appid.py /opt/scripts/steam_library_with_sizes.json' +# +# Реальное выполнение (второй аргумент 0): +# ssh root@192.168.1.150 'pct exec 101 -- bash /opt/scripts/reorganize-games-common.sh /mnt/nextcloud-extra/common 0 /opt/scripts/resolve_steam_appid.py /opt/scripts/steam_library_with_sizes.json' +# +set -euo pipefail + +ROOT="${1:?Usage: $0 [DRY_RUN=1] [RESOLVER_SCRIPT] [LIBRARY_JSON]}" +DRY_RUN="${2:-1}" +RESOLVER_SCRIPT="${3:-}" +LIBRARY_JSON="${4:-}" +[[ -z "$RESOLVER_SCRIPT" ]] && [[ -f ./resolve_steam_appid.py ]] && RESOLVER_SCRIPT="./resolve_steam_appid.py" +[[ -n "$RESOLVER_SCRIPT" ]] && [[ -z "$LIBRARY_JSON" ]] && LIBRARY_JSON="$(dirname "$RESOLVER_SCRIPT")/steam_library_with_sizes.json" +[[ -z "$RESOLVER_SCRIPT" ]] && { + echo "ERROR: resolve_steam_appid.py not found. Pass path as 3rd arg or run from script dir." + exit 1 +} +[[ ! -f "$RESOLVER_SCRIPT" ]] && { + echo "ERROR: resolver script not found: $RESOLVER_SCRIPT" + exit 1 +} +[[ -z "$LIBRARY_JSON" ]] || [[ ! -f "$LIBRARY_JSON" ]] && { + echo "ERROR: steam_library_with_sizes.json not found. Pass path as 4th arg or put next to resolver." + exit 1 +} + +echo "=== ROOT: $ROOT | DRY_RUN: $DRY_RUN | Resolver: $RESOLVER_SCRIPT | Library: $LIBRARY_JSON ===" +echo "" + +if [[ ! -d "$ROOT" ]]; then + echo "ERROR: not a directory: $ROOT" + exit 1 +fi + +PARENT_DIR="$(dirname "$ROOT")" +echo "Папки после обработки будут перенесены: $ROOT -> $PARENT_DIR (уровень выше)" +echo "" + +MISSING_APPID=() +MISSING_MANIFEST=() +RESOLVED=() +PROCESSED_DIRS=() + +while IFS= read -r -d '' dir; do + base=$(basename "$dir") + [[ "$base" == "lost+found" ]] && continue + + appid="" + appid=$(python3 "$RESOLVER_SCRIPT" "$LIBRARY_JSON" "$base" 2>/dev/null || true) + if [[ -z "$appid" ]]; then + MISSING_APPID+=("$base") + echo "[$base] appId не найден по имени — пропуск" + echo "" + continue + fi + + manifest="$ROOT/appmanifest_${appid}.acf" + # Манифест может лежать в корне common/ или на уровень выше (steamapps/) + if [[ ! -f "$manifest" ]]; then + manifest_parent="$(dirname "$ROOT")/appmanifest_${appid}.acf" + if [[ -f "$manifest_parent" ]]; then + manifest="$manifest_parent" + else + MISSING_MANIFEST+=("$base -> appmanifest_${appid}.acf") + fi + fi + + inner="$dir/$base" + if [[ -d "$inner" ]]; then + echo "[$base] (уже есть вложенная $base/) appId=$appid" + for item in "$dir"/*; do + [[ -e "$item" ]] || continue + iname=$(basename "$item") + [[ "$iname" == "$base" ]] && continue + [[ "$iname" == appmanifest_*.acf ]] && continue + if [[ $DRY_RUN -eq 1 ]]; then + echo " [DRY-RUN] mv '$item' -> '$inner/'" + else + mv "$item" "$inner/" + echo " mv $iname -> $base/" + fi + done + if [[ -f "$manifest" ]]; then + dest_manifest="$dir/appmanifest_${appid}.acf" + if [[ "$manifest" != "$dest_manifest" ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + echo " [DRY-RUN] cp/mv '$manifest' -> '$dest_manifest'" + else + cp "$manifest" "$dest_manifest" 2>/dev/null || mv "$manifest" "$dest_manifest" + echo " appmanifest_${appid}.acf -> $dir/" + fi + fi + fi + else + echo "[$base] (создать $base/$base/ и перенести файлы) appId=$appid" + if [[ $DRY_RUN -eq 1 ]]; then + echo " [DRY-RUN] mkdir -p '$inner'" + else + mkdir -p "$inner" + fi + for item in "$dir"/*; do + [[ -e "$item" ]] || continue + iname=$(basename "$item") + [[ "$iname" == "$base" ]] && continue + [[ "$iname" == appmanifest_*.acf ]] && continue + if [[ $DRY_RUN -eq 1 ]]; then + echo " [DRY-RUN] mv '$item' -> '$inner/'" + else + mv "$item" "$inner/" + echo " mv $iname -> $base/" + fi + done + if [[ -f "$manifest" ]]; then + dest_manifest="$dir/appmanifest_${appid}.acf" + if [[ "$manifest" != "$dest_manifest" ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + echo " [DRY-RUN] cp/mv '$manifest' -> '$dest_manifest'" + else + cp "$manifest" "$dest_manifest" 2>/dev/null || mv "$manifest" "$dest_manifest" + echo " appmanifest_${appid}.acf -> $dir/" + fi + fi + fi + fi + RESOLVED+=("$base -> $appid") + PROCESSED_DIRS+=("$base") + echo "" +done < <(find "$ROOT" -maxdepth 1 -type d ! -path "$ROOT" -print0 | sort -z) + +echo "=== Перенос папок из common/ на уровень выше (HDD 4 TB) ===" +for base in "${PROCESSED_DIRS[@]}"; do + src="$ROOT/$base" + dst="$PARENT_DIR/$base" + if [[ $DRY_RUN -eq 1 ]]; then + echo " [DRY-RUN] mv '$src' -> '$PARENT_DIR/'" + else + if mv "$src" "$PARENT_DIR/" 2>/dev/null; then + echo " mv $base -> $PARENT_DIR/" + else + # Цель уже существует (например, дубликат игры) — сливаем содержимое и удаляем источник + if [[ -d "$dst" ]]; then + inner="$src/$base" + if [[ -d "$inner" ]]; then + mkdir -p "$dst/$base" + for item in "$inner"/*; do [[ -e "$item" ]] && mv "$item" "$dst/$base/"; done + rmdir "$inner" 2>/dev/null || true + fi + for f in "$src"/appmanifest_*.acf; do [[ -f "$f" ]] && cp -n "$f" "$dst/" 2>/dev/null || true; done + rm -rf "$src" + echo " merge $base -> $PARENT_DIR/ (target existed)" + else + echo " ERROR: could not move $base" + fi + fi + fi +done +echo "" + +echo "=== Папки, для которых не найден appId ===" +for x in "${MISSING_APPID[@]}"; do echo " $x"; done +echo "" +echo "=== Итог: игры в $PARENT_DIR/GameName/GameName/, манифесты в $PARENT_DIR/GameName/, common/ пустая ===" +echo "" +echo "=== Нет файла appmanifest (игра найдена по имени) ===" +for x in "${MISSING_MANIFEST[@]}"; do echo " $x"; done +echo "" +echo "=== Сопоставления имя -> appId ===" +for x in "${RESOLVED[@]}"; do echo " $x"; done +echo "" +echo "=== Конец (DRY_RUN=$DRY_RUN) ===" diff --git a/homelab/scripts/reorganize-games.sh b/homelab/scripts/reorganize-games.sh new file mode 100644 index 0000000..072608a --- /dev/null +++ b/homelab/scripts/reorganize-games.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# +# Упорядочивание папок игр: GameName/GameName/ (файлы) + appmanifest в GameName/ +# Запуск в CT 101. Поддерживает две корневые директории: Игры и nextcloud-extra. +# +# Использование (с хоста Proxmox 192.168.1.150): +# +# Dry-run (только вывод действий, ничего не меняет): +# ssh root@192.168.1.150 'pct exec 101 -- bash -s "/mnt/nextcloud-data/html/data/kerrad/files/Игры" 1' < homelab/scripts/reorganize-games.sh +# ssh root@192.168.1.150 'pct exec 101 -- bash -s /mnt/nextcloud-extra 1' < homelab/scripts/reorganize-games.sh +# +# Реальное выполнение (второй аргумент 0): +# ssh root@192.168.1.150 'pct exec 101 -- bash -s "/mnt/nextcloud-data/html/data/kerrad/files/Игры" 0' < homelab/scripts/reorganize-games.sh +# ssh root@192.168.1.150 'pct exec 101 -- bash -s /mnt/nextcloud-extra 0' < homelab/scripts/reorganize-games.sh +# +# Либо скопировать скрипт в контейнер и запустить там: +# pct exec 101 -- bash /path/to/reorganize-games.sh "/mnt/.../Игры" 1 +# +set -euo pipefail + +ROOT="${1:?Usage: $0 [DRY_RUN=1]}" +DRY_RUN="${2:-1}" +README="" +[ -f "$ROOT/readme.md" ] && README="$ROOT/readme.md" +[ -f "$ROOT/Readme.md" ] && README="${README:-$ROOT/Readme.md}" +[ -z "$README" ] && { echo "ERROR: no readme.md or Readme.md in $ROOT"; exit 1; } + +# Маппинг: "имя в readme" -> "имя папки на диске" (если отличается) +declare -A NAME_TO_FOLDER +# Игры (kerrad/files/Игры) +NAME_TO_FOLDER["Mortal Kombat X"]="MK10" +NAME_TO_FOLDER["Ведьмак 3"]="The Witcher 3" +NAME_TO_FOLDER["Crusader Kings 3"]="Crusader Kings III" +NAME_TO_FOLDER["Cities: Skylines"]="Cities_Skylines" +NAME_TO_FOLDER["Baldur's gate 3"]="Baldurs Gate 3" +NAME_TO_FOLDER["Anno 1800 (full dlc)"]="Anno 1800" +NAME_TO_FOLDER["Titan Quest (full dlc)"]="Titan Quest Anniversary Edition" +NAME_TO_FOLDER["KCD 2"]="KingdomComeDeliverance2" +NAME_TO_FOLDER["FC 24"]="EA Sports FC 24" +NAME_TO_FOLDER["Katana Zero"]="Katana ZERO" +NAME_TO_FOLDER["L4D2"]="Left 4 Dead 2" +NAME_TO_FOLDER["XCOM2"]="XCOM 2" +NAME_TO_FOLDER["L.A. Noire"]="L.A.Noire" +NAME_TO_FOLDER["ETS 2"]="Euro Truck Simulator 2" +NAME_TO_FOLDER["Phantome Doctrine"]="PhantomDoctrine" +NAME_TO_FOLDER["Черепашки ниндзя: В поисках Сплинтера"]="Teenage Mutant Ninja Turtles Splintered Fate" +NAME_TO_FOLDER["Казаки 3"]="Cossacks 3" +NAME_TO_FOLDER["A Way Out"]="AWayOut" +NAME_TO_FOLDER["Counter Strike 2"]="Counter-Strike Global Offensive" +NAME_TO_FOLDER["Sid Meier's Civilization IV"]="Sid Meier's Civilization VI" +# nextcloud-extra (readme: "Assasins" с одной s, на диске часто "Assassin's") +NAME_TO_FOLDER["Assasins Creed"]="Assassins Creed" +NAME_TO_FOLDER["Bully"]="Bully Scholarship Edition" +NAME_TO_FOLDER["Assasins Creed 2"]="Assassin's Creed 2" +NAME_TO_FOLDER["Assasins Creed III Remastered"]="Assassin's Creed III Remastered" +NAME_TO_FOLDER["Assasins Creed IV Black Flag"]="Assassin's Creed IV Black Flag" +NAME_TO_FOLDER["Assasins Creed Revelations"]="Assassin's Creed Revelations" +NAME_TO_FOLDER["Assasins Creed Syndicate"]="Assassin's Creed Syndicate" +NAME_TO_FOLDER["Assasins Creed Unity"]="Assassin's Creed Unity" +NAME_TO_FOLDER["Assasins Creed Valhalla"]="Assassin's Creed Valhalla" +NAME_TO_FOLDER["Assasins Creed Mirage"]="Assassin's Creed Mirage" +NAME_TO_FOLDER["Assassins Creed Brotherhood"]="Assassins Creed Brotherhood" +NAME_TO_FOLDER["Assassins Creed Odyssey"]="Assassins Creed Odyssey" +NAME_TO_FOLDER["Assassins Creed Origins"]="Assassins Creed Origins" +NAME_TO_FOLDER["Bioshock Remastered"]="BioShock Remastered" +NAME_TO_FOLDER["Bioshock Infinite"]="BioShock Infinite" +NAME_TO_FOLDER["Bioshock 2 Remastered"]="BioShock 2 Remastered" + +normalize() { + echo "$1" | tr '[:upper:]' '[:lower:]' | tr -s ' \t' ' ' | sed "s/[[:punct:]]//g" | sed 's/^ *//;s/ *$//' +} + +# Парсим readme: строки таблицы | ... | название | appid | +declare -A README_NAME_TO_APPID +while IFS= read -r line; do + [[ "$line" != "|"* ]] && continue + # Убираем первый и последний |, разбиваем по | + rest="${line#|}" + rest="${rest%|}" + rest="${rest# }" + rest="${rest% }" + # Колонки: жанр | название | appmanifest + name="" + appid="" + col=0 + while [[ -n "$rest" ]]; do + if [[ "$rest" == *"|"* ]]; then + part="${rest%%|*}" + rest="${rest#*|}" + else + part="$rest" + rest="" + fi + part=$(echo "$part" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + if [[ $col -eq 1 ]]; then + name="$part" + elif [[ $col -eq 2 ]]; then + appid="${part// /}" + fi + ((col++)) || true + done + [[ -z "$name" || -z "$appid" ]] && continue + [[ "$name" == "название игры" ]] && continue + [[ "$name" == "appmanifest" ]] && continue + README_NAME_TO_APPID["$name"]="$appid" +done < "$README" + +# По имени из readme находим папку на диске +resolve_folder() { + local readme_name="$1" + if [[ -n "${NAME_TO_FOLDER[$readme_name]:-}" ]]; then + echo "${NAME_TO_FOLDER[$readme_name]}" + return + fi + local norm=$(normalize "$readme_name") + for dir in "$ROOT"/*/; do + [[ -d "$dir" ]] || continue + base=$(basename "$dir") + [[ "$base" == "моды" || "$base" == "Моды" || "$base" == "lost+found" ]] && continue + if [[ $(normalize "$base") == "$norm" ]]; then + echo "$base" + return + fi + done + echo "" +} + +# По имени папки находим appid из readme (перебор по всем именам в readme) +resolve_appid() { + local folder_name="$1" + local norm_folder=$(normalize "$folder_name") + for readme_name in "${!README_NAME_TO_APPID[@]}"; do + local f=$(resolve_folder "$readme_name") + if [[ "$f" == "$folder_name" ]]; then + echo "${README_NAME_TO_APPID[$readme_name]}" + return + fi + done + # Прямое совпадение по нормализованному имени папки + for readme_name in "${!README_NAME_TO_APPID[@]}"; do + if [[ $(normalize "$readme_name") == "$norm_folder" ]]; then + echo "${README_NAME_TO_APPID[$readme_name]}" + return + fi + done + echo "" +} + +echo "=== ROOT: $ROOT | DRY_RUN: $DRY_RUN ===" +echo "" + +# Собираем папки игр (директории, не файлы) +MISSING_APPID=() +MISSING_MANIFEST=() +while IFS= read -r -d '' dir; do + base=$(basename "$dir") + [[ "$base" == "моды" || "$base" == "Моды" || "$base" == "lost+found" ]] && continue + appid=$(resolve_appid "$base") + if [[ -z "$appid" ]]; then + MISSING_APPID+=("$base (нет в readme)") + continue + fi + manifest="$ROOT/appmanifest_${appid}.acf" + if [[ ! -f "$manifest" ]]; then + MISSING_MANIFEST+=("$base -> appmanifest_${appid}.acf") + fi + + inner="$dir/$base" + if [[ -d "$inner" ]]; then + # Уже есть вложенная папка с тем же именем + echo "[$base] (есть вложенная $base/)" + for item in "$dir"/*; do + [[ -e "$item" ]] || continue + iname=$(basename "$item") + [[ "$iname" == "$base" ]] && continue + [[ "$iname" == "readme.md" || "$iname" == "Readme.md" ]] && continue + [[ "$iname" == appmanifest_*.acf ]] && continue + if [[ $DRY_RUN -eq 1 ]]; then + echo " [DRY-RUN] mv '$item' -> '$inner/'" + else + echo " mv '$item' -> '$inner/'" + mv "$item" "$inner/" + fi + done + if [[ -f "$manifest" ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + echo " [DRY-RUN] mv '$manifest' -> '$dir/'" + else + mv "$manifest" "$dir/" + echo " mv appmanifest_${appid}.acf -> $dir/" + fi + fi + else + # Нет вложенной папки — создаём GameName/GameName/ и переносим всё + echo "[$base] (создать $base/$base/ и перенести файлы)" + if [[ $DRY_RUN -eq 1 ]]; then + echo " [DRY-RUN] mkdir -p '$inner'" + else + mkdir -p "$inner" + fi + for item in "$dir"/*; do + [[ -e "$item" ]] || continue + iname=$(basename "$item") + [[ "$iname" == "$base" ]] && continue + [[ "$iname" == "readme.md" || "$iname" == "Readme.md" ]] && continue + [[ "$iname" == appmanifest_*.acf ]] && continue + if [[ $DRY_RUN -eq 1 ]]; then + echo " [DRY-RUN] mv '$item' -> '$inner/'" + else + mv "$item" "$inner/" + echo " mv $iname -> $base/" + fi + done + if [[ -f "$manifest" ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + echo " [DRY-RUN] mv '$manifest' -> '$dir/'" + else + mv "$manifest" "$dir/" + echo " mv appmanifest_${appid}.acf -> $dir/" + fi + fi + fi + echo "" +done < <(find "$ROOT" -maxdepth 1 -type d ! -path "$ROOT" -print0 | sort -z) + +echo "=== Папки без соответствия в readme ===" +for x in "${MISSING_APPID[@]}"; do echo " $x"; done +echo "" +echo "=== Не найдены appmanifest (игра есть в readme) ===" +for x in "${MISSING_MANIFEST[@]}"; do echo " $x"; done +echo "" +echo "=== Конец (DRY_RUN=$DRY_RUN) ===" diff --git a/homelab/scripts/resolve_steam_appid.py b/homelab/scripts/resolve_steam_appid.py new file mode 100644 index 0000000..5832907 --- /dev/null +++ b/homelab/scripts/resolve_steam_appid.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +По имени папки (игры) возвращает Steam App ID из локального steam_library_with_sizes.json. +Совпадение по нормализованному имени (регистр, пунктуация не учитываются). +Использование: resolve_steam_appid.py [PATH_TO_JSON] "Имя папки" +Выход: один appid или пусто при ненайденном. +""" + +import json +import re +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +DEFAULT_JSON = SCRIPT_DIR / "steam_library_with_sizes.json" + +# Ручное сопоставление: имя папки на диске -> appId (если нет в JSON или совпадение неверное) +MANUAL_MAP = { + "CM3": "351920", # Crazy Machines 3 + "Darksiders 3": "606280", # Darksiders III + "DOOMEternal": "782330", # DOOM Eternal (папка без пробела) + "FarCry5": "552520", # Far Cry 5 + "Gears5": "1097840", # Gears 5 + "GodOfWar": "1593500", # God of War + "HITMAN 3": "1659040", # HITMAN World of Assassination + "ItTakesTwo": "1426210", # It Takes Two + "hotline_miami": "219150", # Hotline Miami + "Jaded": "1932570", # Jaded +} + + +def normalize(name: str) -> str: + s = (name or "").lower().strip() + s = re.sub(r"[^\w\s]", " ", s) + s = re.sub(r"\s+", " ", s) + return s.strip() + + +def load_library(path: Path) -> list[dict]: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def find_best_appid(query: str, library: list[dict]) -> str: + if not query: + return "" + qnorm = normalize(query) + if not qnorm: + return "" + qwords = set(qnorm.split()) + best_appid = "" + best_score = -1 + for entry in library: + name = entry.get("name") or "" + appid = entry.get("appid") + if appid is None: + continue + nnorm = normalize(name) + if not nnorm: + continue + # Точное совпадение + if nnorm == qnorm: + return str(appid) + # Оба содержат друг друга + if qnorm in nnorm or nnorm in qnorm: + score = len(qwords & set(nnorm.split())) + if score > best_score: + best_score = score + best_appid = str(appid) + # Пересечение по словам + nwords = set(nnorm.split()) + overlap = len(qwords & nwords) + if overlap > 0 and overlap >= min(2, len(qwords), len(nwords)): + if overlap > best_score: + best_score = overlap + best_appid = str(appid) + return best_appid + + +def main() -> int: + args = [a for a in sys.argv[1:] if a.strip()] + if not args: + print("", end="") + return 0 + # Путь к JSON: первый аргумент, если это путь к существующему файлу + json_path = Path(args[0]) + if json_path.exists() and json_path.is_file(): + path = json_path + name = " ".join(args[1:]).strip() + else: + path = DEFAULT_JSON + name = " ".join(args).strip() + if not name: + print("", end="") + return 0 + if not path.exists(): + sys.stderr.write(f"resolve_steam_appid: file not found: {path}\n") + print("", end="") + return 1 + if name in MANUAL_MAP: + print(MANUAL_MAP[name], end="") + return 0 + try: + library = load_library(path) + except Exception as e: + sys.stderr.write(f"resolve_steam_appid: {e}\n") + print("", end="") + return 1 + appid = find_best_appid(name, library) + print(appid, end="") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/homelab/scripts/steam-library-to-json.py b/homelab/scripts/steam-library-to-json.py new file mode 100644 index 0000000..6458e82 --- /dev/null +++ b/homelab/scripts/steam-library-to-json.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +Выгрузка списка игр из Steam-библиотеки в JSON через Steam Web API. +Нужны: API key (https://steamcommunity.com/dev/apikey) и 64-битный Steam ID. +""" + +import json +import urllib.request +import urllib.parse + +# Подставь свои данные +STEAM_API_KEY = "CB3A546C47CBADB5CAB3FA6D60B6DE4C" # https://steamcommunity.com/dev/apikey +STEAM_ID_64 = "76561198113489815" # 64-битный ID, например из https://steamid.io/ + +OUTPUT_FILE = "steam_library.json" + + +def get_owned_games(api_key: str, steam_id: str) -> list[dict]: + url = "https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/" + params = { + "key": api_key, + "steamid": steam_id, + "include_appinfo": 1, + "include_played_free_games": 1, + } + req = urllib.request.Request( + f"{url}?{urllib.parse.urlencode(params)}", + headers={"Accept": "application/json"}, + ) + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read().decode()) + if "response" not in data or "games" not in data["response"]: + raise RuntimeError(data.get("response", data)) + return data["response"]["games"] + + +def main() -> None: + if not STEAM_API_KEY or not STEAM_ID_64: + print("Заполни STEAM_API_KEY и STEAM_ID_64 в начале скрипта.") + return + games = get_owned_games(STEAM_API_KEY, STEAM_ID_64) + out = [ + { + "appid": g["appid"], + "name": g.get("name", ""), + "playtime_forever_min": g.get("playtime_forever", 0), + "img_icon_url": g.get("img_icon_url", ""), + "img_logo_url": g.get("img_logo_url", ""), + } + for g in games + ] + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + json.dump(out, f, ensure_ascii=False, indent=2) + print(f"Записано {len(out)} игр в {OUTPUT_FILE}") + print("Дальше: python3 steam_library_size.py — посчитает размеры и суммарный объём.") + + +if __name__ == "__main__": + main() diff --git a/homelab/scripts/steam-to-cloud-options.md b/homelab/scripts/steam-to-cloud-options.md new file mode 100644 index 0000000..96d54a0 --- /dev/null +++ b/homelab/scripts/steam-to-cloud-options.md @@ -0,0 +1,37 @@ +# Варианты: Steam → облако (Nextcloud) + +## Как сейчас +Скачиваешь игру в Steam на ПК, потом вручную заливаешь папку игры в облако и отдельно — манифест. Долго и неудобно. + +--- + +## Вариант 1: Библиотека Steam в папке Nextcloud + +Создаёшь вторую библиотеку Steam и указываешь папку, которую синкает Nextcloud (например папка «Игры» на компе или отдельная «Steam в облако»). При установке игры в Steam выбираешь эту библиотеку — игра качается сразу в эту папку, а Nextcloud в фоне поднимает её в облако. + +Плюсы: один раз настроил, дальше только выбираешь библиотеку при установке. +Минусы: структура в облаке будет не «Игра/Игра/файлы», а как у Steam — `steamapps/common/Игра/` и манифесты в `steamapps/`. То есть не совсем как у тебя сейчас. Плюс синк больших объёмов может долго идти и грузить диск и сеть. + +Как сделать: Steam → Настройки → Загрузки → Папки библиотеки Steam → Добавить → указать папку внутри Nextcloud (или отдельную папку, которую потом добавишь в синк). При установке игры выбирать эту библиотеку. + +--- + +## Вариант 2: Качаешь как обычно, потом один раз копируешь в Nextcloud + +Игру качаешь в обычную библиотеку Steam. Когда установилась — один раз копируешь в папку Nextcloud на компе: саму папку игры (из steamapps/common) и соответствующий appmanifest (из steamapps). В облаке заранее создаёшь папку с именем игры, внутрь — папку с тем же именем, туда кладёшь файлы игры, а appmanifest — в корень папки игры. То есть делаешь ту же структуру, что уже есть в облаке, но вручную за один «копируй и вставь» в синк-папку Nextcloud, без отдельной загрузки манифеста через веб. + +Плюсы: схема в облаке остаётся твоя (Игра/Игра/ + манифест), процесс быстрее, чем «игра отдельно, манифест отдельно» через интерфейс. +Минусы: после каждой установки нужно один раз скопировать папку и манифест в папку Nextcloud. + +--- + +## Вариант 3: Сетевое хранилище как диск + +Поднимаешь на сервере общий доступ по сети (SMB) к тому месту, где лежат «Игры» или HDD 4 TB (или к отдельной папке под Steam). На компе монтируешь этот диск и в Steam добавляешь вторую библиотеку на этот сетевой диск. Тогда установка игры идёт сразу «в облако» по сети. + +Плюсы: не нужно потом ничего копировать — всё пишется сразу на сервер. +Минусы: нужна настройка SMB (или другого доступа к диску), скорость и стабильность зависят от сети; структура в облаке снова будет Steam-стандартная (steamapps/common и т.д.), не «Игра/Игра/». + +--- + +Итого: если хочешь минимум возни — вариант 1 (библиотека в папке Nextcloud), но структура в облаке будет другая. Если важно сохранить текущую структуру и чуть ускорить процесс — вариант 2 (качаешь как обычно, один раз копируешь игру и манифест в папку Nextcloud в нужном виде). diff --git a/homelab/scripts/steam_app_sizes.json b/homelab/scripts/steam_app_sizes.json new file mode 100644 index 0000000..2a274aa --- /dev/null +++ b/homelab/scripts/steam_app_sizes.json @@ -0,0 +1,271 @@ +{ + "10": 0.09375, + "80": 0.09375, + "100": 0.09375, + "220": 0.31052582152187824, + "240": 0.29873945750296116, + "320": 0.30254453141242266, + "360": 0.29670846182852983, + "400": 0.15206828899681568, + "440": 0.5697660846635699, + "550": 2.0, + "570": 6.1378975212574005, + "620": 0.7513375505805016, + "730": 8.0, + "1250": 1.0, + "2600": null, + "4500": 6.0, + "6370": 1.0, + "7670": 1.0, + "8850": 13.66907548904419, + "8870": 2.0, + "9480": 12.131029523909092, + "10150": null, + "12200": 1.0, + "12210": 1.5, + "13500": 1.5, + "13530": 1.5, + "15100": 1.0, + "19980": 1.0, + "20500": 1.0, + "20510": 6.0, + "20920": 0.13999772910028696, + "24240": 1.0, + "24880": 2.0, + "28000": 1.0, + "29800": 0.04376364313066006, + "33230": 6.003417863510549, + "33320": 1.0, + "35140": 7.889322315342724, + "35420": null, + "35700": 4.141127409413457, + "35720": 4.501905304379761, + "40800": 0.4876335524022579, + "41700": 6.0, + "47810": 1.0, + "48190": 18.191587133333087, + "50130": 9.246913000009954, + "55230": 11.12900713365525, + "96100": 0.5, + "102600": 2.0, + "104200": null, + "105600": 0.746307723224163, + "108600": 6.804165206849575, + "110800": 2.0, + "113200": 0.12740739434957504, + "200260": 18.57858782913536, + "201870": 1.5, + "204100": 36.21077110711485, + "204180": 0.012577004730701447, + "204360": 8.0, + "204450": 2.0, + "206420": 3.034766612574458, + "208090": null, + "208650": 70.37538086436689, + "209000": 2.0, + "218620": 82.8623315691948, + "219150": 0.5458543803542852, + "219990": 2.0, + "220240": 11.551703695207834, + "222900": 2.0, + "224260": 2.792985877022147, + "225540": 55.86762906052172, + "226320": 0.4693394098430872, + "227300": 8.0, + "230230": 8.314474378712475, + "232090": 3.0, + "233270": 4.994622882455587, + "233450": 0.5001069400459528, + "233860": 11.471694991923869, + "241930": 3.0, + "242050": 29.128467716276646, + "242760": 5.5415748516097665, + "243470": 11.854517194442451, + "250400": 4.0, + "250900": 1.905337835662067, + "255710": 8.0, + "261550": 61.63875979743898, + "263980": 0.3049567611888051, + "268500": 4.0, + "270550": 1.0, + "270880": 8.0, + "271590": 4.0, + "274170": 0.5740419281646609, + "286690": 7.812409473583102, + "287450": 1.0, + "287700": 4.0, + "289070": 4.0, + "289130": 10.362007912248373, + "289650": 6.0, + "291010": 0.18821301590651274, + "292030": 6.0, + "292620": 0.02205665223300457, + "294100": 4.0, + "298110": 4.0, + "301910": 0.018734455108642578, + "305620": 17.259958535432816, + "307690": 16.70861548744142, + "307780": 3.0, + "314020": 1.1223331121727824, + "315430": 3.094804703257978, + "316030": 1.0, + "318430": 0.0009765625, + "319910": 4.7006360068917274, + "322330": 4.019438261166215, + "330020": 1.7270579617470503, + "330350": 0.7871295092627406, + "331470": 0.5, + "333420": 0.021825484931468964, + "339610": 10.02976268529892, + "345520": 2.0, + "346900": 0.4643522584810853, + "351920": 5.7309086956083775, + "356190": 6.0, + "360430": 52.395995314233005, + "361420": 2.9809251623228192, + "362890": 0.20420024264603853, + "363970": 0.5682053109630942, + "368500": 6.0, + "371660": 4.0, + "373420": 9.955548072233796, + "374320": 4.0, + "377160": 8.0, + "379430": 86.40584150701761, + "379720": 0.16897685825824738, + "386360": 38.648146538995206, + "391220": 6.0, + "393380": 64.49421414826065, + "394360": 4.0, + "409710": 20.658832599408925, + "409720": 19.94643583614379, + "412020": 70.07764175347984, + "414340": 8.0, + "435150": 65.71966441441327, + "447040": 31.45290844514966, + "460950": 0.21165237482637167, + "474960": 8.0, + "475150": 20.15837282501161, + "489830": 8.0, + "492720": 20.065139889717102, + "526870": 25.532881601713598, + "529340": 8.0, + "548430": 3.6065317867323756, + "551770": 19.047910733148456, + "552520": 47.025207565166056, + "557340": 3.561660211533308, + "559100": 23.429331749677658, + "578080": 8.0, + "582160": 6.0, + "582660": 87.07785203587264, + "597180": 7.040180410258472, + "601150": 40.011952906847, + "606280": 8.0, + "613100": 26.301719304174185, + "632470": 9.572589739225805, + "667720": 30.291241589933634, + "690640": 15.723209703341126, + "728880": 7.922727338038385, + "747350": 23.047448326833546, + "774941": 63.950939419679344, + "782330": 0.42261453717947006, + "812140": 8.0, + "829660": 0.2579149715602398, + "870780": 83.82497963216156, + "892970": 1.478191097266972, + "911400": 47.15562159009278, + "916440": 8.0, + "939960": 39.56889073736966, + "945360": 0.9332891013473272, + "960910": 36.49493746738881, + "973580": 8.0, + "976310": 8.0, + "978300": 46.344152592122555, + "990080": 2.2234949553385377, + "1029690": 8.0, + "1030830": 7.450580596923828e-08, + "1030840": 36.23636360000819, + "1044720": 8.0, + "1070330": 0.5611261501908302, + "1086940": 145.0, + "1088850": 8.0, + "1091500": 62.0, + "1092790": 4.0, + "1097150": 8.0, + "1097840": 120.63846635539085, + "1123770": 4.0, + "1124300": 34.544752170331776, + "1139900": 34.11205630656332, + "1145360": 13.034619254991412, + "1151640": 88.1803381787613, + "1158310": 8.0, + "1172380": 8.0, + "1174180": 8.0, + "1210320": 8.0, + "1222140": 58.74557256232947, + "1222670": 4.0, + "1222680": 8.0, + "1222700": 8.0, + "1232130": 2.0, + "1233570": 6.0, + "1237950": 8.0, + "1238810": 8.0, + "1239690": 4.0, + "1259420": 110.01045930571854, + "1272080": 16.0, + "1316680": 4.0, + "1328670": 8.0, + "1332010": 6.137137608602643, + "1336490": 6.509317799471319, + "1338770": 8.0, + "1363080": 13.207469248212874, + "1426210": 8.0, + "1436700": 8.0, + "1449560": 72.23142071720213, + "1466860": 8.0, + "1510580": 4.0, + "1546970": 8.0, + "1546990": 8.0, + "1547000": 8.0, + "1568590": 0.9997884016484022, + "1593500": 8.0, + "1659040": 78.71203076094389, + "1665460": 46.43916262499988, + "1715130": 19.86594975180924, + "1771300": 102.12097447179258, + "1774580": 8.0, + "1794680": 0.7062101969495416, + "1797610": 2.0, + "1817070": 70.1352766584605, + "1850570": 8.0, + "1888930": 4.507225760258734, + "1903340": 8.0, + "1971870": 201.90859704837203, + "2096600": 8.0, + "2096610": 8.0, + "2138710": 30.304323970340192, + "2140020": 0.00941288098692894, + "2144740": 69.84031751286238, + "2195250": 8.0, + "2208920": 158.53002528753132, + "2239550": 8.0, + "2290000": 1.6174326296895742, + "2369390": 8.0, + "2406770": 8.0, + "2417610": 16.0, + "2427410": 8.0, + "2427420": 8.0, + "2427430": 8.0, + "2456740": 33.04833281133324, + "2461850": 16.0, + "2531310": 139.8620684761554, + "2532550": 3.0764142777770758, + "2784470": 0.1604305850341916, + "2796010": 5.448299317620695, + "2807960": 16.0, + "2996040": 4.0, + "3035570": 65.0052012577653, + "3107800": 8.0, + "3240220": 8.0, + "3472040": 26.530300861224532, + "3551340": 7.121387985534966 +} \ No newline at end of file diff --git a/homelab/scripts/steam_library.json b/homelab/scripts/steam_library.json new file mode 100644 index 0000000..c23b683 --- /dev/null +++ b/homelab/scripts/steam_library.json @@ -0,0 +1,1885 @@ +[ + { + "appid": 10, + "name": "Counter-Strike", + "playtime_forever_min": 0, + "img_icon_url": "6b0312cda02f5f777efa2f3318c307ff9acafbb5", + "img_logo_url": "" + }, + { + "appid": 80, + "name": "Counter-Strike: Condition Zero", + "playtime_forever_min": 77, + "img_icon_url": "077b050ef3e89cd84e2c5a575d78d53b54058236", + "img_logo_url": "" + }, + { + "appid": 100, + "name": "Counter-Strike: Condition Zero Deleted Scenes", + "playtime_forever_min": 0, + "img_icon_url": "077b050ef3e89cd84e2c5a575d78d53b54058236", + "img_logo_url": "" + }, + { + "appid": 220, + "name": "Half-Life 2", + "playtime_forever_min": 0, + "img_icon_url": "fcfb366051782b8ebf2aa297f3b746395858cb62", + "img_logo_url": "" + }, + { + "appid": 240, + "name": "Counter-Strike: Source", + "playtime_forever_min": 0, + "img_icon_url": "9052fa60c496a1c03383b27687ec50f4bf0f0e10", + "img_logo_url": "" + }, + { + "appid": 320, + "name": "Half-Life 2: Deathmatch", + "playtime_forever_min": 0, + "img_icon_url": "795e85364189511f4990861b578084deef086cb1", + "img_logo_url": "" + }, + { + "appid": 360, + "name": "Half-Life Deathmatch: Source", + "playtime_forever_min": 0, + "img_icon_url": "40b8a62efff5a9ab356e5c56f5c8b0532c8e1aa3", + "img_logo_url": "" + }, + { + "appid": 400, + "name": "Portal", + "playtime_forever_min": 275, + "img_icon_url": "cfa928ab4119dd137e50d728e8fe703e4e970aff", + "img_logo_url": "" + }, + { + "appid": 440, + "name": "Team Fortress 2", + "playtime_forever_min": 0, + "img_icon_url": "f568912870a4684f9ec76277a1a404dda6bab213", + "img_logo_url": "" + }, + { + "appid": 550, + "name": "Left 4 Dead 2", + "playtime_forever_min": 0, + "img_icon_url": "7d5a243f9500d2f8467312822f8af2a2928777ed", + "img_logo_url": "" + }, + { + "appid": 570, + "name": "Dota 2", + "playtime_forever_min": 35696, + "img_icon_url": "0bbb630d63262dd66d2fdd0f7d37e8661a410075", + "img_logo_url": "" + }, + { + "appid": 620, + "name": "Portal 2", + "playtime_forever_min": 185, + "img_icon_url": "25a5a16b2423bf7487ac5340b5b0948cef48c5f8", + "img_logo_url": "" + }, + { + "appid": 730, + "name": "Counter-Strike 2", + "playtime_forever_min": 23810, + "img_icon_url": "8dbc71957312bbd3baea65848b545be9eae2a355", + "img_logo_url": "" + }, + { + "appid": 1250, + "name": "Killing Floor", + "playtime_forever_min": 904, + "img_icon_url": "d8a2d777cb4c59cf06aa244166db232336520547", + "img_logo_url": "" + }, + { + "appid": 2600, + "name": "Vampire: The Masquerade - Bloodlines", + "playtime_forever_min": 54, + "img_icon_url": "9fd08d0034ba09d371e1f1a179a0a3af6c36d1f0", + "img_logo_url": "" + }, + { + "appid": 4500, + "name": "S.T.A.L.K.E.R.: Shadow of Chernobyl", + "playtime_forever_min": 646, + "img_icon_url": "c57f5fdde74464aed0a09c2e5dd41f8973cbee8d", + "img_logo_url": "" + }, + { + "appid": 6370, + "name": "Bloodline Champions", + "playtime_forever_min": 494, + "img_icon_url": "f277e8f95b03f1565e3c5b3d45ff1b625fc01354", + "img_logo_url": "" + }, + { + "appid": 7670, + "name": "BioShock", + "playtime_forever_min": 0, + "img_icon_url": "9a7c9f640a76e6a32592277dbbc36a0f6da05372", + "img_logo_url": "" + }, + { + "appid": 8850, + "name": "BioShock 2", + "playtime_forever_min": 0, + "img_icon_url": "f5eda925c0e57373aaea4cae17b6f175115a8d54", + "img_logo_url": "" + }, + { + "appid": 8870, + "name": "BioShock Infinite", + "playtime_forever_min": 0, + "img_icon_url": "4ebaf5f9ee74f50152f7ff361debef7553fa0e4e", + "img_logo_url": "" + }, + { + "appid": 9480, + "name": "Saints Row 2", + "playtime_forever_min": 0, + "img_icon_url": "1a0aef912300d396a67f9eda9d8b4e66c41c9891", + "img_logo_url": "" + }, + { + "appid": 10150, + "name": "Prototype", + "playtime_forever_min": 0, + "img_icon_url": "c09d5f7b718a963b1f24162184c28d3293e8fd8c", + "img_logo_url": "" + }, + { + "appid": 12200, + "name": "Bully: Scholarship Edition", + "playtime_forever_min": 0, + "img_icon_url": "791f13dd4ea6c4cdf171670cc576682171c1eae5", + "img_logo_url": "" + }, + { + "appid": 12210, + "name": "Grand Theft Auto IV: The Complete Edition", + "playtime_forever_min": 1684, + "img_icon_url": "a3cf6a64c73f991898a9e34681d0db8226eaa191", + "img_logo_url": "" + }, + { + "appid": 13500, + "name": "Prince of Persia: Warrior Within", + "playtime_forever_min": 32, + "img_icon_url": "7ccfe3fae3c5fc44dad734dbd0af10f041fe94e7", + "img_logo_url": "" + }, + { + "appid": 13530, + "name": "Prince of Persia: The Two Thrones", + "playtime_forever_min": 0, + "img_icon_url": "8ceeaa9e2d1b0eb804b1826e95e5c268703f6c37", + "img_logo_url": "" + }, + { + "appid": 15100, + "name": "Assassin's Creed", + "playtime_forever_min": 0, + "img_icon_url": "cd8f7a795e34e16449f7ad8d8190dce521967917", + "img_logo_url": "" + }, + { + "appid": 19980, + "name": "Prince of Persia", + "playtime_forever_min": 0, + "img_icon_url": "2b404bd60fb97a9c1eabb9d4a573909c014a23fd", + "img_logo_url": "" + }, + { + "appid": 20500, + "name": "Red Faction: Guerrilla Steam Edition", + "playtime_forever_min": 211, + "img_icon_url": "e7f356bc74c871f6955c9caec5d38a8d56f49490", + "img_logo_url": "" + }, + { + "appid": 20510, + "name": "S.T.A.L.K.E.R.: Clear Sky", + "playtime_forever_min": 918, + "img_icon_url": "82713e11a93dcbdbb0e1158b2e289720000bed3c", + "img_logo_url": "" + }, + { + "appid": 20920, + "name": "The Witcher 2: Assassins of Kings Enhanced Edition", + "playtime_forever_min": 0, + "img_icon_url": "62dd5c627664df1bcabc47727c7dcd7ccab353e9", + "img_logo_url": "" + }, + { + "appid": 24240, + "name": "PAYDAY: The Heist", + "playtime_forever_min": 147, + "img_icon_url": "a07ceea8808f1a2104ed1c864756f263ec67df49", + "img_logo_url": "" + }, + { + "appid": 24880, + "name": "The Saboteur™", + "playtime_forever_min": 0, + "img_icon_url": "fd29235b1565127a71fd1065a4347d4ea780f1fc", + "img_logo_url": "" + }, + { + "appid": 28000, + "name": "Kane & Lynch 2: Dog Days", + "playtime_forever_min": 10, + "img_icon_url": "e82911b452dd182875dc83cd03767de44f68856c", + "img_logo_url": "" + }, + { + "appid": 29800, + "name": "Caster", + "playtime_forever_min": 88, + "img_icon_url": "31528f4c11a59d2c904109a6ad5e3764f782108c", + "img_logo_url": "" + }, + { + "appid": 33230, + "name": "Assassin's Creed II", + "playtime_forever_min": 0, + "img_icon_url": "0492d8ee860ac99168e46efeb003029c3224cb38", + "img_logo_url": "" + }, + { + "appid": 33320, + "name": "Prince of Persia: The Forgotten Sands", + "playtime_forever_min": 0, + "img_icon_url": "ae71d05f6a30478dc896f58bff7b353c0edf9959", + "img_logo_url": "" + }, + { + "appid": 35140, + "name": "Batman: Arkham Asylum GOTY Edition", + "playtime_forever_min": 0, + "img_icon_url": "e52f91ecb0d3f20263e96fe188de1bcc8c91643e", + "img_logo_url": "" + }, + { + "appid": 35420, + "name": "Killing Floor Mod: Defence Alliance 2", + "playtime_forever_min": 6, + "img_icon_url": "ae7580a60cf77b754c723c72d5e31d530fbe7804", + "img_logo_url": "" + }, + { + "appid": 35700, + "name": "Trine", + "playtime_forever_min": 0, + "img_icon_url": "298185d8ef9d688fe96a5fa15ee8174892f1c000", + "img_logo_url": "" + }, + { + "appid": 35720, + "name": "Trine 2", + "playtime_forever_min": 0, + "img_icon_url": "061ecbbd7c70ae1c052377bad136c7759cbb708d", + "img_logo_url": "" + }, + { + "appid": 40800, + "name": "Super Meat Boy", + "playtime_forever_min": 66, + "img_icon_url": "64eec20c9375e7473b964f0d0bc41d19f03add3b", + "img_logo_url": "" + }, + { + "appid": 41700, + "name": "S.T.A.L.K.E.R.: Call of Pripyat", + "playtime_forever_min": 768, + "img_icon_url": "60b32d190ebcaac8d2b2aaf16db2b07015616eb2", + "img_logo_url": "" + }, + { + "appid": 47810, + "name": "Dragon Age: Origins - Ultimate Edition", + "playtime_forever_min": 0, + "img_icon_url": "e5e0b9629b314f7338f493d26227eb2bd1172e77", + "img_logo_url": "" + }, + { + "appid": 48190, + "name": "Assassin's Creed Brotherhood", + "playtime_forever_min": 0, + "img_icon_url": "6a87d0b61e45e8075decd1f8ace549300b036a52", + "img_logo_url": "" + }, + { + "appid": 50130, + "name": "Mafia II (Classic)", + "playtime_forever_min": 0, + "img_icon_url": "62f1f7324e520be067aec1ab06d1ec6fa56f25bd", + "img_logo_url": "" + }, + { + "appid": 55230, + "name": "Saints Row: The Third", + "playtime_forever_min": 0, + "img_icon_url": "ec83645f13643999e7c91da75d418053d6b56529", + "img_logo_url": "" + }, + { + "appid": 96100, + "name": "Defy Gravity", + "playtime_forever_min": 128, + "img_icon_url": "fa26f1090915f4248bdabe9c99e3d51113fb6e3f", + "img_logo_url": "" + }, + { + "appid": 102600, + "name": "Orcs Must Die!", + "playtime_forever_min": 144, + "img_icon_url": "0d14a70abb580146741de26bbad46d0ad81a67c7", + "img_logo_url": "" + }, + { + "appid": 104200, + "name": "BEEP", + "playtime_forever_min": 0, + "img_icon_url": "a08809b6b78b2adcc347ad406d4deb2946761e4b", + "img_logo_url": "" + }, + { + "appid": 105600, + "name": "Terraria", + "playtime_forever_min": 33, + "img_icon_url": "858961e95fbf869f136e1770d586e0caefd4cfac", + "img_logo_url": "" + }, + { + "appid": 108600, + "name": "Project Zomboid", + "playtime_forever_min": 0, + "img_icon_url": "2bd4642ae337e378e7b04a19d19683425c5f81a4", + "img_logo_url": "" + }, + { + "appid": 110800, + "name": "L.A. Noire", + "playtime_forever_min": 0, + "img_icon_url": "ee09d4d2552e52c78d139a3b6dbe60173079d9f8", + "img_logo_url": "" + }, + { + "appid": 113200, + "name": "The Binding of Isaac", + "playtime_forever_min": 0, + "img_icon_url": "383cf045ca20625db18f68ef5e95169012118b9e", + "img_logo_url": "" + }, + { + "appid": 200260, + "name": "Batman: Arkham City GOTY", + "playtime_forever_min": 76, + "img_icon_url": "746ecf3ce44b2525eb7ad643e76a3b60913d2662", + "img_logo_url": "" + }, + { + "appid": 201870, + "name": "Assassin's Creed Revelations", + "playtime_forever_min": 0, + "img_icon_url": "08f7266496718dbd5d2f93e4776d3f5dc368ad54", + "img_logo_url": "" + }, + { + "appid": 204100, + "name": "Max Payne 3", + "playtime_forever_min": 0, + "img_icon_url": "96af86331719b56cefc55298b4fcb99c99f1cfee", + "img_logo_url": "" + }, + { + "appid": 204180, + "name": "Waveform", + "playtime_forever_min": 30, + "img_icon_url": "a4637179003554caab19db1e35ca684f6137a01d", + "img_logo_url": "" + }, + { + "appid": 204360, + "name": "Castle Crashers", + "playtime_forever_min": 32, + "img_icon_url": "9b7625f9b70f103397fd0416fd92abb583db8659", + "img_logo_url": "" + }, + { + "appid": 204450, + "name": "Call of Juarez Gunslinger", + "playtime_forever_min": 0, + "img_icon_url": "b9a00d132852719cd6e6c2be3f3d0fdf669ed7ce", + "img_logo_url": "" + }, + { + "appid": 206420, + "name": "Saints Row IV", + "playtime_forever_min": 0, + "img_icon_url": "211c502e385781a17b4d02cad7a3ef53f09f1e91", + "img_logo_url": "" + }, + { + "appid": 208090, + "name": "Loadout", + "playtime_forever_min": 102, + "img_icon_url": "d4dc7a4f71d4ba7c44cd05b5c235e0a387ca212b", + "img_logo_url": "" + }, + { + "appid": 208650, + "name": "Batman™: Arkham Knight", + "playtime_forever_min": 0, + "img_icon_url": "f6c2ce13796844750dfbd01685fb009eeac4bf70", + "img_logo_url": "" + }, + { + "appid": 209000, + "name": "Batman™: Arkham Origins", + "playtime_forever_min": 0, + "img_icon_url": "76dac70a2206de1a80da4950da43e1b05ea302a8", + "img_logo_url": "" + }, + { + "appid": 218620, + "name": "PAYDAY 2", + "playtime_forever_min": 4724, + "img_icon_url": "a6abc0d0c1e79c0b5b0f5c8ab81ce9076a542414", + "img_logo_url": "" + }, + { + "appid": 219150, + "name": "Hotline Miami", + "playtime_forever_min": 1249, + "img_icon_url": "493c0a3e823c60c14f91204526f26cc064ed7c84", + "img_logo_url": "" + }, + { + "appid": 219990, + "name": "Grim Dawn", + "playtime_forever_min": 0, + "img_icon_url": "762057f2b14463ae1cbf0701a4cdb25cf94e8a0c", + "img_logo_url": "" + }, + { + "appid": 220240, + "name": "Far Cry® 3", + "playtime_forever_min": 880, + "img_icon_url": "8231f43d3dd47fb4b4111fd6bd4a849e73eb0520", + "img_logo_url": "" + }, + { + "appid": 222900, + "name": "Dead Island: Epidemic", + "playtime_forever_min": 129, + "img_icon_url": "eeca961216745840443d87373f907d1e6e4ca764", + "img_logo_url": "" + }, + { + "appid": 224260, + "name": "No More Room in Hell", + "playtime_forever_min": 37, + "img_icon_url": "684de0d9c5749b5ddd52f120894fd97efd620b1d", + "img_logo_url": "" + }, + { + "appid": 225540, + "name": "Just Cause 3", + "playtime_forever_min": 0, + "img_icon_url": "c82c69c8d616d9d273d749e3dd3bd7a0f9da594a", + "img_logo_url": "" + }, + { + "appid": 226320, + "name": "Marvel Heroes Omega", + "playtime_forever_min": 1601, + "img_icon_url": "f9e8431600ea2ac6b15d81bd6c38e63c13ba2bbd", + "img_logo_url": "" + }, + { + "appid": 227300, + "name": "Euro Truck Simulator 2", + "playtime_forever_min": 3617, + "img_icon_url": "adc18a4fc9adc0330144b76d61cbda68bb2394a0", + "img_logo_url": "" + }, + { + "appid": 230230, + "name": "Divinity: Original Sin (Classic)", + "playtime_forever_min": 0, + "img_icon_url": "ca9cdafd4257253dba68babeed300874190fbbff", + "img_logo_url": "" + }, + { + "appid": 232090, + "name": "Killing Floor 2", + "playtime_forever_min": 81, + "img_icon_url": "15837cefb378766e9916548f8591b6eb490b9e52", + "img_logo_url": "" + }, + { + "appid": 233270, + "name": "Far Cry® 3 Blood Dragon", + "playtime_forever_min": 14, + "img_icon_url": "37ea239abeef5c5b2a90a2f821aaab745633972a", + "img_logo_url": "" + }, + { + "appid": 233450, + "name": "Prison Architect", + "playtime_forever_min": 0, + "img_icon_url": "c166c7911beec4d63a74cdddf25f26b73c84556b", + "img_logo_url": "" + }, + { + "appid": 233860, + "name": "Kenshi", + "playtime_forever_min": 0, + "img_icon_url": "9e8fa3ef841ba1861b2b8aa1254532bb4eee954b", + "img_logo_url": "" + }, + { + "appid": 241930, + "name": "Middle-earth™: Shadow of Mordor™", + "playtime_forever_min": 49, + "img_icon_url": "161afab3c9f725f593635723cc64a7fbeb324ead", + "img_logo_url": "" + }, + { + "appid": 242050, + "name": "Assassin's Creed IV Black Flag", + "playtime_forever_min": 0, + "img_icon_url": "a03c06290af00eed66814815663b68f5fd063882", + "img_logo_url": "" + }, + { + "appid": 242760, + "name": "The Forest", + "playtime_forever_min": 0, + "img_icon_url": "3a6847f6ac5879e48531db52261771d5e22904ac", + "img_logo_url": "" + }, + { + "appid": 243470, + "name": "Watch_Dogs", + "playtime_forever_min": 1323, + "img_icon_url": "53368e59a196dfa9af66ecd32135939da97fa72e", + "img_logo_url": "" + }, + { + "appid": 250400, + "name": "How to Survive", + "playtime_forever_min": 538, + "img_icon_url": "2c493ce76970c4d70183c17b2e44b0099f0c56d5", + "img_logo_url": "" + }, + { + "appid": 250900, + "name": "The Binding of Isaac: Rebirth", + "playtime_forever_min": 0, + "img_icon_url": "16d46c8630499bfc54d20745ac90786a302cd643", + "img_logo_url": "" + }, + { + "appid": 255710, + "name": "Cities: Skylines", + "playtime_forever_min": 890, + "img_icon_url": "6cf7b10dd29db28448ef79698ed2118a03617d63", + "img_logo_url": "" + }, + { + "appid": 261550, + "name": "Mount & Blade II: Bannerlord", + "playtime_forever_min": 1446, + "img_icon_url": "896d5f4214e4e185a20af51b46d9dea2e6f4aaad", + "img_logo_url": "" + }, + { + "appid": 263980, + "name": "Out There Somewhere", + "playtime_forever_min": 0, + "img_icon_url": "9ef5b78baca02bd93df4050525de055b4b5c0e3a", + "img_logo_url": "" + }, + { + "appid": 268500, + "name": "XCOM 2", + "playtime_forever_min": 2343, + "img_icon_url": "f275aeb0b1b947262810569356a199848c643754", + "img_logo_url": "" + }, + { + "appid": 270550, + "name": "Yet Another Zombie Defense", + "playtime_forever_min": 72, + "img_icon_url": "d9414db164fa12a1e2cb83a60056dccc77ea3f38", + "img_logo_url": "" + }, + { + "appid": 270880, + "name": "American Truck Simulator", + "playtime_forever_min": 0, + "img_icon_url": "2aec8af4b4b23cb6054d9aea06194621cdd66945", + "img_logo_url": "" + }, + { + "appid": 271590, + "name": "Grand Theft Auto V Legacy", + "playtime_forever_min": 2473, + "img_icon_url": "1e72f87eb927fa1485e68aefaff23c7fd7178251", + "img_logo_url": "" + }, + { + "appid": 274170, + "name": "Hotline Miami 2: Wrong Number", + "playtime_forever_min": 570, + "img_icon_url": "3fc78b615529f78cc24cea247c7b2ee50c551bbd", + "img_logo_url": "" + }, + { + "appid": 286690, + "name": "Metro 2033 Redux", + "playtime_forever_min": 0, + "img_icon_url": "353ea01a045c084215bca95519808eaa7319ce0c", + "img_logo_url": "" + }, + { + "appid": 287450, + "name": "Rise of Nations: Extended Edition", + "playtime_forever_min": 696, + "img_icon_url": "0e142a02add319e32447f957a30fd1d360e62d3e", + "img_logo_url": "" + }, + { + "appid": 287700, + "name": "METAL GEAR SOLID V: THE PHANTOM PAIN", + "playtime_forever_min": 0, + "img_icon_url": "7a1737163c96ea641143db45709a4ac444ba8f7b", + "img_logo_url": "" + }, + { + "appid": 289070, + "name": "Sid Meier's Civilization VI", + "playtime_forever_min": 3752, + "img_icon_url": "9dc914132fec244adcede62fb8e7524a72a7398c", + "img_logo_url": "" + }, + { + "appid": 289130, + "name": "ENDLESS™ Legend", + "playtime_forever_min": 4, + "img_icon_url": "02277be6b9b09671b857948006316057974d7036", + "img_logo_url": "" + }, + { + "appid": 289650, + "name": "Assassin's Creed Unity", + "playtime_forever_min": 0, + "img_icon_url": "0629ffe7fc7d88011c5b9705625bfe3791fe0afe", + "img_logo_url": "" + }, + { + "appid": 291010, + "name": "The Hat Man: Shadow Ward", + "playtime_forever_min": 13, + "img_icon_url": "8a9fd7a24e99762464daa999cde440cca5373d2c", + "img_logo_url": "" + }, + { + "appid": 292030, + "name": "The Witcher 3: Wild Hunt", + "playtime_forever_min": 0, + "img_icon_url": "78d0ff98b67851f24539cdf2402cf147679134f4", + "img_logo_url": "" + }, + { + "appid": 292620, + "name": "Pressured", + "playtime_forever_min": 31, + "img_icon_url": "53d99d13ef00dcd83fd9666379076aa290923f63", + "img_logo_url": "" + }, + { + "appid": 294100, + "name": "RimWorld", + "playtime_forever_min": 0, + "img_icon_url": "addbe3b704b267060b4d5d7649cfb292de61bd70", + "img_logo_url": "" + }, + { + "appid": 298110, + "name": "Far Cry 4", + "playtime_forever_min": 686, + "img_icon_url": "be1366a047a515ed2275aa500a36a50de587b7a7", + "img_logo_url": "" + }, + { + "appid": 301910, + "name": "Saints Row: Gat out of Hell", + "playtime_forever_min": 0, + "img_icon_url": "7e817d02a9e0a52cdc171a006a8f0a48225456ea", + "img_logo_url": "" + }, + { + "appid": 305620, + "name": "The Long Dark", + "playtime_forever_min": 0, + "img_icon_url": "e26b78087b75d5e002e92f2bdd73ce6fc4861e56", + "img_logo_url": "" + }, + { + "appid": 307690, + "name": "Sleeping Dogs: Definitive Edition", + "playtime_forever_min": 0, + "img_icon_url": "8ef5e84ffbd9a4b3fb28d308073bccb415fc25ab", + "img_logo_url": "" + }, + { + "appid": 307780, + "name": "Mortal Kombat X", + "playtime_forever_min": 333, + "img_icon_url": "f3137c23413b98a9091a217e16ccc5e477a2a88e", + "img_logo_url": "" + }, + { + "appid": 314020, + "name": "Morphopolis", + "playtime_forever_min": 82, + "img_icon_url": "e425a6c4512f5c35a6cb08ff855630889ef32b29", + "img_logo_url": "" + }, + { + "appid": 315430, + "name": "Polarity", + "playtime_forever_min": 27, + "img_icon_url": "65a5e1336816df79435ac5164644c62380c3f133", + "img_logo_url": "" + }, + { + "appid": 316030, + "name": "Alice in Wonderland", + "playtime_forever_min": 19, + "img_icon_url": "53860ea4348022e17e92801257f50007fc3a736d", + "img_logo_url": "" + }, + { + "appid": 318430, + "name": "Squishy the Suicidal Pig", + "playtime_forever_min": 260, + "img_icon_url": "32f01eb14852af74a09807dc7154f6e9ef459d77", + "img_logo_url": "" + }, + { + "appid": 319910, + "name": "Trine 3: The Artifacts of Power", + "playtime_forever_min": 0, + "img_icon_url": "15853f24d7576a83cd4d54532a2a93c2d8d6024d", + "img_logo_url": "" + }, + { + "appid": 322330, + "name": "Don't Starve Together", + "playtime_forever_min": 0, + "img_icon_url": "a80aa6cff8eebc1cbc18c367d9ab063e1553b0ee", + "img_logo_url": "" + }, + { + "appid": 330020, + "name": "Children of Morta", + "playtime_forever_min": 200, + "img_icon_url": "29e179c5c7ac3aa6d3fb15ca5b19fd1124af4e7b", + "img_logo_url": "" + }, + { + "appid": 330350, + "name": "Robotex", + "playtime_forever_min": 4, + "img_icon_url": "367cda832cbe1383afd3eb00bff109fff0db8e40", + "img_logo_url": "" + }, + { + "appid": 331470, + "name": "Everlasting Summer", + "playtime_forever_min": 1391, + "img_icon_url": "c238887d29f28382920884d89bb3fc0718070a19", + "img_logo_url": "" + }, + { + "appid": 333420, + "name": "Cossacks 3", + "playtime_forever_min": 731, + "img_icon_url": "7613db27bfc85fdd767782eb6e53ba19d8bd028d", + "img_logo_url": "" + }, + { + "appid": 339610, + "name": "FreeStyle 2: Street Basketball", + "playtime_forever_min": 3428, + "img_icon_url": "368f89b5ecc999f1956a98a9bdb86f4d6acb243d", + "img_logo_url": "" + }, + { + "appid": 345520, + "name": "Infinite Crisis™", + "playtime_forever_min": 21, + "img_icon_url": "afc10c6aa4476db6d0d7fc3b6ccbeba33f5c0b3a", + "img_logo_url": "" + }, + { + "appid": 346900, + "name": "AdVenture Capitalist", + "playtime_forever_min": 313, + "img_icon_url": "b4dd5ca1582ed52335b31960e05766fd22fa7cc4", + "img_logo_url": "" + }, + { + "appid": 351920, + "name": "Crazy Machines 3", + "playtime_forever_min": 0, + "img_icon_url": "6cf6da3763360bbc86b1b55b10ddbb7e8f363d81", + "img_logo_url": "" + }, + { + "appid": 356190, + "name": "Middle-earth™: Shadow of War™", + "playtime_forever_min": 0, + "img_icon_url": "316c479e5d51d21e15eb8f82338fce82c42c4115", + "img_logo_url": "" + }, + { + "appid": 360430, + "name": "Mafia III: Definitive Edition", + "playtime_forever_min": 0, + "img_icon_url": "3df3ee0dfcb254bedbf15822c93a6983f67b0822", + "img_logo_url": "" + }, + { + "appid": 361420, + "name": "ASTRONEER", + "playtime_forever_min": 0, + "img_icon_url": "18ef632a152a69a8fb4f62c074cd50aa15d3bf33", + "img_logo_url": "" + }, + { + "appid": 362890, + "name": "Black Mesa", + "playtime_forever_min": 0, + "img_icon_url": "51182b6a74035eb913b353d5d60e596c3804daf6", + "img_logo_url": "" + }, + { + "appid": 363970, + "name": "Clicker Heroes", + "playtime_forever_min": 87, + "img_icon_url": "155ed2de95eacc3353b91c2a057bdbf727c11c20", + "img_logo_url": "" + }, + { + "appid": 368500, + "name": "Assassin's Creed Syndicate", + "playtime_forever_min": 0, + "img_icon_url": "4f67f650e621319029fa884755dbd89a2d7076f6", + "img_logo_url": "" + }, + { + "appid": 371660, + "name": "Far Cry Primal", + "playtime_forever_min": 1031, + "img_icon_url": "5b8109f8e71bc8b5c295d6972b841ffb92702ef0", + "img_logo_url": "" + }, + { + "appid": 373420, + "name": "Divinity: Original Sin Enhanced Edition", + "playtime_forever_min": 0, + "img_icon_url": "822ff912b2e464fbebbedf7a75ae1eb24921f16b", + "img_logo_url": "" + }, + { + "appid": 374320, + "name": "DARK SOULS™ III", + "playtime_forever_min": 271, + "img_icon_url": "7abe1a33129c20cf10d2c74128bbd657a2a2c806", + "img_logo_url": "" + }, + { + "appid": 377160, + "name": "Fallout 4", + "playtime_forever_min": 0, + "img_icon_url": "779c4356ebe32af2af7c9f0bbba595dfe872cd7f", + "img_logo_url": "" + }, + { + "appid": 379430, + "name": "Kingdom Come: Deliverance", + "playtime_forever_min": 2164, + "img_icon_url": "915ec515c36645855abed0476b5b0891d8a4e47a", + "img_logo_url": "" + }, + { + "appid": 379720, + "name": "DOOM", + "playtime_forever_min": 655, + "img_icon_url": "b6e72ff47d1990cb644700751eeeff14e0aba6dc", + "img_logo_url": "" + }, + { + "appid": 386360, + "name": "SMITE", + "playtime_forever_min": 24274, + "img_icon_url": "7ed9de7bbfab9accb81e47b84943e7478baf2f3a", + "img_logo_url": "" + }, + { + "appid": 391220, + "name": "Rise of the Tomb Raider", + "playtime_forever_min": 0, + "img_icon_url": "0b8a37f32ed2b7c934be8aa94d53f71e274c6497", + "img_logo_url": "" + }, + { + "appid": 393380, + "name": "Squad", + "playtime_forever_min": 0, + "img_icon_url": "eb66a8cb52165274c84cae6526205065d13119d5", + "img_logo_url": "" + }, + { + "appid": 394360, + "name": "Hearts of Iron IV", + "playtime_forever_min": 0, + "img_icon_url": "67b15e2336311b4b4f1eb5c802af0c0dbe644195", + "img_logo_url": "" + }, + { + "appid": 409710, + "name": "BioShock Remastered", + "playtime_forever_min": 0, + "img_icon_url": "eb72262cd3ccc3219dd76392be3b60a4b6cbfd38", + "img_logo_url": "" + }, + { + "appid": 409720, + "name": "BioShock 2 Remastered", + "playtime_forever_min": 0, + "img_icon_url": "97527a02b36f8ac4aba21005c2d953cc908a08e1", + "img_logo_url": "" + }, + { + "appid": 412020, + "name": "Metro Exodus", + "playtime_forever_min": 0, + "img_icon_url": "f9863ed0ffc717b130744488f088ae95fe6e9bd8", + "img_logo_url": "" + }, + { + "appid": 414340, + "name": "Hellblade: Senua's Sacrifice", + "playtime_forever_min": 565, + "img_icon_url": "8a5d8f2d1cb52b21eaf4f6609592c6634731c962", + "img_logo_url": "" + }, + { + "appid": 435150, + "name": "Divinity: Original Sin 2", + "playtime_forever_min": 0, + "img_icon_url": "519a99caef7c5e2b4625c8c2fa0620fb66a752f3", + "img_logo_url": "" + }, + { + "appid": 447040, + "name": "Watch_Dogs 2", + "playtime_forever_min": 191, + "img_icon_url": "49dc5c3e4053e40159ffa73d2121daadbc467fe2", + "img_logo_url": "" + }, + { + "appid": 460950, + "name": "Katana ZERO", + "playtime_forever_min": 0, + "img_icon_url": "874ee87e03abd66ff158727c051ceb173e3cba3c", + "img_logo_url": "" + }, + { + "appid": 474960, + "name": "Quantum Break", + "playtime_forever_min": 0, + "img_icon_url": "68c50addc599af219e4e135a81733a867c2986c2", + "img_logo_url": "" + }, + { + "appid": 475150, + "name": "Titan Quest Anniversary Edition", + "playtime_forever_min": 1618, + "img_icon_url": "be3384e7936f548303c77ee2abc4c9026285f554", + "img_logo_url": "" + }, + { + "appid": 489830, + "name": "The Elder Scrolls V: Skyrim Special Edition", + "playtime_forever_min": 153, + "img_icon_url": "0dfe3eed5658f9fbd8b62f8021038c0a4190f21d", + "img_logo_url": "" + }, + { + "appid": 492720, + "name": "Tropico 6", + "playtime_forever_min": 643, + "img_icon_url": "11f6e0cc7514f7ef8f1687276718c6f95d3c3e50", + "img_logo_url": "" + }, + { + "appid": 526870, + "name": "Satisfactory", + "playtime_forever_min": 0, + "img_icon_url": "ee3406fe5ec813b1987ad67e37e5cd6fb4f620e6", + "img_logo_url": "" + }, + { + "appid": 529340, + "name": "Victoria 3", + "playtime_forever_min": 0, + "img_icon_url": "da7eddaef804905207eaf0ba9580b5026385f041", + "img_logo_url": "" + }, + { + "appid": 548430, + "name": "Deep Rock Galactic", + "playtime_forever_min": 0, + "img_icon_url": "e033e23c29a192a17c16a7645a2b423ac64ff447", + "img_logo_url": "" + }, + { + "appid": 551770, + "name": "ECHO", + "playtime_forever_min": 0, + "img_icon_url": "1a1502bdb54955388979ee39fb2821e79c0046c7", + "img_logo_url": "" + }, + { + "appid": 552520, + "name": "Far Cry 5", + "playtime_forever_min": 948, + "img_icon_url": "928df2d32021f480499a97458068f62c45298e7a", + "img_logo_url": "" + }, + { + "appid": 557340, + "name": "My Friend Pedro", + "playtime_forever_min": 239, + "img_icon_url": "734deb65d5561961595a190fa2d2adf61d607efe", + "img_logo_url": "" + }, + { + "appid": 559100, + "name": "Phantom Doctrine", + "playtime_forever_min": 86, + "img_icon_url": "042d9ec1fed7cfb3581d6e0a24c4c3738573130a", + "img_logo_url": "" + }, + { + "appid": 578080, + "name": "PUBG: BATTLEGROUNDS", + "playtime_forever_min": 138, + "img_icon_url": "609f27278aa70697c13bf99f32c5a0248c381f9d", + "img_logo_url": "" + }, + { + "appid": 582160, + "name": "Assassin's Creed Origins", + "playtime_forever_min": 0, + "img_icon_url": "dff3a53f93d39f1e0432ec4f22d31c12aeefa36f", + "img_logo_url": "" + }, + { + "appid": 582660, + "name": "Black Desert", + "playtime_forever_min": 0, + "img_icon_url": "bf5ccace0a692720984827bf042143d0d4b28a42", + "img_logo_url": "" + }, + { + "appid": 597180, + "name": "Old World", + "playtime_forever_min": 139, + "img_icon_url": "015feb0aa056dd763c0ead2b98a774719ca62ac0", + "img_logo_url": "" + }, + { + "appid": 601150, + "name": "Devil May Cry 5", + "playtime_forever_min": 0, + "img_icon_url": "7ec2f6fb9069c3ae03af7e83610862090cd757bb", + "img_logo_url": "" + }, + { + "appid": 606280, + "name": "Darksiders III", + "playtime_forever_min": 0, + "img_icon_url": "1f2b21f809d9a409460f23d12061c1b18f6d5a74", + "img_logo_url": "" + }, + { + "appid": 613100, + "name": "House Flipper", + "playtime_forever_min": 143, + "img_icon_url": "4ab6e7c49f3f0c063dfe08ab85038c979301f6dd", + "img_logo_url": "" + }, + { + "appid": 632470, + "name": "Disco Elysium", + "playtime_forever_min": 84, + "img_icon_url": "b681544caa931c7c1a6788e6e3e33cb42892d17c", + "img_logo_url": "" + }, + { + "appid": 667720, + "name": "Red Faction Guerrilla Re-Mars-tered", + "playtime_forever_min": 0, + "img_icon_url": "a98e571b7225e3acc06042ed97444e37e99cc4ad", + "img_logo_url": "" + }, + { + "appid": 690640, + "name": "Trine 4: The Nightmare Prince", + "playtime_forever_min": 0, + "img_icon_url": "afe71b7d8dd5ceb78d971d1996266c6307ec5fd0", + "img_logo_url": "" + }, + { + "appid": 728880, + "name": "Overcooked! 2", + "playtime_forever_min": 0, + "img_icon_url": "bac36f471109038ae0b40619de4109d475f86a54", + "img_logo_url": "" + }, + { + "appid": 747350, + "name": "Hellblade: Senua's Sacrifice VR Edition", + "playtime_forever_min": 0, + "img_icon_url": "8a5d8f2d1cb52b21eaf4f6609592c6634731c962", + "img_logo_url": "" + }, + { + "appid": 774941, + "name": "Squad - Public Testing", + "playtime_forever_min": 0, + "img_icon_url": "6a9ee44c92c5ca7ddacf0d56bb7d5720961da0f8", + "img_logo_url": "" + }, + { + "appid": 782330, + "name": "DOOM Eternal", + "playtime_forever_min": 0, + "img_icon_url": "14235f425d27ab3b83661ba14c17d22dc90e566b", + "img_logo_url": "" + }, + { + "appid": 812140, + "name": "Assassin's Creed Odyssey", + "playtime_forever_min": 0, + "img_icon_url": "4b6cf0715b30669411bf204bce7ed99a9c84671b", + "img_logo_url": "" + }, + { + "appid": 829660, + "name": "No Time to Relax", + "playtime_forever_min": 0, + "img_icon_url": "0ec88035a2161eb56476db70fe7593d4aed94425", + "img_logo_url": "" + }, + { + "appid": 870780, + "name": "Control Ultimate Edition", + "playtime_forever_min": 0, + "img_icon_url": "3438e3a03c82194349d66f99ee642ba59cbcc3dc", + "img_logo_url": "" + }, + { + "appid": 892970, + "name": "Valheim", + "playtime_forever_min": 0, + "img_icon_url": "2f64c9a826e2c6cf3253fea4834c2e612db09143", + "img_logo_url": "" + }, + { + "appid": 911400, + "name": "Assassin's Creed III Remastered", + "playtime_forever_min": 0, + "img_icon_url": "68e5640754e5ddf29aadff49062fa68cc8c8f4a5", + "img_logo_url": "" + }, + { + "appid": 916440, + "name": "Anno 1800", + "playtime_forever_min": 6387, + "img_icon_url": "cf7c754267856d5a1b36614855c52867daf72186", + "img_logo_url": "" + }, + { + "appid": 939960, + "name": "Far Cry New Dawn", + "playtime_forever_min": 643, + "img_icon_url": "4123e8e644fd7c9d576e405f76f4296869293deb", + "img_logo_url": "" + }, + { + "appid": 945360, + "name": "Among Us", + "playtime_forever_min": 81, + "img_icon_url": "b82c3f46da8f3c918e1c9e0d18bd6fa8fcef6801", + "img_logo_url": "" + }, + { + "appid": 960910, + "name": "Heavy Rain", + "playtime_forever_min": 0, + "img_icon_url": "ddc1b819a9c00d9642d290fc521e48d708af7329", + "img_logo_url": "" + }, + { + "appid": 973580, + "name": "Sniper Ghost Warrior Contracts", + "playtime_forever_min": 1102, + "img_icon_url": "3519b24cf69525f64e262d8791dff1cb650069e5", + "img_logo_url": "" + }, + { + "appid": 976310, + "name": "Mortal Kombat 11", + "playtime_forever_min": 0, + "img_icon_url": "315c46ce25bcb6afa47473588b5fd8edeb3afdfa", + "img_logo_url": "" + }, + { + "appid": 978300, + "name": "Saints Row The Third Remastered", + "playtime_forever_min": 0, + "img_icon_url": "115fd158b792354987fb64f7cbcceadfa2f43b8b", + "img_logo_url": "" + }, + { + "appid": 990080, + "name": "Hogwarts Legacy", + "playtime_forever_min": 2130, + "img_icon_url": "a9ecb94f249768d0ee5ccecbffe8d8c06d9bed59", + "img_logo_url": "" + }, + { + "appid": 1029690, + "name": "Sniper Elite 5", + "playtime_forever_min": 0, + "img_icon_url": "00bce3b25aed4dfd96e864e03dd315448dcb834b", + "img_logo_url": "" + }, + { + "appid": 1030830, + "name": "Mafia II: Definitive Edition", + "playtime_forever_min": 2, + "img_icon_url": "f80e173ad5c242ca0d41e30af1f6358d1e64d9b4", + "img_logo_url": "" + }, + { + "appid": 1030840, + "name": "Mafia: Definitive Edition", + "playtime_forever_min": 480, + "img_icon_url": "883ceebf7d8ebce37c9244a62777ef4a800393ce", + "img_logo_url": "" + }, + { + "appid": 1044720, + "name": "Farthest Frontier", + "playtime_forever_min": 1059, + "img_icon_url": "8cc098a18d3e8b12529e5653fc075af66a7f7155", + "img_logo_url": "" + }, + { + "appid": 1070330, + "name": "Russian Life Simulator", + "playtime_forever_min": 164, + "img_icon_url": "41e1a7721ee76c3beab84b01f8fe8c83c9802a93", + "img_logo_url": "" + }, + { + "appid": 1086940, + "name": "Baldur's Gate 3", + "playtime_forever_min": 5742, + "img_icon_url": "d866cae7ea1e471fdbc206287111f1b642373bd9", + "img_logo_url": "" + }, + { + "appid": 1088850, + "name": "Marvel's Guardians of the Galaxy", + "playtime_forever_min": 0, + "img_icon_url": "8aec558b5e9c3aa185869d438f78200a2d4bf885", + "img_logo_url": "" + }, + { + "appid": 1091500, + "name": "Cyberpunk 2077", + "playtime_forever_min": 5057, + "img_icon_url": "42b9b33fa0f0d997beb299c6157592a8fe7d8f68", + "img_logo_url": "" + }, + { + "appid": 1092790, + "name": "Inscryption", + "playtime_forever_min": 0, + "img_icon_url": "25c02dcbabe97febe787797195099325a4d47935", + "img_logo_url": "" + }, + { + "appid": 1097150, + "name": "Fall Guys", + "playtime_forever_min": 112, + "img_icon_url": "6c027709b9eb1417c993bc7d513b97b232002368", + "img_logo_url": "" + }, + { + "appid": 1097840, + "name": "Gears 5", + "playtime_forever_min": 0, + "img_icon_url": "df954557a7cbb1ae3ef632871025991082ed362f", + "img_logo_url": "" + }, + { + "appid": 1123770, + "name": "Curse of the Dead Gods", + "playtime_forever_min": 0, + "img_icon_url": "74439a263f77dc8e5d6dff12da3458b46ce2726b", + "img_logo_url": "" + }, + { + "appid": 1124300, + "name": "HUMANKIND™", + "playtime_forever_min": 877, + "img_icon_url": "c2d90169b56bc798213b8bcf949d3d5924a2d0a0", + "img_logo_url": "" + }, + { + "appid": 1139900, + "name": "Ghostrunner", + "playtime_forever_min": 1335, + "img_icon_url": "ed987f3c8a114549fca26e0c2e9bbc62a9691d57", + "img_logo_url": "" + }, + { + "appid": 1145360, + "name": "Hades", + "playtime_forever_min": 1014, + "img_icon_url": "8a3fca36a00883e8066263ad35dd15d77a1f9abc", + "img_logo_url": "" + }, + { + "appid": 1151640, + "name": "Horizon Zero Dawn™ Complete Edition", + "playtime_forever_min": 0, + "img_icon_url": "08a1b6df68bdc8c28c974b79325ed8c89417af48", + "img_logo_url": "" + }, + { + "appid": 1158310, + "name": "Crusader Kings III", + "playtime_forever_min": 4503, + "img_icon_url": "8a0d88dfaff790ea1aa2b9fcf50d4e3b4f49cf56", + "img_logo_url": "" + }, + { + "appid": 1172380, + "name": "STAR WARS Jedi: Fallen Order™ ", + "playtime_forever_min": 1186, + "img_icon_url": "0ea1d285a8ee6fbeef0e8f7f3b2d7fa4cbcb423b", + "img_logo_url": "" + }, + { + "appid": 1174180, + "name": "Red Dead Redemption 2", + "playtime_forever_min": 2811, + "img_icon_url": "5106abd9c1187a97f23295a0ba9470c94804ec6c", + "img_logo_url": "" + }, + { + "appid": 1210320, + "name": "Potion Craft: Alchemist Simulator", + "playtime_forever_min": 1178, + "img_icon_url": "e0a089daf3bfb668d42375e2e3d1f9c1184bb80f", + "img_logo_url": "" + }, + { + "appid": 1222140, + "name": "Detroit: Become Human", + "playtime_forever_min": 0, + "img_icon_url": "3d9f5bc971d44398aef740c0d8e79a358be2e800", + "img_logo_url": "" + }, + { + "appid": 1222670, + "name": "The Sims™ 4", + "playtime_forever_min": 19, + "img_icon_url": "ca6bc8b2411bce4a2cd325ab75f0204bc3a4ad98", + "img_logo_url": "" + }, + { + "appid": 1222680, + "name": "Need for Speed™ Heat ", + "playtime_forever_min": 1084, + "img_icon_url": "98a96955403db8eeed026bc5ff04e51e3329f1b0", + "img_logo_url": "" + }, + { + "appid": 1222700, + "name": "A Way Out", + "playtime_forever_min": 0, + "img_icon_url": "ac36b7d06c117d34c204166c3120e15910ef2b8b", + "img_logo_url": "" + }, + { + "appid": 1232130, + "name": "BEAR, VODKA, STALINGRAD! 🐻", + "playtime_forever_min": 10, + "img_icon_url": "945316799a912f0b53214c956a76b8ca38755541", + "img_logo_url": "" + }, + { + "appid": 1233570, + "name": "Mirror's Edge™ Catalyst", + "playtime_forever_min": 0, + "img_icon_url": "a5583fa5e07defaeb3a3ade0b4936767265dd660", + "img_logo_url": "" + }, + { + "appid": 1237950, + "name": "STAR WARS™ Battlefront™ II", + "playtime_forever_min": 0, + "img_icon_url": "dbdd20f60579c3695427801a7b8d9844cf3e428b", + "img_logo_url": "" + }, + { + "appid": 1238810, + "name": "Battlefield™ V", + "playtime_forever_min": 0, + "img_icon_url": "23ded6a957b5ec7525679c2e7bdac90c0653dbc8", + "img_logo_url": "" + }, + { + "appid": 1239690, + "name": "Retrowave", + "playtime_forever_min": 87, + "img_icon_url": "43f7a3f64c81e229b53fd3fdb7ee151faea0eff1", + "img_logo_url": "" + }, + { + "appid": 1259420, + "name": "Days Gone", + "playtime_forever_min": 0, + "img_icon_url": "839338fcedd1033ab7f0d2a31c194fb880ebf7b5", + "img_logo_url": "" + }, + { + "appid": 1272080, + "name": "PAYDAY 3", + "playtime_forever_min": 0, + "img_icon_url": "d4ad48893646cae61934ea10749527346f8b7736", + "img_logo_url": "" + }, + { + "appid": 1316680, + "name": "VLADiK BRUTAL", + "playtime_forever_min": 271, + "img_icon_url": "0bb8db4a473947acbdbe1c8814ff338efd053a31", + "img_logo_url": "" + }, + { + "appid": 1328670, + "name": "Mass Effect™ Legendary Edition", + "playtime_forever_min": 0, + "img_icon_url": "443ed7dba43882ef4298f8ed15bf67f323461e14", + "img_logo_url": "" + }, + { + "appid": 1332010, + "name": "Stray", + "playtime_forever_min": 366, + "img_icon_url": "7794cc0b4e70bd3e4b9a0d07e2570ab8668c5f0b", + "img_logo_url": "" + }, + { + "appid": 1336490, + "name": "Against the Storm", + "playtime_forever_min": 0, + "img_icon_url": "f26712271f7b0554cc1c6fb0267f122c47cf6e85", + "img_logo_url": "" + }, + { + "appid": 1338770, + "name": "Sniper Ghost Warrior Contracts 2", + "playtime_forever_min": 0, + "img_icon_url": "6bdcfa89cd9d556ead596849b17683ee047a926c", + "img_logo_url": "" + }, + { + "appid": 1363080, + "name": "Manor Lords", + "playtime_forever_min": 263, + "img_icon_url": "f0b869c004ddb564f961668130894f335fe04175", + "img_logo_url": "" + }, + { + "appid": 1426210, + "name": "It Takes Two", + "playtime_forever_min": 1890, + "img_icon_url": "6b15f6d81c4fc52056f4928b363b7fac591a945b", + "img_logo_url": "" + }, + { + "appid": 1436700, + "name": "Trine 5: A Clockwork Conspiracy", + "playtime_forever_min": 0, + "img_icon_url": "3ef869db216460baed5dcd9caedc3d86510db597", + "img_logo_url": "" + }, + { + "appid": 1449560, + "name": "Metro Exodus Enhanced Edition", + "playtime_forever_min": 0, + "img_icon_url": "f9863ed0ffc717b130744488f088ae95fe6e9bd8", + "img_logo_url": "" + }, + { + "appid": 1466860, + "name": "Age of Empires IV: Anniversary Edition", + "playtime_forever_min": 306, + "img_icon_url": "503acf3ef0503e24af6b5336012a1f585a2f955e", + "img_logo_url": "" + }, + { + "appid": 1510580, + "name": "Toy Tinker Simulator", + "playtime_forever_min": 0, + "img_icon_url": "7a7c69259b7d1f622d8f7c0d34f93a85683c8fa4", + "img_logo_url": "" + }, + { + "appid": 1546970, + "name": "Grand Theft Auto III - The Definitive Edition", + "playtime_forever_min": 2214, + "img_icon_url": "34da0ad18d5dd3507e137195038ecd4b45ec08b3", + "img_logo_url": "" + }, + { + "appid": 1546990, + "name": "Grand Theft Auto: Vice City - The Definitive Edition", + "playtime_forever_min": 2176, + "img_icon_url": "cdbe042499edb7c1ca10ae17077a01ef2d4ea116", + "img_logo_url": "" + }, + { + "appid": 1547000, + "name": "Grand Theft Auto: San Andreas - The Definitive Edition", + "playtime_forever_min": 4008, + "img_icon_url": "44e14013432397d8c710ffcafcb77c259f3b4f27", + "img_logo_url": "" + }, + { + "appid": 1568590, + "name": "Goose Goose Duck", + "playtime_forever_min": 599, + "img_icon_url": "3b802d5010d6d5d72c93a8565b260c6042cc9390", + "img_logo_url": "" + }, + { + "appid": 1593500, + "name": "God of War", + "playtime_forever_min": 0, + "img_icon_url": "8aa5e7e0e55cad682c7f2e65e075c3bc04dfc1cb", + "img_logo_url": "" + }, + { + "appid": 1659040, + "name": "HITMAN World of Assassination", + "playtime_forever_min": 0, + "img_icon_url": "552be1d38866afd1c33f682323d325130e7d0ce6", + "img_logo_url": "" + }, + { + "appid": 1665460, + "name": "eFootball™", + "playtime_forever_min": 13, + "img_icon_url": "bde1bd66adae3f4e72e67e0687321f0038153ce3", + "img_logo_url": "" + }, + { + "appid": 1715130, + "name": "Crysis Remastered", + "playtime_forever_min": 647, + "img_icon_url": "c94e91cdf9ec7a73fde8090d96a8674386a1a0e3", + "img_logo_url": "" + }, + { + "appid": 1771300, + "name": "Kingdom Come: Deliverance II", + "playtime_forever_min": 0, + "img_icon_url": "80e8d75b87433627cafd2a1bcbb5b9f5741e2277", + "img_logo_url": "" + }, + { + "appid": 1774580, + "name": "STAR WARS Jedi: Survivor™", + "playtime_forever_min": 1666, + "img_icon_url": "25578ffb2c6070c5d4e6569624efaadb8a0f5538", + "img_logo_url": "" + }, + { + "appid": 1794680, + "name": "Vampire Survivors", + "playtime_forever_min": 0, + "img_icon_url": "3677cf1be3be1f4ea42261c62ce10519715ade58", + "img_logo_url": "" + }, + { + "appid": 1797610, + "name": "Barro 22", + "playtime_forever_min": 0, + "img_icon_url": "ab6f762cb4a3a63c26e0f9f03393cd2aab5a827e", + "img_logo_url": "" + }, + { + "appid": 1817070, + "name": "Marvel’s Spider-Man Remastered", + "playtime_forever_min": 0, + "img_icon_url": "346333cb340139ad8b697005e5c79a3162c387b0", + "img_logo_url": "" + }, + { + "appid": 1850570, + "name": "DEATH STRANDING DIRECTOR'S CUT", + "playtime_forever_min": 0, + "img_icon_url": "dd1abd2566c95d1e89408570719657a663591f8f", + "img_logo_url": "" + }, + { + "appid": 1888930, + "name": "The Last of Us™ Part I", + "playtime_forever_min": 1167, + "img_icon_url": "6991d13038a61e35bade3aabea400c74bbbf966d", + "img_logo_url": "" + }, + { + "appid": 1903340, + "name": "Clair Obscur: Expedition 33", + "playtime_forever_min": 1817, + "img_icon_url": "d48fb3ed033a08bad9bf0a2e1ceb145e58ffe0aa", + "img_logo_url": "" + }, + { + "appid": 1971870, + "name": "Mortal Kombat 1", + "playtime_forever_min": 0, + "img_icon_url": "8b1c5aa33466802fc2a5df95505be71fae0b8d47", + "img_logo_url": "" + }, + { + "appid": 2096600, + "name": "Crysis 2 Remastered", + "playtime_forever_min": 729, + "img_icon_url": "cb25994d3d2d4ea9c605e785c1f5b3db265e2529", + "img_logo_url": "" + }, + { + "appid": 2096610, + "name": "Crysis 3 Remastered", + "playtime_forever_min": 294, + "img_icon_url": "0c3330f540a6fbce8123110357608e6a36fff1fb", + "img_logo_url": "" + }, + { + "appid": 2138710, + "name": "Sifu", + "playtime_forever_min": 0, + "img_icon_url": "3930eb06cc31a6c91ab243afd4c45f49c9d0c5c1", + "img_logo_url": "" + }, + { + "appid": 2140020, + "name": "Stronghold: Definitive Edition", + "playtime_forever_min": 186, + "img_icon_url": "2079705e18e488f1aae992fb72f16c6853da710c", + "img_logo_url": "" + }, + { + "appid": 2144740, + "name": "Ghostrunner 2", + "playtime_forever_min": 178, + "img_icon_url": "39fc0a6b7051f744c725f1fc518a8996b859e86d", + "img_logo_url": "" + }, + { + "appid": 2195250, + "name": "EA SPORTS FC™ 24", + "playtime_forever_min": 4492, + "img_icon_url": "d01e05e0fb8e77c790c699b4fa0a9559cf80d16d", + "img_logo_url": "" + }, + { + "appid": 2208920, + "name": "Assassin's Creed Valhalla", + "playtime_forever_min": 0, + "img_icon_url": "3946f91cf6beb0d384a85fc32d71616313dc362e", + "img_logo_url": "" + }, + { + "appid": 2239550, + "name": "Watch Dogs: Legion", + "playtime_forever_min": 0, + "img_icon_url": "66909b160660967933f4ffed89d10d1b1cc5df32", + "img_logo_url": "" + }, + { + "appid": 2290000, + "name": "TerraScape", + "playtime_forever_min": 303, + "img_icon_url": "261012036371fb8015bab5f0728c0518a0e7a50d", + "img_logo_url": "" + }, + { + "appid": 2369390, + "name": "Far Cry 6", + "playtime_forever_min": 1570, + "img_icon_url": "178c21f6ed3ea43c9e5b3c9ea2919e7bca63c7b0", + "img_logo_url": "" + }, + { + "appid": 2406770, + "name": "Bodycam", + "playtime_forever_min": 0, + "img_icon_url": "793bf9dd10a0c8838879adca10459e77e1f02c51", + "img_logo_url": "" + }, + { + "appid": 2417610, + "name": "METAL GEAR SOLID Δ: SNAKE EATER", + "playtime_forever_min": 0, + "img_icon_url": "23e791770df64f422ed9e3620085f44c1c27ed50", + "img_logo_url": "" + }, + { + "appid": 2427410, + "name": "S.T.A.L.K.E.R.: Shadow of Chornobyl - Enhanced Edition", + "playtime_forever_min": 0, + "img_icon_url": "2bd3b525b674984e9e89f33fd47c5ce6020beeda", + "img_logo_url": "" + }, + { + "appid": 2427420, + "name": "S.T.A.L.K.E.R.: Clear Sky - Enhanced Edition", + "playtime_forever_min": 0, + "img_icon_url": "e04a49e45a8389c008cd46b54653889a8982eca9", + "img_logo_url": "" + }, + { + "appid": 2427430, + "name": "S.T.A.L.K.E.R.: Call of Prypiat - Enhanced Edition", + "playtime_forever_min": 0, + "img_icon_url": "bcf208587bb2cca67a8994f2e8599e6cfe0769e2", + "img_logo_url": "" + }, + { + "appid": 2456740, + "name": "inZOI", + "playtime_forever_min": 273, + "img_icon_url": "91593017a96a23af849781d6a68e9599cf250b4d", + "img_logo_url": "" + }, + { + "appid": 2461850, + "name": "Senua’s Saga: Hellblade II", + "playtime_forever_min": 0, + "img_icon_url": "f70eeb6c1dbafe03301c6ddaa3da5907755e4834", + "img_logo_url": "" + }, + { + "appid": 2531310, + "name": "The Last of Us™ Part II Remastered", + "playtime_forever_min": 1458, + "img_icon_url": "e04b9ac7c860fb6c60d9ad26e6779f324a7bcb70", + "img_logo_url": "" + }, + { + "appid": 2532550, + "name": "LIZARDS MUST DIE", + "playtime_forever_min": 328, + "img_icon_url": "1f0e5d8402f5f70e2c575f5548e9f1196251f869", + "img_logo_url": "" + }, + { + "appid": 2784470, + "name": "9 Kings", + "playtime_forever_min": 323, + "img_icon_url": "d1d0c590e82d977b24e1070a9ec1aeea9e4cd9f4", + "img_logo_url": "" + }, + { + "appid": 2796010, + "name": "Party Club", + "playtime_forever_min": 0, + "img_icon_url": "a80c96dde162ef5ed25704384b6e312b6b6cddf9", + "img_logo_url": "" + }, + { + "appid": 2807960, + "name": "Battlefield™ 6", + "playtime_forever_min": 72, + "img_icon_url": "f07be1624106283c0d85e1bc9211d431caa9b4b8", + "img_logo_url": "" + }, + { + "appid": 2996040, + "name": "Teenage Mutant Ninja Turtles: Splintered Fate", + "playtime_forever_min": 77, + "img_icon_url": "52873169f648846aadf836fefa8b6a4305c8dab9", + "img_logo_url": "" + }, + { + "appid": 3035570, + "name": "Assassin's Creed Mirage", + "playtime_forever_min": 0, + "img_icon_url": "e5d6098ec84423be04e5d8a4fac2784b392dae86", + "img_logo_url": "" + }, + { + "appid": 3107800, + "name": "LIZARDS MUST DIE 2", + "playtime_forever_min": 256, + "img_icon_url": "d6dd1764f0f95a2cfc6279e1f90340e21e26232b", + "img_logo_url": "" + }, + { + "appid": 3240220, + "name": "Grand Theft Auto V Enhanced", + "playtime_forever_min": 97, + "img_icon_url": "8355a7bbdb704f727bfba80ec56bc7228991338e", + "img_logo_url": "" + }, + { + "appid": 3472040, + "name": "NBA 2K26", + "playtime_forever_min": 918, + "img_icon_url": "78919a2d089bd18bd0dc9ee2525256f9723b047c", + "img_logo_url": "" + }, + { + "appid": 3551340, + "name": "Football Manager 26", + "playtime_forever_min": 797, + "img_icon_url": "fa56d151ecdd69c841687cbe65b4beba767bafb4", + "img_logo_url": "" + } +] \ No newline at end of file diff --git a/homelab/scripts/steam_library_size.py b/homelab/scripts/steam_library_size.py new file mode 100644 index 0000000..72e6322 --- /dev/null +++ b/homelab/scripts/steam_library_size.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Расчёт суммарного размера библиотеки Steam по данным из steam_library.json. +Приоритет: api.steamcmd.net (реальные размеры депо). Запас: системные требования +в магазине Steam (часто занижены). Результаты кэшируются в steam_app_sizes.json. +""" + +import json +import re +import time +import urllib.request +from pathlib import Path +from typing import Optional + +LIBRARY_FILE = "steam_library.json" +SIZES_CACHE_FILE = "steam_app_sizes.json" +OUTPUT_FILE = "steam_library_with_sizes.json" + +REQUEST_DELAY = 0.4 + + +def get_app_size_from_steamcmd(appid: int) -> Optional[float]: + """ + Размер в ГБ из api.steamcmd.net (депо, реальный объём установки). + Учитываются только депо для Windows, для языков — только english, чтобы не суммировать все локали. + """ + url = f"https://api.steamcmd.net/v1/info/{appid}" + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode()) + except Exception: + return None + if data.get("status") != "success" or "data" not in data: + return None + app_data = data["data"].get(str(appid)) + if not app_data or "depots" not in app_data: + return None + depots = app_data["depots"] + total_bytes = 0 + for depot_id, depot in depots.items(): + if depot_id in ("appmanagesdlc", "baselanguages", "branches", "privatebranches"): + continue + if not isinstance(depot, dict): + continue + config = depot.get("config") or {} + oslist = config.get("oslist", "") + if "windows" not in oslist.lower(): + continue + manifests = depot.get("manifests") + if not manifests or "public" not in manifests: + continue + size_str = manifests["public"].get("size") + if not size_str: + continue + try: + size_bytes = int(size_str) + except (TypeError, ValueError): + continue + language = config.get("language", "") + if language and language != "english": + continue + total_bytes += size_bytes + if total_bytes == 0: + return None + return total_bytes / (1024 ** 3) + + +def parse_size_from_text(text: str) -> Optional[float]: + """Извлекает размер в ГБ из строки системных требований.""" + if not text: + return None + text = text.replace(",", ".") + m = re.search(r"(\d+(?:\.\d+)?)\s*GB", text, re.IGNORECASE) + if m: + return float(m.group(1)) + m = re.search(r"(\d+(?:\.\d+)?)\s*MB", text, re.IGNORECASE) + if m: + return float(m.group(1)) / 1024 + return None + + +def get_app_size_from_store(appid: int) -> Optional[float]: + """Размер в ГБ из системных требований store.steampowered.com (часто занижен).""" + url = f"https://store.steampowered.com/api/appdetails?appids={appid}&l=english" + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode()) + except Exception: + return None + block = data.get(str(appid)) + if not block or not block.get("success") or "data" not in block: + return None + reqs = block["data"].get("pc_requirements") + if not reqs: + return None + if isinstance(reqs, dict): + for part in (reqs.get("minimum"), reqs.get("recommended")): + size = parse_size_from_text(part) + if size is not None: + return size + elif isinstance(reqs, str): + return parse_size_from_text(reqs) + return None + + +def get_app_size(appid: int) -> Optional[float]: + """Сначала steamcmd (реальный размер), при отсутствии — магазин.""" + size = get_app_size_from_steamcmd(appid) + if size is not None: + return size + return get_app_size_from_store(appid) + + +def load_library(path: Path) -> list[dict]: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def load_sizes_cache(path: Path) -> dict: + if not path.exists(): + return {} + with open(path, encoding="utf-8") as f: + return {int(k): v for k, v in json.load(f).items()} + + +def save_sizes_cache(path: Path, cache: dict) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(cache, f, indent=2) + + +def progress_bar(current: int, total: int, width: int = 40) -> str: + """Строка прогресс-бара: [=======> ] 45/289 15%""" + if total <= 0: + return "" + pct = current / total + filled = int(width * pct) + bar = "=" * filled + ">" * (1 if filled < width else 0) + " " * (width - filled - 1) + return f"[{bar}] {current}/{total} {pct*100:.0f}%" + + +def main() -> None: + base = Path(__file__).parent + lib_path = base / LIBRARY_FILE + cache_path = base / SIZES_CACHE_FILE + out_path = base / OUTPUT_FILE + + if not lib_path.exists(): + print(f"Сначала запусти steam-library-to-json.py — нужен файл {LIBRARY_FILE}") + return + + library = load_library(lib_path) + cache = load_sizes_cache(cache_path) + total_gb = 0.0 + unknown_count = 0 + results = [] + + total_games = len(library) + for i, game in enumerate(library): + appid = game["appid"] + name = game.get("name", "") + if appid in cache: + size_gb = cache[appid] + else: + size_gb = get_app_size(appid) + cache[appid] = size_gb + time.sleep(REQUEST_DELAY) + if size_gb is not None: + total_gb += size_gb + else: + unknown_count += 1 + results.append({ + "appid": appid, + "name": name, + "size_gb": round(size_gb, 2) if size_gb is not None else None, + }) + print(f"\r{progress_bar(i + 1, total_games)} {name[:35]:<35}", end="", flush=True) + print() + + save_sizes_cache(cache_path, cache) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(results, f, ensure_ascii=False, indent=2) + + print() + print(f"Игр в библиотеке: {len(library)}") + print(f"С известным размером: {len(library) - unknown_count}") + print(f"Без размера: {unknown_count}") + print(f"Суммарный размер: {total_gb:.1f} ГБ") + print(f"Результат: {out_path}") + print(f"Кэш: {cache_path}") + + +if __name__ == "__main__": + main() diff --git a/homelab/scripts/steam_library_with_sizes.json b/homelab/scripts/steam_library_with_sizes.json new file mode 100644 index 0000000..54cb369 --- /dev/null +++ b/homelab/scripts/steam_library_with_sizes.json @@ -0,0 +1,1347 @@ +[ + { + "appid": 10, + "name": "Counter-Strike", + "size_gb": 0.09 + }, + { + "appid": 80, + "name": "Counter-Strike: Condition Zero", + "size_gb": 0.09 + }, + { + "appid": 100, + "name": "Counter-Strike: Condition Zero Deleted Scenes", + "size_gb": 0.09 + }, + { + "appid": 220, + "name": "Half-Life 2", + "size_gb": 0.31 + }, + { + "appid": 240, + "name": "Counter-Strike: Source", + "size_gb": 0.3 + }, + { + "appid": 320, + "name": "Half-Life 2: Deathmatch", + "size_gb": 0.3 + }, + { + "appid": 360, + "name": "Half-Life Deathmatch: Source", + "size_gb": 0.3 + }, + { + "appid": 400, + "name": "Portal", + "size_gb": 0.15 + }, + { + "appid": 440, + "name": "Team Fortress 2", + "size_gb": 0.57 + }, + { + "appid": 550, + "name": "Left 4 Dead 2", + "size_gb": 2.0 + }, + { + "appid": 570, + "name": "Dota 2", + "size_gb": 6.14 + }, + { + "appid": 620, + "name": "Portal 2", + "size_gb": 0.75 + }, + { + "appid": 730, + "name": "Counter-Strike 2", + "size_gb": 8.0 + }, + { + "appid": 1250, + "name": "Killing Floor", + "size_gb": 1.0 + }, + { + "appid": 2600, + "name": "Vampire: The Masquerade - Bloodlines", + "size_gb": null + }, + { + "appid": 4500, + "name": "S.T.A.L.K.E.R.: Shadow of Chernobyl", + "size_gb": 6.0 + }, + { + "appid": 6370, + "name": "Bloodline Champions", + "size_gb": 1.0 + }, + { + "appid": 7670, + "name": "BioShock", + "size_gb": 1.0 + }, + { + "appid": 8850, + "name": "BioShock 2", + "size_gb": 13.67 + }, + { + "appid": 8870, + "name": "BioShock Infinite", + "size_gb": 2.0 + }, + { + "appid": 9480, + "name": "Saints Row 2", + "size_gb": 12.13 + }, + { + "appid": 10150, + "name": "Prototype", + "size_gb": null + }, + { + "appid": 12200, + "name": "Bully: Scholarship Edition", + "size_gb": 1.0 + }, + { + "appid": 12210, + "name": "Grand Theft Auto IV: The Complete Edition", + "size_gb": 1.5 + }, + { + "appid": 13500, + "name": "Prince of Persia: Warrior Within", + "size_gb": 1.5 + }, + { + "appid": 13530, + "name": "Prince of Persia: The Two Thrones", + "size_gb": 1.5 + }, + { + "appid": 15100, + "name": "Assassin's Creed", + "size_gb": 1.0 + }, + { + "appid": 19980, + "name": "Prince of Persia", + "size_gb": 1.0 + }, + { + "appid": 20500, + "name": "Red Faction: Guerrilla Steam Edition", + "size_gb": 1.0 + }, + { + "appid": 20510, + "name": "S.T.A.L.K.E.R.: Clear Sky", + "size_gb": 6.0 + }, + { + "appid": 20920, + "name": "The Witcher 2: Assassins of Kings Enhanced Edition", + "size_gb": 0.14 + }, + { + "appid": 24240, + "name": "PAYDAY: The Heist", + "size_gb": 1.0 + }, + { + "appid": 24880, + "name": "The Saboteur™", + "size_gb": 2.0 + }, + { + "appid": 28000, + "name": "Kane & Lynch 2: Dog Days", + "size_gb": 1.0 + }, + { + "appid": 29800, + "name": "Caster", + "size_gb": 0.04 + }, + { + "appid": 33230, + "name": "Assassin's Creed II", + "size_gb": 6.0 + }, + { + "appid": 33320, + "name": "Prince of Persia: The Forgotten Sands", + "size_gb": 1.0 + }, + { + "appid": 35140, + "name": "Batman: Arkham Asylum GOTY Edition", + "size_gb": 7.89 + }, + { + "appid": 35420, + "name": "Killing Floor Mod: Defence Alliance 2", + "size_gb": null + }, + { + "appid": 35700, + "name": "Trine", + "size_gb": 4.14 + }, + { + "appid": 35720, + "name": "Trine 2", + "size_gb": 4.5 + }, + { + "appid": 40800, + "name": "Super Meat Boy", + "size_gb": 0.49 + }, + { + "appid": 41700, + "name": "S.T.A.L.K.E.R.: Call of Pripyat", + "size_gb": 6.0 + }, + { + "appid": 47810, + "name": "Dragon Age: Origins - Ultimate Edition", + "size_gb": 1.0 + }, + { + "appid": 48190, + "name": "Assassin's Creed Brotherhood", + "size_gb": 18.19 + }, + { + "appid": 50130, + "name": "Mafia II (Classic)", + "size_gb": 9.25 + }, + { + "appid": 55230, + "name": "Saints Row: The Third", + "size_gb": 11.13 + }, + { + "appid": 96100, + "name": "Defy Gravity", + "size_gb": 0.5 + }, + { + "appid": 102600, + "name": "Orcs Must Die!", + "size_gb": 2.0 + }, + { + "appid": 104200, + "name": "BEEP", + "size_gb": null + }, + { + "appid": 105600, + "name": "Terraria", + "size_gb": 0.75 + }, + { + "appid": 108600, + "name": "Project Zomboid", + "size_gb": 6.8 + }, + { + "appid": 110800, + "name": "L.A. Noire", + "size_gb": 2.0 + }, + { + "appid": 113200, + "name": "The Binding of Isaac", + "size_gb": 0.13 + }, + { + "appid": 200260, + "name": "Batman: Arkham City GOTY", + "size_gb": 18.58 + }, + { + "appid": 201870, + "name": "Assassin's Creed Revelations", + "size_gb": 1.5 + }, + { + "appid": 204100, + "name": "Max Payne 3", + "size_gb": 36.21 + }, + { + "appid": 204180, + "name": "Waveform", + "size_gb": 0.01 + }, + { + "appid": 204360, + "name": "Castle Crashers", + "size_gb": 8.0 + }, + { + "appid": 204450, + "name": "Call of Juarez Gunslinger", + "size_gb": 2.0 + }, + { + "appid": 206420, + "name": "Saints Row IV", + "size_gb": 3.03 + }, + { + "appid": 208090, + "name": "Loadout", + "size_gb": null + }, + { + "appid": 208650, + "name": "Batman™: Arkham Knight", + "size_gb": 70.38 + }, + { + "appid": 209000, + "name": "Batman™: Arkham Origins", + "size_gb": 2.0 + }, + { + "appid": 218620, + "name": "PAYDAY 2", + "size_gb": 82.86 + }, + { + "appid": 219150, + "name": "Hotline Miami", + "size_gb": 0.55 + }, + { + "appid": 219990, + "name": "Grim Dawn", + "size_gb": 2.0 + }, + { + "appid": 220240, + "name": "Far Cry® 3", + "size_gb": 11.55 + }, + { + "appid": 222900, + "name": "Dead Island: Epidemic", + "size_gb": 2.0 + }, + { + "appid": 224260, + "name": "No More Room in Hell", + "size_gb": 2.79 + }, + { + "appid": 225540, + "name": "Just Cause 3", + "size_gb": 55.87 + }, + { + "appid": 226320, + "name": "Marvel Heroes Omega", + "size_gb": 0.47 + }, + { + "appid": 227300, + "name": "Euro Truck Simulator 2", + "size_gb": 8.0 + }, + { + "appid": 230230, + "name": "Divinity: Original Sin (Classic)", + "size_gb": 8.31 + }, + { + "appid": 232090, + "name": "Killing Floor 2", + "size_gb": 3.0 + }, + { + "appid": 233270, + "name": "Far Cry® 3 Blood Dragon", + "size_gb": 4.99 + }, + { + "appid": 233450, + "name": "Prison Architect", + "size_gb": 0.5 + }, + { + "appid": 233860, + "name": "Kenshi", + "size_gb": 11.47 + }, + { + "appid": 241930, + "name": "Middle-earth™: Shadow of Mordor™", + "size_gb": 3.0 + }, + { + "appid": 242050, + "name": "Assassin's Creed IV Black Flag", + "size_gb": 29.13 + }, + { + "appid": 242760, + "name": "The Forest", + "size_gb": 5.54 + }, + { + "appid": 243470, + "name": "Watch_Dogs", + "size_gb": 11.85 + }, + { + "appid": 250400, + "name": "How to Survive", + "size_gb": 4.0 + }, + { + "appid": 250900, + "name": "The Binding of Isaac: Rebirth", + "size_gb": 1.91 + }, + { + "appid": 255710, + "name": "Cities: Skylines", + "size_gb": 8.0 + }, + { + "appid": 261550, + "name": "Mount & Blade II: Bannerlord", + "size_gb": 61.64 + }, + { + "appid": 263980, + "name": "Out There Somewhere", + "size_gb": 0.3 + }, + { + "appid": 268500, + "name": "XCOM 2", + "size_gb": 4.0 + }, + { + "appid": 270550, + "name": "Yet Another Zombie Defense", + "size_gb": 1.0 + }, + { + "appid": 270880, + "name": "American Truck Simulator", + "size_gb": 8.0 + }, + { + "appid": 271590, + "name": "Grand Theft Auto V Legacy", + "size_gb": 4.0 + }, + { + "appid": 274170, + "name": "Hotline Miami 2: Wrong Number", + "size_gb": 0.57 + }, + { + "appid": 286690, + "name": "Metro 2033 Redux", + "size_gb": 7.81 + }, + { + "appid": 287450, + "name": "Rise of Nations: Extended Edition", + "size_gb": 1.0 + }, + { + "appid": 287700, + "name": "METAL GEAR SOLID V: THE PHANTOM PAIN", + "size_gb": 4.0 + }, + { + "appid": 289070, + "name": "Sid Meier's Civilization VI", + "size_gb": 4.0 + }, + { + "appid": 289130, + "name": "ENDLESS™ Legend", + "size_gb": 10.36 + }, + { + "appid": 289650, + "name": "Assassin's Creed Unity", + "size_gb": 6.0 + }, + { + "appid": 291010, + "name": "The Hat Man: Shadow Ward", + "size_gb": 0.19 + }, + { + "appid": 292030, + "name": "The Witcher 3: Wild Hunt", + "size_gb": 6.0 + }, + { + "appid": 292620, + "name": "Pressured", + "size_gb": 0.02 + }, + { + "appid": 294100, + "name": "RimWorld", + "size_gb": 4.0 + }, + { + "appid": 298110, + "name": "Far Cry 4", + "size_gb": 4.0 + }, + { + "appid": 301910, + "name": "Saints Row: Gat out of Hell", + "size_gb": 0.02 + }, + { + "appid": 305620, + "name": "The Long Dark", + "size_gb": 17.26 + }, + { + "appid": 307690, + "name": "Sleeping Dogs: Definitive Edition", + "size_gb": 16.71 + }, + { + "appid": 307780, + "name": "Mortal Kombat X", + "size_gb": 3.0 + }, + { + "appid": 314020, + "name": "Morphopolis", + "size_gb": 1.12 + }, + { + "appid": 315430, + "name": "Polarity", + "size_gb": 3.09 + }, + { + "appid": 316030, + "name": "Alice in Wonderland", + "size_gb": 1.0 + }, + { + "appid": 318430, + "name": "Squishy the Suicidal Pig", + "size_gb": 0.0 + }, + { + "appid": 319910, + "name": "Trine 3: The Artifacts of Power", + "size_gb": 4.7 + }, + { + "appid": 322330, + "name": "Don't Starve Together", + "size_gb": 4.02 + }, + { + "appid": 330020, + "name": "Children of Morta", + "size_gb": 1.73 + }, + { + "appid": 330350, + "name": "Robotex", + "size_gb": 0.79 + }, + { + "appid": 331470, + "name": "Everlasting Summer", + "size_gb": 0.5 + }, + { + "appid": 333420, + "name": "Cossacks 3", + "size_gb": 0.02 + }, + { + "appid": 339610, + "name": "FreeStyle 2: Street Basketball", + "size_gb": 10.03 + }, + { + "appid": 345520, + "name": "Infinite Crisis™", + "size_gb": 2.0 + }, + { + "appid": 346900, + "name": "AdVenture Capitalist", + "size_gb": 0.46 + }, + { + "appid": 351920, + "name": "Crazy Machines 3", + "size_gb": 5.73 + }, + { + "appid": 356190, + "name": "Middle-earth™: Shadow of War™", + "size_gb": 6.0 + }, + { + "appid": 360430, + "name": "Mafia III: Definitive Edition", + "size_gb": 52.4 + }, + { + "appid": 361420, + "name": "ASTRONEER", + "size_gb": 2.98 + }, + { + "appid": 362890, + "name": "Black Mesa", + "size_gb": 0.2 + }, + { + "appid": 363970, + "name": "Clicker Heroes", + "size_gb": 0.57 + }, + { + "appid": 368500, + "name": "Assassin's Creed Syndicate", + "size_gb": 6.0 + }, + { + "appid": 371660, + "name": "Far Cry Primal", + "size_gb": 4.0 + }, + { + "appid": 373420, + "name": "Divinity: Original Sin Enhanced Edition", + "size_gb": 9.96 + }, + { + "appid": 374320, + "name": "DARK SOULS™ III", + "size_gb": 4.0 + }, + { + "appid": 377160, + "name": "Fallout 4", + "size_gb": 8.0 + }, + { + "appid": 379430, + "name": "Kingdom Come: Deliverance", + "size_gb": 86.41 + }, + { + "appid": 379720, + "name": "DOOM", + "size_gb": 0.17 + }, + { + "appid": 386360, + "name": "SMITE", + "size_gb": 38.65 + }, + { + "appid": 391220, + "name": "Rise of the Tomb Raider", + "size_gb": 6.0 + }, + { + "appid": 393380, + "name": "Squad", + "size_gb": 64.49 + }, + { + "appid": 394360, + "name": "Hearts of Iron IV", + "size_gb": 4.0 + }, + { + "appid": 409710, + "name": "BioShock Remastered", + "size_gb": 20.66 + }, + { + "appid": 409720, + "name": "BioShock 2 Remastered", + "size_gb": 19.95 + }, + { + "appid": 412020, + "name": "Metro Exodus", + "size_gb": 70.08 + }, + { + "appid": 414340, + "name": "Hellblade: Senua's Sacrifice", + "size_gb": 8.0 + }, + { + "appid": 435150, + "name": "Divinity: Original Sin 2", + "size_gb": 65.72 + }, + { + "appid": 447040, + "name": "Watch_Dogs 2", + "size_gb": 31.45 + }, + { + "appid": 460950, + "name": "Katana ZERO", + "size_gb": 0.21 + }, + { + "appid": 474960, + "name": "Quantum Break", + "size_gb": 8.0 + }, + { + "appid": 475150, + "name": "Titan Quest Anniversary Edition", + "size_gb": 20.16 + }, + { + "appid": 489830, + "name": "The Elder Scrolls V: Skyrim Special Edition", + "size_gb": 8.0 + }, + { + "appid": 492720, + "name": "Tropico 6", + "size_gb": 20.07 + }, + { + "appid": 526870, + "name": "Satisfactory", + "size_gb": 25.53 + }, + { + "appid": 529340, + "name": "Victoria 3", + "size_gb": 8.0 + }, + { + "appid": 548430, + "name": "Deep Rock Galactic", + "size_gb": 3.61 + }, + { + "appid": 551770, + "name": "ECHO", + "size_gb": 19.05 + }, + { + "appid": 552520, + "name": "Far Cry 5", + "size_gb": 47.03 + }, + { + "appid": 557340, + "name": "My Friend Pedro", + "size_gb": 3.56 + }, + { + "appid": 559100, + "name": "Phantom Doctrine", + "size_gb": 23.43 + }, + { + "appid": 578080, + "name": "PUBG: BATTLEGROUNDS", + "size_gb": 8.0 + }, + { + "appid": 582160, + "name": "Assassin's Creed Origins", + "size_gb": 6.0 + }, + { + "appid": 582660, + "name": "Black Desert", + "size_gb": 87.08 + }, + { + "appid": 597180, + "name": "Old World", + "size_gb": 7.04 + }, + { + "appid": 601150, + "name": "Devil May Cry 5", + "size_gb": 40.01 + }, + { + "appid": 606280, + "name": "Darksiders III", + "size_gb": 8.0 + }, + { + "appid": 613100, + "name": "House Flipper", + "size_gb": 26.3 + }, + { + "appid": 632470, + "name": "Disco Elysium", + "size_gb": 9.57 + }, + { + "appid": 667720, + "name": "Red Faction Guerrilla Re-Mars-tered", + "size_gb": 30.29 + }, + { + "appid": 690640, + "name": "Trine 4: The Nightmare Prince", + "size_gb": 15.72 + }, + { + "appid": 728880, + "name": "Overcooked! 2", + "size_gb": 7.92 + }, + { + "appid": 747350, + "name": "Hellblade: Senua's Sacrifice VR Edition", + "size_gb": 23.05 + }, + { + "appid": 774941, + "name": "Squad - Public Testing", + "size_gb": 63.95 + }, + { + "appid": 782330, + "name": "DOOM Eternal", + "size_gb": 0.42 + }, + { + "appid": 812140, + "name": "Assassin's Creed Odyssey", + "size_gb": 8.0 + }, + { + "appid": 829660, + "name": "No Time to Relax", + "size_gb": 0.26 + }, + { + "appid": 870780, + "name": "Control Ultimate Edition", + "size_gb": 83.82 + }, + { + "appid": 892970, + "name": "Valheim", + "size_gb": 1.48 + }, + { + "appid": 911400, + "name": "Assassin's Creed III Remastered", + "size_gb": 47.16 + }, + { + "appid": 916440, + "name": "Anno 1800", + "size_gb": 8.0 + }, + { + "appid": 939960, + "name": "Far Cry New Dawn", + "size_gb": 39.57 + }, + { + "appid": 945360, + "name": "Among Us", + "size_gb": 0.93 + }, + { + "appid": 960910, + "name": "Heavy Rain", + "size_gb": 36.49 + }, + { + "appid": 973580, + "name": "Sniper Ghost Warrior Contracts", + "size_gb": 8.0 + }, + { + "appid": 976310, + "name": "Mortal Kombat 11", + "size_gb": 8.0 + }, + { + "appid": 978300, + "name": "Saints Row The Third Remastered", + "size_gb": 46.34 + }, + { + "appid": 990080, + "name": "Hogwarts Legacy", + "size_gb": 2.22 + }, + { + "appid": 1029690, + "name": "Sniper Elite 5", + "size_gb": 8.0 + }, + { + "appid": 1030830, + "name": "Mafia II: Definitive Edition", + "size_gb": 0.0 + }, + { + "appid": 1030840, + "name": "Mafia: Definitive Edition", + "size_gb": 36.24 + }, + { + "appid": 1044720, + "name": "Farthest Frontier", + "size_gb": 8.0 + }, + { + "appid": 1070330, + "name": "Russian Life Simulator", + "size_gb": 0.56 + }, + { + "appid": 1086940, + "name": "Baldur's Gate 3", + "size_gb": 145.0 + }, + { + "appid": 1088850, + "name": "Marvel's Guardians of the Galaxy", + "size_gb": 8.0 + }, + { + "appid": 1091500, + "name": "Cyberpunk 2077", + "size_gb": 62.0 + }, + { + "appid": 1092790, + "name": "Inscryption", + "size_gb": 4.0 + }, + { + "appid": 1097150, + "name": "Fall Guys", + "size_gb": 8.0 + }, + { + "appid": 1097840, + "name": "Gears 5", + "size_gb": 120.64 + }, + { + "appid": 1123770, + "name": "Curse of the Dead Gods", + "size_gb": 4.0 + }, + { + "appid": 1124300, + "name": "HUMANKIND™", + "size_gb": 34.54 + }, + { + "appid": 1139900, + "name": "Ghostrunner", + "size_gb": 34.11 + }, + { + "appid": 1145360, + "name": "Hades", + "size_gb": 13.03 + }, + { + "appid": 1151640, + "name": "Horizon Zero Dawn™ Complete Edition", + "size_gb": 88.18 + }, + { + "appid": 1158310, + "name": "Crusader Kings III", + "size_gb": 8.0 + }, + { + "appid": 1172380, + "name": "STAR WARS Jedi: Fallen Order™ ", + "size_gb": 8.0 + }, + { + "appid": 1174180, + "name": "Red Dead Redemption 2", + "size_gb": 8.0 + }, + { + "appid": 1210320, + "name": "Potion Craft: Alchemist Simulator", + "size_gb": 8.0 + }, + { + "appid": 1222140, + "name": "Detroit: Become Human", + "size_gb": 58.75 + }, + { + "appid": 1222670, + "name": "The Sims™ 4", + "size_gb": 4.0 + }, + { + "appid": 1222680, + "name": "Need for Speed™ Heat ", + "size_gb": 8.0 + }, + { + "appid": 1222700, + "name": "A Way Out", + "size_gb": 8.0 + }, + { + "appid": 1232130, + "name": "BEAR, VODKA, STALINGRAD! 🐻", + "size_gb": 2.0 + }, + { + "appid": 1233570, + "name": "Mirror's Edge™ Catalyst", + "size_gb": 6.0 + }, + { + "appid": 1237950, + "name": "STAR WARS™ Battlefront™ II", + "size_gb": 8.0 + }, + { + "appid": 1238810, + "name": "Battlefield™ V", + "size_gb": 8.0 + }, + { + "appid": 1239690, + "name": "Retrowave", + "size_gb": 4.0 + }, + { + "appid": 1259420, + "name": "Days Gone", + "size_gb": 110.01 + }, + { + "appid": 1272080, + "name": "PAYDAY 3", + "size_gb": 16.0 + }, + { + "appid": 1316680, + "name": "VLADiK BRUTAL", + "size_gb": 4.0 + }, + { + "appid": 1328670, + "name": "Mass Effect™ Legendary Edition", + "size_gb": 8.0 + }, + { + "appid": 1332010, + "name": "Stray", + "size_gb": 6.14 + }, + { + "appid": 1336490, + "name": "Against the Storm", + "size_gb": 6.51 + }, + { + "appid": 1338770, + "name": "Sniper Ghost Warrior Contracts 2", + "size_gb": 8.0 + }, + { + "appid": 1363080, + "name": "Manor Lords", + "size_gb": 13.21 + }, + { + "appid": 1426210, + "name": "It Takes Two", + "size_gb": 8.0 + }, + { + "appid": 1436700, + "name": "Trine 5: A Clockwork Conspiracy", + "size_gb": 8.0 + }, + { + "appid": 1449560, + "name": "Metro Exodus Enhanced Edition", + "size_gb": 72.23 + }, + { + "appid": 1466860, + "name": "Age of Empires IV: Anniversary Edition", + "size_gb": 8.0 + }, + { + "appid": 1510580, + "name": "Toy Tinker Simulator", + "size_gb": 4.0 + }, + { + "appid": 1546970, + "name": "Grand Theft Auto III - The Definitive Edition", + "size_gb": 8.0 + }, + { + "appid": 1546990, + "name": "Grand Theft Auto: Vice City - The Definitive Edition", + "size_gb": 8.0 + }, + { + "appid": 1547000, + "name": "Grand Theft Auto: San Andreas - The Definitive Edition", + "size_gb": 8.0 + }, + { + "appid": 1568590, + "name": "Goose Goose Duck", + "size_gb": 1.0 + }, + { + "appid": 1593500, + "name": "God of War", + "size_gb": 8.0 + }, + { + "appid": 1659040, + "name": "HITMAN World of Assassination", + "size_gb": 78.71 + }, + { + "appid": 1665460, + "name": "eFootball™", + "size_gb": 46.44 + }, + { + "appid": 1715130, + "name": "Crysis Remastered", + "size_gb": 19.87 + }, + { + "appid": 1771300, + "name": "Kingdom Come: Deliverance II", + "size_gb": 102.12 + }, + { + "appid": 1774580, + "name": "STAR WARS Jedi: Survivor™", + "size_gb": 8.0 + }, + { + "appid": 1794680, + "name": "Vampire Survivors", + "size_gb": 0.71 + }, + { + "appid": 1797610, + "name": "Barro 22", + "size_gb": 2.0 + }, + { + "appid": 1817070, + "name": "Marvel’s Spider-Man Remastered", + "size_gb": 70.14 + }, + { + "appid": 1850570, + "name": "DEATH STRANDING DIRECTOR'S CUT", + "size_gb": 8.0 + }, + { + "appid": 1888930, + "name": "The Last of Us™ Part I", + "size_gb": 4.51 + }, + { + "appid": 1903340, + "name": "Clair Obscur: Expedition 33", + "size_gb": 8.0 + }, + { + "appid": 1971870, + "name": "Mortal Kombat 1", + "size_gb": 201.91 + }, + { + "appid": 2096600, + "name": "Crysis 2 Remastered", + "size_gb": 8.0 + }, + { + "appid": 2096610, + "name": "Crysis 3 Remastered", + "size_gb": 8.0 + }, + { + "appid": 2138710, + "name": "Sifu", + "size_gb": 30.3 + }, + { + "appid": 2140020, + "name": "Stronghold: Definitive Edition", + "size_gb": 0.01 + }, + { + "appid": 2144740, + "name": "Ghostrunner 2", + "size_gb": 69.84 + }, + { + "appid": 2195250, + "name": "EA SPORTS FC™ 24", + "size_gb": 8.0 + }, + { + "appid": 2208920, + "name": "Assassin's Creed Valhalla", + "size_gb": 158.53 + }, + { + "appid": 2239550, + "name": "Watch Dogs: Legion", + "size_gb": 8.0 + }, + { + "appid": 2290000, + "name": "TerraScape", + "size_gb": 1.62 + }, + { + "appid": 2369390, + "name": "Far Cry 6", + "size_gb": 8.0 + }, + { + "appid": 2406770, + "name": "Bodycam", + "size_gb": 8.0 + }, + { + "appid": 2417610, + "name": "METAL GEAR SOLID Δ: SNAKE EATER", + "size_gb": 16.0 + }, + { + "appid": 2427410, + "name": "S.T.A.L.K.E.R.: Shadow of Chornobyl - Enhanced Edition", + "size_gb": 8.0 + }, + { + "appid": 2427420, + "name": "S.T.A.L.K.E.R.: Clear Sky - Enhanced Edition", + "size_gb": 8.0 + }, + { + "appid": 2427430, + "name": "S.T.A.L.K.E.R.: Call of Prypiat - Enhanced Edition", + "size_gb": 8.0 + }, + { + "appid": 2456740, + "name": "inZOI", + "size_gb": 33.05 + }, + { + "appid": 2461850, + "name": "Senua’s Saga: Hellblade II", + "size_gb": 16.0 + }, + { + "appid": 2531310, + "name": "The Last of Us™ Part II Remastered", + "size_gb": 139.86 + }, + { + "appid": 2532550, + "name": "LIZARDS MUST DIE", + "size_gb": 3.08 + }, + { + "appid": 2784470, + "name": "9 Kings", + "size_gb": 0.16 + }, + { + "appid": 2796010, + "name": "Party Club", + "size_gb": 5.45 + }, + { + "appid": 2807960, + "name": "Battlefield™ 6", + "size_gb": 16.0 + }, + { + "appid": 2996040, + "name": "Teenage Mutant Ninja Turtles: Splintered Fate", + "size_gb": 4.0 + }, + { + "appid": 3035570, + "name": "Assassin's Creed Mirage", + "size_gb": 65.01 + }, + { + "appid": 3107800, + "name": "LIZARDS MUST DIE 2", + "size_gb": 8.0 + }, + { + "appid": 3240220, + "name": "Grand Theft Auto V Enhanced", + "size_gb": 8.0 + }, + { + "appid": 3472040, + "name": "NBA 2K26", + "size_gb": 26.53 + }, + { + "appid": 3551340, + "name": "Football Manager 26", + "size_gb": 7.12 + } +] \ No newline at end of file diff --git a/homelab/us-router.conf b/homelab/us-router.conf new file mode 100644 index 0000000..a4a9dce --- /dev/null +++ b/homelab/us-router.conf @@ -0,0 +1,19 @@ +[Interface] +Address = 10.8.1.2/32 +PrivateKey = htF4B+QY72eKaJnnKmrKqtowN76c4IV1qYxlEaIx2Z4= +Jc = 6 +Jmin = 10 +Jmax = 50 +S1 = 90 +S2 = 62 +H1 = 1455064900 +H2 = 852483043 +H3 = 2078090415 +H4 = 1981181588 + +[Peer] +PublicKey = SDjcBREvIXfuCOCISj4hk3gvYaM188IZcZNnWAuOclc= +PresharedKey = 8C4rE3PVqyi3FQD+zDjGzQ4W5Dju8T2uGhGF9yor1zc= +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = 147.45.124.117:37135 +PersistentKeepalive = 25 diff --git a/homelab/vpn-keenetic-us-second-connection.md b/homelab/vpn-keenetic-us-second-connection.md new file mode 100644 index 0000000..da9b43a --- /dev/null +++ b/homelab/vpn-keenetic-us-second-connection.md @@ -0,0 +1,99 @@ +# Второе VPN-подключение (US) на Keenetic без смены маршрутов + +Оба туннеля используют один и тот же адрес клиента **10.8.1.2**. Маршруты на роутере указывают на шлюз 10.8.1.2 — переключение DE/US делается включением/выключением нужного подключения в веб-интерфейсе. + +--- + +## Что уже сделано + +- На **US VPS** (147.45.124.117) в AmneziaWG добавлен peer с **AllowedIPs = 10.8.1.2/32** и публичным ключом роутера (тот же, что для DE). Контейнер перезапущен, peer активен. + +--- + +## Параметры US-сервера (для конфига роутера) + +| Параметр | Значение | +|----------|----------| +| Endpoint | 147.45.124.117:37135 | +| PublicKey сервера | SDjcBREvIXfuCOCISj4hk3gvYaM188IZcZNnWAuOclc= | +| PresharedKey | 8C4rE3PVqyi3FQD+zDjGzQ4W5Dju8T2uGhGF9yor1zc= | +| Адрес клиента | 10.8.1.2/32 (как у DE) | + +**Параметры обфускации (asc)** на US-сервере сделаны **такими же, как на DE** — один набор для обоих подключений, вручную вводить один раз: + +- Jc = 6, Jmin = 10, Jmax = 50 +- S1 = 90, S2 = 62 +- H1 = 1455064900, H2 = 852483043, H3 = 2078090415, H4 = 1981181588 + +--- + +## Конфиг клиента для роутера (шаблон) + +Нужно подставить **PrivateKey** из текущего подключения **netcraze_amnezia** на роутере (тот же ключ, что и для DE — тогда публичный ключ совпадает и peer 10.8.1.2 на US уже привязан к нему). + +Сохраните как `amnezia-us-router.conf` и загрузите в роутер через **Интернет → Другие подключения → Wireguard → Загрузить из файла**. + +```ini +[Interface] +PrivateKey = ВСТАВЬТЕ_ПРИВАТНЫЙ_КЛЮЧ_ИЗ_netcraze_amnezia +Address = 10.8.1.2/32 +Jc = 6 +Jmin = 10 +Jmax = 50 +S1 = 90 +S2 = 62 +H1 = 1455064900 +H2 = 852483043 +H3 = 2078090415 +H4 = 1981181588 + +[Peer] +PublicKey = SDjcBREvIXfuCOCISj4hk3gvYaM188IZcZNnWAuOclc= +PresharedKey = 8C4rE3PVqyi3FQD+zDjGzQ4W5Dju8T2uGhGF9yor1zc= +Endpoint = 147.45.124.117:37135 +AllowedIPs = 0.0.0.0/0, ::/0 +``` + +**Откуда взять PrivateKey:** в приложении AmneziaVPN он не показывается (только «Частный ключ установлен»). Варианты: (1) В веб-интерфейсе Keenetic откройте **Интернет → Другие подключения → Wireguard**, нажмите на строку **netcraze_amnezia** и проверьте, есть ли там поле приватного ключа или экспорт конфига. (2) После настройки доступа по SSH-ключу в Amnezia создайте на US-сервере нового пользователя, скачайте конфиг — в нём будет новая пара ключей; тогда на US-сервере нужно заменить peer 10.8.1.2 на новый публичный ключ из этого конфига и в конфиге прописать Address 10.8.1.2/32 — этот конфиг и загружать в роутер. + +--- + +## Если при загрузке конфига ошибка про обфускацию + +1. **Версия KeeneticOS** смотрится в **Управление → Обзор системы** («Об системе»), а не в версии компонента MDNS. Нужна именно строка вида **KeeneticOS 4.3.x** (или 4.2.x). +2. В **KeeneticOS 4.3.4+** параметры asc должны подхватываться из файла. Если ошибка всё равно есть — возможно, формат S3/S4 или H не поддерживается; тогда добавьте подключение **без** asc в файле и задайте asc вручную через CLI (см. ниже). +3. Для **KeeneticOS 4.3.3 и ниже** по [инструкции Amnezia](https://docs.amnezia.org/documentation/instructions/keenetic-os-awg/) после импорта конфига нужно вручную прописать asc в веб-CLI: + - **Управление → шестерёнка → Командная строка** + - `show interface` — найти имя интерфейса нового подключения (например Wireguard1). + - Выполнить (подставив имя интерфейса и значения asc из блока выше): + ```text + interface Wireguard1 wireguard asc 6 10 50 90 62 1455064900 852483043 2078090415 1981181588 + ``` + (Один набор asc для обоих подключений: Jc Jmin Jmax S1 S2 H1 H2 H3 H4.) + - `system configuration save` + +--- + +## После добавления второго подключения + +- В **Приоритеты подключений** можно добавить вторую политику с новым подключением (US) или оставить одну политику и переключать только активное подключение в ней. +- **Маршруты не менять**: все пользовательские маршруты по-прежнему с шлюзом **10.8.1.2**. +- **Включать только одно подключение**: либо **netcraze_amnezia** (DE), либо новое (US). Включённое подключение даёт интерфейс с адресом 10.8.1.2 — трафик по маршрутам пойдёт через него. +- Переключение: в **Другие подключения → Wireguard** выключить одно подключение и включить другое. + +--- + +## Сводка по серверам + +| Сервер | IP | Порт | Роутер (peer) | +|--------|-----|------|----------------| +| DE (текущий) | 185.103.253.99 | 33118 | 10.8.1.2, ключ HSgydyy... | +| US (новый) | 147.45.124.117 | 37135 | 10.8.1.2, тот же ключ | + +При обновлении конфига Amnezia через приложение на US-сервере вручную добавленный peer 10.8.1.2 может пропасть; тогда его нужно снова добавить в `awg0.conf` в контейнере и перезапустить контейнер (или восстановить конфиг из бэкапа). + +--- + +## Ошибка 300 (SshRequestDeniedError) в AmneziaVPN + +Если на сервере отключён вход по паролю, приложение не сможет подключаться к серверу (создавать пользователей, менять настройки) и выдаст ошибку 300. **Решение:** в настройках сервера в AmneziaVPN указать аутентификацию по **SSH-ключу**: выбрать «Подключение по ключу» и указать ваш приватный SSH-ключ (тот же, которым заходите с Mac в `ssh root@...`). После сохранения создание пользователей и выдача конфигов снова работают. [Подробнее в документации Amnezia](https://m-docs-3w5hsuiikq-ez.a.run.app/troubleshooting/error-codes/#error-300-sshrequestdeniederror). diff --git a/homelab/vpn-route-check/.gitignore b/homelab/vpn-route-check/.gitignore new file mode 100644 index 0000000..ea1472e --- /dev/null +++ b/homelab/vpn-route-check/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/homelab/vpn-route-check/Dockerfile b/homelab/vpn-route-check/Dockerfile new file mode 100644 index 0000000..f0d3c0f --- /dev/null +++ b/homelab/vpn-route-check/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-slim +RUN apt-get update && apt-get install -y --no-install-recommends traceroute dnsutils iproute2 openssh-client && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY check_routes.py domains.txt serve_no_cache.py ./ +RUN mkdir -p /data +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +EXPOSE 8765 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/homelab/vpn-route-check/PROMPT-deploy-to-server.md b/homelab/vpn-route-check/PROMPT-deploy-to-server.md new file mode 100644 index 0000000..33f8400 --- /dev/null +++ b/homelab/vpn-route-check/PROMPT-deploy-to-server.md @@ -0,0 +1,28 @@ +# Промпт: обновить vpn-route-check на сервере + +Скопируй этот блок и вставь в чат с ассистентом, когда нужно выкатить обновления на сервер. + +--- + +**Задача:** развернуть/обновить сервис vpn-route-check на сервере. + +**Контекст:** +- Проект в репозитории: `homelab/vpn-route-check/` (скрипт проверки маршрутов VPN/Ethernet, выдача HTML по HTTP). +- Сервер: Proxmox по адресу **192.168.1.150**, логин **root** (SSH без пароля уже настроен). +- Сервис крутится **внутри LXC-контейнера с ID 100** (IP контейнера **192.168.1.100**). В контейнере установлены Docker и Docker Compose. +- Путь в контейнере: `/opt/docker/vpn-route-check`. После деплоя страница доступна по **http://192.168.1.100:8765**. + +**Что сделать:** +1. Из корня репозитория (или из `homelab/vpn-route-check`) упаковать содержимое папки `vpn-route-check` в tar (без самой папки, только файлы внутри: `check_routes.py`, `domains.txt`, `Dockerfile`, `entrypoint.sh`, `docker-compose.yml` и т.д.). +2. Скопировать tar на Proxmox: `scp ... root@192.168.1.150:/tmp/vpn-route-check.tar`. +3. На Proxmox: загрузить tar в контейнер 100: + `pct push 100 /tmp/vpn-route-check.tar /tmp/vpn-route-check.tar` +4. В контейнере: распаковать в `/opt/docker/vpn-route-check`: + `pct exec 100 -- tar -xf /tmp/vpn-route-check.tar -C /opt/docker/vpn-route-check` +5. В контейнере: пересобрать и запустить: + `pct exec 100 -- bash -c 'cd /opt/docker/vpn-route-check && docker compose up -d --build'` +6. Проверить: контейнер `vpn-route-check` в статусе Up, в логах есть строка вида `OK: N доменов`, по адресу http://192.168.1.100:8765 отдаётся страница (curl или браузер). + +Все команды выполни сам (у меня есть доступ по SSH к 192.168.1.150). Если репозиторий открыт в Cursor, используй путь к проекту из workspace (например `homelab/vpn-route-check`). + +--- diff --git a/homelab/vpn-route-check/README.md b/homelab/vpn-route-check/README.md new file mode 100644 index 0000000..3d23586 --- /dev/null +++ b/homelab/vpn-route-check/README.md @@ -0,0 +1,49 @@ +# Проверка маршрута к доменам (VPN / Ethernet) + +Скрипт проверяет, идёт ли трафик к заданным доменам через VPN (10.8.1.0) или через Ethernet. Результаты отдаются по HTTP и обновляются **каждые 15 минут** без ручного запуска. + +## Быстрый старт (один раз настроил — дальше всё само) + +**Шаг 1.** С хоста Proxmox скопировать папку в контейнер 100 и запустить контейнер: + +```bash +# С Mac скопировать папку на Proxmox (если репо только на Mac): +scp -r /Users/andrejkatyhin/Work/plantUML/homelab/vpn-route-check root@192.168.1.150:/root/ + +# Зайти на Proxmox и запустить развёртывание: +ssh root@192.168.1.150 +bash /root/vpn-route-check/deploy-on-proxmox.sh +``` + +Если репо уже есть на Proxmox (например в `/root/plantUML`): + +```bash +ssh root@192.168.1.150 +cd /root/plantUML/homelab/vpn-route-check +bash deploy-on-proxmox.sh +``` + +Контейнер поднимется, проверка будет запускаться при старте и **каждые 15 минут**, страница отдаётся на порту **8765** на 192.168.1.100. + +**Шаг 2 (опционально).** Когда сервис доступен по http://192.168.1.100:8765 — можно добавить виджет в Homepage (фрагмент в `homepage-widget.yaml`). Сейчас виджет из дашборда убран: страница 8765 недоступна. + +**Итоговая страница (в локальной сети):** **http://192.168.1.100:8765** + +--- + +## Список доменов + +По умолчанию проверяются домены из `domains.txt` в образе. Чтобы менять список без пересборки, раскомментируй в `docker-compose.yml` volume для `domains.txt` и перезапусти контейнер. + +--- + +## Локальный запуск (без Docker) + +Для разовой проверки с Mac: + +```bash +python3 check_routes.py +# Результаты в output/index.html и output/results.json +``` + +Требуется: `traceroute`, `dig` (dnsutils). diff --git a/homelab/vpn-route-check/__pycache__/check_routes.cpython-313.pyc b/homelab/vpn-route-check/__pycache__/check_routes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ffd17813fab1de9debf784b423f93dd4a98f570a GIT binary patch literal 17555 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 Ab^rhX literal 0 HcmV?d00001 diff --git a/homelab/vpn-route-check/check_routes.py b/homelab/vpn-route-check/check_routes.py new file mode 100644 index 0000000..2d69743 --- /dev/null +++ b/homelab/vpn-route-check/check_routes.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +""" +Проверяет, идёт ли трафик к заданным доменам через VPN (10.8.1.0) или через Ethernet. +Запускать с хоста в той же LAN, что и роутер (например контейнер 100 или рабочий Mac). +Требуется: traceroute, dig (dnsutils) или резолв через Python. +Выход: JSON + HTML в указанную директорию для просмотра в Homepage (iframe). +""" +import json +import os +import re +import subprocess +import sys +try: + import telnetlib +except ImportError: + telnetlib = None # удалён в Python 3.13+ +from datetime import datetime, timezone +from ipaddress import IPv4Address, ip_network +from pathlib import Path + +# Второй прыжок traceroute = 10.8.1.0 означает туннель AmneziaWG (VPN) +VPN_HOP_IP = "10.8.1.0" +VPN_IN_FIRST_N_HOPS = 10 # в первых N прыжках ищем VPN-шлюз +TRACEROUTE_MAX_HOPS = 15 # больше прыжков — видим VPN до того, как хост перестаёт отвечать (Яндекс, Mail и др.) +TRACEROUTE_TIMEOUT = 25 # секунд на один домен + +# Роутер: откуда брать реальный маршрут (VPN vs провайдер). Либо SSH, либо Telnet (NDMS/Keenetic). +ROUTER_HOST = os.environ.get("ROUTER_HOST", "").strip() # 192.168.1.1 +ROUTER_SSH_USER = os.environ.get("ROUTER_SSH_USER", "root") +ROUTER_SSH_TIMEOUT = 8 +# Telnet (если на роутере нет SSH, только telnet) +ROUTER_TELNET_HOST = os.environ.get("ROUTER_TELNET_HOST", "").strip() +ROUTER_TELNET_USER = os.environ.get("ROUTER_TELNET_USER", "admin") +ROUTER_TELNET_PASSWORD = os.environ.get("ROUTER_TELNET_PASSWORD", "") +ROUTER_TELNET_TIMEOUT = 15 +# По какой настройке опрашивать роутер (если задан TELNET — используем telnet) +ROUTER_USE_TELNET = bool(ROUTER_TELNET_HOST and ROUTER_TELNET_PASSWORD) + + +def load_domains_grouped(path: Path) -> list[tuple[str, str]]: + """ + Читает domains.txt с секциями [Россия] и [Зарубеж]. + Возвращает список пар (название_группы, домен). + """ + out: list[tuple[str, str]] = [] + current_group = "Прочее" + if not path.exists(): + return out + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + current_group = line[1:-1].strip() + continue + out.append((current_group, line)) + return out + + +def resolve_domain(domain: str) -> str | None: + """Возвращает один IPv4-адрес для домена или None.""" + try: + r = subprocess.run( + ["dig", "+short", "-4", domain], + capture_output=True, + text=True, + timeout=10, + ) + if r.returncode != 0: + return None + lines = [s.strip() for s in r.stdout.splitlines() if s.strip()] + for line in lines: + # первый похожий на IPv4 + if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", line): + return line + return None + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + + +def traceroute_hop2(ip: str) -> tuple[str, list[str]]: + """ + Запускает traceroute до ip с max TRACEROUTE_MAX_HOPS прыжками. + Возвращает ("VPN" | "Ethernet" | "unknown", список IP прыжков). + """ + hops: list[str] = [] + try: + r = subprocess.run( + ["traceroute", "-m", str(TRACEROUTE_MAX_HOPS), "-n", ip], + capture_output=True, + text=True, + timeout=TRACEROUTE_TIMEOUT, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return "unknown", [] + + # Парсим строки вида " 2 10.8.1.0 20.5 ms" или " 2 * * *" + for line in r.stdout.splitlines(): + # номер_прыжка IP время... + m = re.match(r"\s*\d+\s+([*\d.]+)", line) + if m: + hop = m.group(1).strip() + if hop != "*": + hops.append(hop) + + first_hops = hops[:VPN_IN_FIRST_N_HOPS] + if VPN_HOP_IP in first_hops: + return "VPN", hops + if len(hops) >= 2: + return "Ethernet", hops + # Один прыжок: хост (часто Google/CDN) не отвечает на traceroute по пути — виден только последний узел + if len(hops) == 1: + return "few_hops", hops + return "unknown", hops + + +def route_get_path(ip: str) -> str | None: + """ + Определяет маршрут до IP через «ip route get» на этом хосте. + Возвращает "VPN", "Ethernet" или None при ошибке. + На LAN-клиенте всегда виден только шлюз 192.168.1.1 — для реального маршрута настрой ROUTER_HOST. + """ + for ip_bin in ("/usr/sbin/ip", "/sbin/ip", "ip"): + try: + r = subprocess.run( + [ip_bin, "route", "get", ip], + capture_output=True, + text=True, + timeout=5, + ) + out = (r.stdout or "") + (r.stderr or "") + if not out.strip(): + continue + if "via " + VPN_HOP_IP in out or "via " + VPN_HOP_IP + " " in out: + return "VPN" + return "Ethernet" + except (FileNotFoundError, subprocess.TimeoutExpired): + continue + return None + + +def route_get_path_via_router_ssh(ip: str) -> str | None: + """Выполняет на роутере «ip route get list[tuple[str, str, str]] | None: + """ + Подключается к роутеру по Telnet (NDMS/Keenetic), логин, выполняет «show ip route», + парсит таблицу. Возвращает список (network_cidr, gateway, interface) или None. + """ + if not ROUTER_USE_TELNET or telnetlib is None: + return None + try: + tn = telnetlib.Telnet(ROUTER_TELNET_HOST, 23, timeout=ROUTER_TELNET_TIMEOUT) + tn.read_until(b"Login:", timeout=8) + tn.write(ROUTER_TELNET_USER.encode() + b"\n") + tn.read_until(b"Password:", timeout=5) + tn.write(ROUTER_TELNET_PASSWORD.encode() + b"\n") + tn.read_until(b">", timeout=8) + tn.write(b"show ip route\n") + raw = tn.read_until(b"(config)>", timeout=15) + tn.close() + except (OSError, EOFError) as e: + return None + text = raw.decode("utf-8", errors="replace") + routes: list[tuple[str, str, str]] = [] + for line in text.splitlines(): + line = line.strip() + # Строка вида "216.58.192.0/20 10.8.1.2 Wireguard0 ..." + if "/" not in line or line.startswith("("): + continue + parts = line.split() + if len(parts) >= 3: + net, gw, iface = parts[0], parts[1], parts[2] + if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$", net): + routes.append((net, gw, iface)) + return routes if routes else None + + +def find_route_for_ip(router_routes: list[tuple[str, str, str]], ip: str) -> str | None: + """ + По таблице маршрутов роутера (network, gateway, interface) находит маршрут для IP + (longest prefix match). Возвращает "VPN" если шлюз 10.8.1.x или интерфейс Wireguard0, иначе "Ethernet". + """ + try: + addr = IPv4Address(ip) + except ValueError: + return None + best: tuple[int, str, str] | None = None # (prefix_len, gateway, interface) + for net_cidr, gw, iface in router_routes: + try: + net = ip_network(net_cidr, strict=False) + if addr in net: + plen = net.prefixlen + if best is None or plen > best[0]: + best = (plen, gw, iface) + except ValueError: + continue + if best is None: + return None + _plen, gateway, interface = best + if gateway.startswith("10.8.1.") or "Wireguard" in interface: + return "VPN" + return "Ethernet" + + +def check_domain(domain: str, router_routes: list[tuple[str, str, str]] | None = None) -> dict: + ip = resolve_domain(domain) + if not ip: + return { + "domain": domain, + "ip": None, + "path": "error", + "path_label": "Ошибка резолва", + "hops": [], + } + path, hops = traceroute_hop2(ip) + # Если traceroute дал мало данных или неизвестно — смотрим маршрут: таблица с роутера (telnet) или SSH/локальный ip route get. + if path in ("few_hops", "unknown"): + if router_routes is not None: + route_path = find_route_for_ip(router_routes, ip) + hop_note = "(router)" + elif ROUTER_HOST: + route_path = route_get_path_via_router_ssh(ip) + hop_note = "(router)" + else: + route_path = route_get_path(ip) + hop_note = "(ip route get)" + if route_path is not None: + path = route_path + hops = hops + [hop_note] if hops else [hop_note] + path_labels = { + "VPN": "VPN", + "Ethernet": "Ethernet", + "few_hops": "Мало данных (1 прыжок)", + "unknown": "Неизвестно", + } + return { + "domain": domain, + "ip": ip, + "path": path, + "path_label": path_labels.get(path, path), + "hops": hops, + } + + +def _row_html(r: dict) -> str: + path = r.get("path") or "unknown" + if path == "VPN": + badge = 'VPN' + elif path == "Ethernet": + badge = 'Ethernet' + elif path == "error": + badge = 'Ошибка' + elif path == "few_hops": + badge = 'Мало данных' + else: + badge = '?' + ip = r.get("ip") or "—" + hops_s = ", ".join(r.get("hops") or []) or "—" + return f"{r['domain']}{ip}{badge}{hops_s}" + + +def build_html(grouped_results: dict[str, list[dict]], gen_time: str, out_path: Path | None) -> str: + sections_html = [] + for group_name, results in grouped_results.items(): + if not results: + continue + rows = [_row_html(r) for r in results] + table_body = "\n".join(rows) + sections_html.append( + f"

{group_name}

\n" + f" \n" + f" \n" + f" \n{table_body}\n \n
ДоменIPМаршрутПрыжки
" + ) + blocks = "\n\n".join(sections_html) + + html = f""" + + + + + + Маршрут к доменам (VPN / Ethernet) + + + +

Маршрут к доменам

+

Трафик через VPN (10.8.1.0) или напрямую (Ethernet). Обновлено: {gen_time}

+ +{blocks} + + +""" + if out_path: + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(html, encoding="utf-8") + return html + + +def main(): + script_dir = Path(__file__).resolve().parent + domains_path = script_dir / "domains.txt" + out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else script_dir / "output" + out_dir = Path(out_dir) + + grouped_domains = load_domains_grouped(domains_path) + if not grouped_domains: + grouped_domains = [("Зарубеж", "youtube.com"), ("Зарубеж", "instagram.com"), ("Зарубеж", "google.com")] + + router_routes: list[tuple[str, str, str]] | None = None + if ROUTER_USE_TELNET: + router_routes = fetch_router_routes_telnet() + + grouped_results: dict[str, list[dict]] = {} + all_results = [] + for group_name, domain in grouped_domains: + r = check_domain(domain, router_routes) + r["group"] = group_name + all_results.append(r) + grouped_results.setdefault(group_name, []).append(r) + + gen_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + payload = {"updated": gen_time, "results": all_results} + + json_path = out_dir / "results.json" + out_dir.mkdir(parents=True, exist_ok=True) + json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + html_path = out_dir / "index.html" + build_html(grouped_results, gen_time, html_path) + + total = len(all_results) + print(f"OK: {total} доменов → {html_path} / {json_path}") + + +if __name__ == "__main__": + main() diff --git a/homelab/vpn-route-check/deploy-on-proxmox.sh b/homelab/vpn-route-check/deploy-on-proxmox.sh new file mode 100755 index 0000000..5bdf9a4 --- /dev/null +++ b/homelab/vpn-route-check/deploy-on-proxmox.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Запускать на хосте Proxmox (ssh root@192.168.1.150). +# Копирует папку vpn-route-check в контейнер 100 и поднимает Docker. +set -e +CT=100 +SRC="$(cd "$(dirname "$0")" && pwd)" +echo "Источник: $SRC" +echo "Копирую в контейнер $CT..." +TAR="/tmp/vpn-route-check.tar" +tar -C "$SRC" -cf "$TAR" . +pct exec "$CT" -- mkdir -p /opt/docker/vpn-route-check +pct push "$CT" "$TAR" /tmp/vpn-route-check.tar +pct exec "$CT" -- tar -xf /tmp/vpn-route-check.tar -C /opt/docker/vpn-route-check +pct exec "$CT" -- rm -f /tmp/vpn-route-check.tar +rm -f "$TAR" +echo "Запуск Docker Compose в контейнере $CT..." +pct exec "$CT" -- bash -c 'cd /opt/docker/vpn-route-check && docker compose up -d --build' +echo "Готово. Сервис: http://192.168.1.100:8765" diff --git a/homelab/vpn-route-check/docker-compose.yml b/homelab/vpn-route-check/docker-compose.yml new file mode 100644 index 0000000..083af83 --- /dev/null +++ b/homelab/vpn-route-check/docker-compose.yml @@ -0,0 +1,17 @@ +services: + vpn-route-check: + build: . + container_name: vpn-route-check + network_mode: host + environment: + ROUTER_TELNET_HOST: "192.168.1.1" + ROUTER_TELNET_USER: "admin" + ROUTER_TELNET_PASSWORD: "eC1cLwZPRoDVEY1" + volumes: + - vpn-route-check-data:/data + # опционально: свой список доменов с хоста + # - ./domains.txt:/app/domains.txt:ro + restart: unless-stopped + +volumes: + vpn-route-check-data: diff --git a/homelab/vpn-route-check/domains.txt b/homelab/vpn-route-check/domains.txt new file mode 100644 index 0000000..43b0e93 --- /dev/null +++ b/homelab/vpn-route-check/domains.txt @@ -0,0 +1,56 @@ +# Секции: [Россия] и [Зарубеж]. Домены по одному на строку; # — комментарий. + +[Россия] +ozon.ru +wildberries.ru +aliexpress.ru +kinopoisk.ru +ivi.ru +smotrim.ru +ria.ru +tass.ru +lenta.ru +gazeta.ru +vk.com +ok.ru +my.mail.ru +yandex.ru +ya.ru +mail.ru +sberbank.ru +tinkoff.ru +alfabank.ru +vtb.ru +gosuslugi.ru +www.gosuslugi.ru +hh.ru +katykhin.ru + +[Зарубеж] +facebook.com +instagram.com +twitter.com +x.com +threads.net +reddit.com +tiktok.com +youtube.com +youtu.be +openai.com +chat.openai.com +claude.ai +anthropic.com +gemini.google.com +huggingface.co +midjourney.com +leonardo.ai +aistudio.google.com +ai.google.dev +telegram.org +discord.com +signal.org +whatsapp.com +github.com +gitlab.com +stackoverflow.com +npmjs.com diff --git a/homelab/vpn-route-check/entrypoint.sh b/homelab/vpn-route-check/entrypoint.sh new file mode 100755 index 0000000..b0d99d9 --- /dev/null +++ b/homelab/vpn-route-check/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -e +# Сначала поднимаем HTTP, чтобы страница была доступна даже при сбое проверки +python3 /app/serve_no_cache.py & +# Первый запуск проверки (при ошибке не выходим — сервер уже слушает) +python3 /app/check_routes.py /data || true +# Каждые 15 минут обновляем данные +while true; do + sleep 900 + python3 /app/check_routes.py /data || true +done diff --git a/homelab/vpn-route-check/homepage-widget.yaml b/homelab/vpn-route-check/homepage-widget.yaml new file mode 100644 index 0000000..7cdd178 --- /dev/null +++ b/homelab/vpn-route-check/homepage-widget.yaml @@ -0,0 +1,7 @@ +# Фрагмент для /opt/docker/homepage/config/services.yaml (карточка как «Обращения (логи NPM)»: ссылка + пинг, без iframe) + +- Маршруты VPN: + icon: shield-check.png + href: http://192.168.1.100:8765 + description: "Проверка: трафик к доменам через VPN или Ethernet (обновление каждые 15 мин)" + ping: http://192.168.1.100:8765 diff --git a/homelab/vpn-route-check/serve_no_cache.py b/homelab/vpn-route-check/serve_no_cache.py new file mode 100644 index 0000000..b3d86aa --- /dev/null +++ b/homelab/vpn-route-check/serve_no_cache.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Минимальный HTTP-сервер для /data с заголовками no-cache, чтобы браузер не кэшировал страницу.""" +import http.server +import os + +DIR = os.environ.get("SERVE_DIR", "/data") +PORT = int(os.environ.get("SERVE_PORT", "8765")) + + +class NoCacheHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=DIR, **kwargs) + + def end_headers(self): + self.send_header("Cache-Control", "no-store, no-cache, must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + super().end_headers() + + +if __name__ == "__main__": + http.server.HTTPServer(("", PORT), NoCacheHandler).serve_forever()