perf(appstore): быстрый синк Umbrel — иконки с CDN параллельно + стартовый синк
A1: иконки Umbrel тянутся пофайлово с GitHub Pages CDN в пуле из 16 потоков (<app-id>/icon.svg) вместо тяжёлого tarball галереи со скриншотами. Синк Umbrel: ~7.5 мин → ~13-17с, 379/379 иконок. Настройка UMBREL_APPSTORE_GALLERY_CDN заменяет UMBREL_APPSTORE_GALLERY_REPO. 404/сетевой сбой отдельного запроса просто пропускает иконку, не роняя синк. A2: на свежем деплое каталог пуст, а IntervalTrigger впервые сработает лишь через сутки. Теперь при пустом каталоге планировщик ставит разовый синк немедленно (trigger=date) — каталог наполняется на старте без ручной кнопки. Проверено на стенде: стартовый синк наполнил каталог 644 (runtipi 265 + umbrel 379) за 16.5с; эндпоинт логотипа отдаёт 200 image/svg+xml. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
WIKI.md
2
WIKI.md
@@ -788,7 +788,7 @@ Catalog of self-hosted applications with one-click install. Each app runs in its
|
||||
| `RUNTIPI_APPSTORE_REF` | `master` | branch/tag/commit (pin recommended) |
|
||||
| `UMBREL_APPSTORE_REPO` | `getumbrel/umbrel-apps` | Umbrel catalog source (used when `umbrel` is enabled) |
|
||||
| `UMBREL_APPSTORE_REF` | `master` | Umbrel branch/tag/commit |
|
||||
| `UMBREL_APPSTORE_GALLERY_REPO` | `getumbrel/umbrel-apps-gallery` | Umbrel icons source (`<app-id>/icon.svg`) |
|
||||
| `UMBREL_APPSTORE_GALLERY_CDN` | `https://getumbrel.github.io/umbrel-apps-gallery` | Umbrel icons CDN (`<app-id>/icon.svg`, fetched per-file in parallel) |
|
||||
| `APPSTORE_DATA_DIR` | `/app/data/appstore` | logo cache (mounted volume) |
|
||||
| `CATALOG_SYNC_INTERVAL_HOURS` | `24` | auto catalog sync interval |
|
||||
| `APPSTORE_HOST_ARCH` | `amd64` | target node arch (marks unsupported apps) |
|
||||
|
||||
@@ -63,5 +63,5 @@ APPSTORE_SOURCES=runtipi
|
||||
# Репозитории Umbrel (нужны только если включён источник umbrel):
|
||||
# UMBREL_APPSTORE_REPO=getumbrel/umbrel-apps
|
||||
# UMBREL_APPSTORE_REF=master
|
||||
# Иконки Umbrel лежат отдельно (<app-id>/icon.svg) — скачиваются одним tarball:
|
||||
# UMBREL_APPSTORE_GALLERY_REPO=getumbrel/umbrel-apps-gallery
|
||||
# Иконки Umbrel лежат отдельно (<app-id>/icon.svg) — тянутся пофайлово с CDN:
|
||||
# UMBREL_APPSTORE_GALLERY_CDN=https://getumbrel.github.io/umbrel-apps-gallery
|
||||
|
||||
@@ -82,10 +82,12 @@ class Settings(BaseSettings):
|
||||
# отдельным провайдером; см. UmbrelCatalogProvider.
|
||||
UMBREL_APPSTORE_REPO: str = Field(default="getumbrel/umbrel-apps", env="UMBREL_APPSTORE_REPO")
|
||||
UMBREL_APPSTORE_REF: str = Field(default="master", env="UMBREL_APPSTORE_REF")
|
||||
# Иконки Umbrel-приложений лежат не в umbrel-apps, а в отдельном репозитории
|
||||
# галереи (<app-id>/icon.svg). Скачивается одним tarball при синхронизации.
|
||||
UMBREL_APPSTORE_GALLERY_REPO: str = Field(
|
||||
default="getumbrel/umbrel-apps-gallery", env="UMBREL_APPSTORE_GALLERY_REPO")
|
||||
# Иконки Umbrel-приложений лежат не в umbrel-apps, а в галерее
|
||||
# (<app-id>/icon.svg). Тянем их пофайлово с GitHub Pages CDN параллельно —
|
||||
# это быстрее, чем качать тяжёлый tarball галереи (со скриншотами).
|
||||
UMBREL_APPSTORE_GALLERY_CDN: str = Field(
|
||||
default="https://getumbrel.github.io/umbrel-apps-gallery",
|
||||
env="UMBREL_APPSTORE_GALLERY_CDN")
|
||||
# Активные источники каталога (через запятую): "runtipi", "umbrel" или оба.
|
||||
# По умолчанию только runtipi — Umbrel включается явно.
|
||||
APPSTORE_SOURCES: str = Field(default="runtipi", env="APPSTORE_SOURCES")
|
||||
|
||||
@@ -17,6 +17,7 @@ import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
from abc import ABC, abstractmethod
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
@@ -151,10 +152,10 @@ class UmbrelCatalogProvider(CatalogProvider):
|
||||
PREFIX = "umbrel-"
|
||||
|
||||
def __init__(self, repo: Optional[str] = None, ref: Optional[str] = None,
|
||||
gallery_repo: Optional[str] = None):
|
||||
gallery_cdn: Optional[str] = None):
|
||||
self.repo = repo or settings.UMBREL_APPSTORE_REPO
|
||||
self.ref = ref or settings.UMBREL_APPSTORE_REF
|
||||
self.gallery_repo = gallery_repo or settings.UMBREL_APPSTORE_GALLERY_REPO
|
||||
self.gallery_cdn = gallery_cdn or settings.UMBREL_APPSTORE_GALLERY_CDN
|
||||
|
||||
def fetch(self) -> Dict[str, AppRaw]:
|
||||
archive = self._download_tarball(self.repo, self.ref)
|
||||
@@ -191,38 +192,48 @@ class UmbrelCatalogProvider(CatalogProvider):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Иконки — из отдельного репозитория галереи (<app-id>/icon.svg). Сбой
|
||||
# Иконки — из галереи Umbrel (<app-id>/icon.svg), пофайлово с CDN. Сбой
|
||||
# получения иконок не должен ронять синк каталога.
|
||||
try:
|
||||
icons = self._fetch_gallery_icons()
|
||||
raw_ids = [app_id[len(self.PREFIX):] for app_id in apps]
|
||||
icons = self._fetch_gallery_icons(raw_ids)
|
||||
for app_id, raw in apps.items():
|
||||
data = icons.get(app_id[len(self.PREFIX):])
|
||||
if data:
|
||||
raw.logo_bytes = data
|
||||
raw.logo_ext = ".svg"
|
||||
logger.info(f"[appstore] umbrel icons fetched: {len(icons)}/{len(apps)}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[appstore] umbrel gallery icons failed: {e}")
|
||||
|
||||
return apps
|
||||
|
||||
def _fetch_gallery_icons(self) -> Dict[str, bytes]:
|
||||
"""Скачать tarball галереи Umbrel и вернуть словарь raw_id → bytes(icon.svg)."""
|
||||
archive = self._download_tarball(self.gallery_repo, self.ref)
|
||||
icons: Dict[str, bytes] = {}
|
||||
try:
|
||||
with tarfile.open(archive, mode="r:gz") as tar:
|
||||
for member in tar:
|
||||
if not member.isfile():
|
||||
continue
|
||||
# путь вида "<repo>-<ref>/<app-id>/icon.svg"
|
||||
parts = member.name.split("/")
|
||||
if len(parts) == 3 and parts[2].lower() == "icon.svg":
|
||||
icons[parts[1]] = self._read(tar, member)
|
||||
finally:
|
||||
def _fetch_gallery_icons(self, raw_ids: List[str]) -> Dict[str, bytes]:
|
||||
"""Скачать icon.svg каждого приложения параллельно с CDN галереи Umbrel.
|
||||
|
||||
Быстрее тяжёлого tarball галереи (тот тянет и скриншоты). HTTP 404 → у
|
||||
приложения нет иконки (не ошибка); сетевой сбой отдельного запроса просто
|
||||
пропускает иконку, не роняя синк.
|
||||
"""
|
||||
base = (self.gallery_cdn or "").rstrip("/")
|
||||
if not base or not raw_ids:
|
||||
return {}
|
||||
|
||||
def _one(raw_id: str):
|
||||
url = f"{base}/{raw_id}/icon.svg"
|
||||
try:
|
||||
archive.unlink()
|
||||
r = httpx.get(url, timeout=15, follow_redirects=True)
|
||||
if r.status_code == 200 and r.content:
|
||||
return raw_id, r.content
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
return None
|
||||
|
||||
icons: Dict[str, bytes] = {}
|
||||
with ThreadPoolExecutor(max_workers=16, thread_name_prefix="umbrel_icons") as ex:
|
||||
for res in ex.map(_one, raw_ids):
|
||||
if res:
|
||||
icons[res[0]] = res[1]
|
||||
return icons
|
||||
|
||||
@staticmethod
|
||||
@@ -483,6 +494,28 @@ def start_catalog_scheduler():
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
|
||||
# Стартовый синк: на свежем деплое каталог пуст, а IntervalTrigger впервые
|
||||
# сработает только через CATALOG_SYNC_INTERVAL_HOURS. Чтобы не ждать 24ч,
|
||||
# запускаем разовый синк немедленно в фоне (не блокируя старт приложения).
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
is_empty = db.query(CatalogApp).count() == 0
|
||||
finally:
|
||||
db.close()
|
||||
if is_empty:
|
||||
aps.add_job(
|
||||
_scheduled_sync,
|
||||
trigger="date", # run_date по умолчанию = сейчас → разовый запуск
|
||||
id="appstore_catalog_initial_sync",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=600,
|
||||
)
|
||||
logger.info("App Store catalog empty — initial sync scheduled")
|
||||
except Exception as e:
|
||||
logger.warning(f"App Store initial catalog sync check failed: {e}")
|
||||
|
||||
if not aps.running:
|
||||
aps.start()
|
||||
logger.info(
|
||||
|
||||
Reference in New Issue
Block a user