feat(early-access): implement EA filtering and UI improvements

Add Early Access version support with filtering and improved UI:

Backend:
- Add is_early_access and early_access_ends_at fields to ModelVersionRecord
- Implement two-phase EA detection (bulk API + single API enrichment)
- Add hide_early_access_updates setting to filter EA updates
- Update has_update() and has_updates_bulk() to respect EA filter setting
- Add _enrich_early_access_details() for precise EA time fetching
- Fix setting propagation through base_model_service and model_update_service

Frontend:
- Add smart relative time display for EA (in Xh, in Xd, or date)
- Replace EA label with clock icon in metadata (fa-clock)
- Show Download button with bolt icon for EA versions (fa-bolt)
- Change EA badge color to #F59F00 (CivitAI Buzz theme)
- Fix toggle UI for hide_early_access_updates setting
- Add translation keys for EA time formatting

Tests:
- Update all tests to pass with new EA functionality
- Add test coverage for EA filtering logic

Closes #815
This commit is contained in:
Will Miao
2026-02-20 10:32:51 +08:00
parent e8b37365a6
commit 67869f19ff
22 changed files with 506 additions and 31 deletions

View File

@@ -426,6 +426,10 @@
"any": "Jede verfügbare Aktualisierung markieren"
}
},
"hideEarlyAccessUpdates": {
"label": "[TODO: Translate] Hide Early Access Updates",
"help": "[TODO: Translate] When enabled, models with only early access updates will not show 'Update available' badge"
},
"misc": {
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen"
@@ -1031,12 +1035,19 @@
},
"labels": {
"unnamed": "Unbenannte Version",
"noDetails": "Keine zusätzlichen Details"
"noDetails": "Keine zusätzlichen Details",
"earlyAccess": "[TODO: Translate] Early Access"
},
"eaTime": {
"endingSoon": "[TODO: Translate] ending soon",
"hours": "[TODO: Translate] in {count}h",
"days": "[TODO: Translate] in {count}d"
},
"badges": {
"current": "Aktuelle Version",
"inLibrary": "In der Bibliothek",
"newer": "Neuere Version",
"earlyAccess": "[TODO: Translate] Early Access",
"ignored": "Ignoriert"
},
"actions": {
@@ -1044,6 +1055,7 @@
"delete": "Löschen",
"ignore": "Ignorieren",
"unignore": "Ignorierung aufheben",
"earlyAccessTooltip": "[TODO: Translate] Requires early access purchase",
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
"viewLocalVersions": "Alle lokalen Versionen anzeigen",

View File

@@ -426,6 +426,10 @@
"any": "Flag any available update"
}
},
"hideEarlyAccessUpdates": {
"label": "Hide Early Access Updates",
"help": "When enabled, models with only early access updates will not show 'Update available' badge"
},
"misc": {
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard"
@@ -1031,12 +1035,19 @@
},
"labels": {
"unnamed": "Untitled Version",
"noDetails": "No additional details"
"noDetails": "No additional details",
"earlyAccess": "EA"
},
"eaTime": {
"endingSoon": "ending soon",
"hours": "in {count}h",
"days": "in {count}d"
},
"badges": {
"current": "Current Version",
"inLibrary": "In Library",
"newer": "Newer Version",
"earlyAccess": "Early Access",
"ignored": "Ignored"
},
"actions": {
@@ -1044,6 +1055,7 @@
"delete": "Delete",
"ignore": "Ignore",
"unignore": "Unignore",
"earlyAccessTooltip": "Requires early access purchase",
"resumeModelUpdates": "Resume updates for this model",
"ignoreModelUpdates": "Ignore updates for this model",
"viewLocalVersions": "View all local versions",

View File

@@ -426,6 +426,10 @@
"any": "Marcar cualquier actualización disponible"
}
},
"hideEarlyAccessUpdates": {
"label": "[TODO: Translate] Hide Early Access Updates",
"help": "[TODO: Translate] When enabled, models with only early access updates will not show 'Update available' badge"
},
"misc": {
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles"
@@ -1031,12 +1035,19 @@
},
"labels": {
"unnamed": "Versión sin nombre",
"noDetails": "Sin detalles adicionales"
"noDetails": "Sin detalles adicionales",
"earlyAccess": "[TODO: Translate] Early Access"
},
"eaTime": {
"endingSoon": "[TODO: Translate] ending soon",
"hours": "[TODO: Translate] in {count}h",
"days": "[TODO: Translate] in {count}d"
},
"badges": {
"current": "Versión actual",
"inLibrary": "En la biblioteca",
"newer": "Versión más reciente",
"earlyAccess": "[TODO: Translate] Early Access",
"ignored": "Ignorada"
},
"actions": {
@@ -1044,6 +1055,7 @@
"delete": "Eliminar",
"ignore": "Ignorar",
"unignore": "Dejar de ignorar",
"earlyAccessTooltip": "[TODO: Translate] Requires early access purchase",
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
"viewLocalVersions": "Ver todas las versiones locales",

View File

@@ -426,6 +426,10 @@
"any": "Signaler nimporte quelle mise à jour disponible"
}
},
"hideEarlyAccessUpdates": {
"label": "[TODO: Translate] Hide Early Access Updates",
"help": "[TODO: Translate] When enabled, models with only early access updates will not show 'Update available' badge"
},
"misc": {
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers"
@@ -1031,12 +1035,19 @@
},
"labels": {
"unnamed": "Version sans nom",
"noDetails": "Aucun détail supplémentaire"
"noDetails": "Aucun détail supplémentaire",
"earlyAccess": "[TODO: Translate] Early Access"
},
"eaTime": {
"endingSoon": "[TODO: Translate] ending soon",
"hours": "[TODO: Translate] in {count}h",
"days": "[TODO: Translate] in {count}d"
},
"badges": {
"current": "Version actuelle",
"inLibrary": "Dans la bibliothèque",
"newer": "Version plus récente",
"earlyAccess": "[TODO: Translate] Early Access",
"ignored": "Ignorée"
},
"actions": {
@@ -1044,6 +1055,7 @@
"delete": "Supprimer",
"ignore": "Ignorer",
"unignore": "Ne plus ignorer",
"earlyAccessTooltip": "[TODO: Translate] Requires early access purchase",
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
"viewLocalVersions": "Voir toutes les versions locales",

View File

@@ -426,6 +426,10 @@
"any": "תוויות לכל עדכון זמין"
}
},
"hideEarlyAccessUpdates": {
"label": "[TODO: Translate] Hide Early Access Updates",
"help": "[TODO: Translate] When enabled, models with only early access updates will not show 'Update available' badge"
},
"misc": {
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח"
@@ -1031,12 +1035,19 @@
},
"labels": {
"unnamed": "גרסה ללא שם",
"noDetails": "אין פרטים נוספים"
"noDetails": "אין פרטים נוספים",
"earlyAccess": "[TODO: Translate] Early Access"
},
"eaTime": {
"endingSoon": "[TODO: Translate] ending soon",
"hours": "[TODO: Translate] in {count}h",
"days": "[TODO: Translate] in {count}d"
},
"badges": {
"current": "גרסה נוכחית",
"inLibrary": "בספרייה",
"newer": "גרסה חדשה יותר",
"earlyAccess": "[TODO: Translate] Early Access",
"ignored": "התעלם"
},
"actions": {
@@ -1044,6 +1055,7 @@
"delete": "מחיקה",
"ignore": "התעלם",
"unignore": "בטל התעלמות",
"earlyAccessTooltip": "[TODO: Translate] Requires early access purchase",
"resumeModelUpdates": "המשך עדכונים עבור מודל זה",
"ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה",
"viewLocalVersions": "הצג את כל הגרסאות המקומיות",

View File

@@ -426,6 +426,10 @@
"any": "利用可能な更新すべてを表示"
}
},
"hideEarlyAccessUpdates": {
"label": "[TODO: Translate] Hide Early Access Updates",
"help": "[TODO: Translate] When enabled, models with only early access updates will not show 'Update available' badge"
},
"misc": {
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます"
@@ -1031,12 +1035,19 @@
},
"labels": {
"unnamed": "名前のないバージョン",
"noDetails": "追加情報なし"
"noDetails": "追加情報なし",
"earlyAccess": "[TODO: Translate] Early Access"
},
"eaTime": {
"endingSoon": "[TODO: Translate] ending soon",
"hours": "[TODO: Translate] in {count}h",
"days": "[TODO: Translate] in {count}d"
},
"badges": {
"current": "現在のバージョン",
"inLibrary": "ライブラリにあります",
"newer": "新しいバージョン",
"earlyAccess": "[TODO: Translate] Early Access",
"ignored": "無視中"
},
"actions": {
@@ -1044,6 +1055,7 @@
"delete": "削除",
"ignore": "無視",
"unignore": "無視を解除",
"earlyAccessTooltip": "[TODO: Translate] Requires early access purchase",
"resumeModelUpdates": "このモデルの更新を再開",
"ignoreModelUpdates": "このモデルの更新を無視",
"viewLocalVersions": "ローカルの全バージョンを表示",

View File

@@ -426,6 +426,10 @@
"any": "사용 가능한 모든 업데이트 표시"
}
},
"hideEarlyAccessUpdates": {
"label": "[TODO: Translate] Hide Early Access Updates",
"help": "[TODO: Translate] When enabled, models with only early access updates will not show 'Update available' badge"
},
"misc": {
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다"
@@ -1031,12 +1035,19 @@
},
"labels": {
"unnamed": "이름 없는 버전",
"noDetails": "추가 정보 없음"
"noDetails": "추가 정보 없음",
"earlyAccess": "[TODO: Translate] Early Access"
},
"eaTime": {
"endingSoon": "[TODO: Translate] ending soon",
"hours": "[TODO: Translate] in {count}h",
"days": "[TODO: Translate] in {count}d"
},
"badges": {
"current": "현재 버전",
"inLibrary": "라이브러리에 있음",
"newer": "최신 버전",
"earlyAccess": "[TODO: Translate] Early Access",
"ignored": "무시됨"
},
"actions": {
@@ -1044,6 +1055,7 @@
"delete": "삭제",
"ignore": "무시",
"unignore": "무시 해제",
"earlyAccessTooltip": "[TODO: Translate] Requires early access purchase",
"resumeModelUpdates": "이 모델 업데이트 재개",
"ignoreModelUpdates": "이 모델 업데이트 무시",
"viewLocalVersions": "로컬 버전 모두 보기",

View File

@@ -426,6 +426,10 @@
"any": "Отмечать любые доступные обновления"
}
},
"hideEarlyAccessUpdates": {
"label": "[TODO: Translate] Hide Early Access Updates",
"help": "[TODO: Translate] When enabled, models with only early access updates will not show 'Update available' badge"
},
"misc": {
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена"
@@ -1031,12 +1035,19 @@
},
"labels": {
"unnamed": "Версия без названия",
"noDetails": "Дополнительная информация отсутствует"
"noDetails": "Дополнительная информация отсутствует",
"earlyAccess": "[TODO: Translate] Early Access"
},
"eaTime": {
"endingSoon": "[TODO: Translate] ending soon",
"hours": "[TODO: Translate] in {count}h",
"days": "[TODO: Translate] in {count}d"
},
"badges": {
"current": "Текущая версия",
"inLibrary": "В библиотеке",
"newer": "Более новая версия",
"earlyAccess": "[TODO: Translate] Early Access",
"ignored": "Игнорируется"
},
"actions": {
@@ -1044,6 +1055,7 @@
"delete": "Удалить",
"ignore": "Игнорировать",
"unignore": "Перестать игнорировать",
"earlyAccessTooltip": "[TODO: Translate] Requires early access purchase",
"resumeModelUpdates": "Возобновить обновления для этой модели",
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
"viewLocalVersions": "Показать все локальные версии",

View File

@@ -426,6 +426,10 @@
"any": "显示任何可用更新"
}
},
"hideEarlyAccessUpdates": {
"label": "[TODO: Translate] Hide Early Access Updates",
"help": "[TODO: Translate] When enabled, models with only early access updates will not show 'Update available' badge"
},
"misc": {
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词"
@@ -1031,12 +1035,19 @@
},
"labels": {
"unnamed": "未命名版本",
"noDetails": "暂无更多信息"
"noDetails": "暂无更多信息",
"earlyAccess": "[TODO: Translate] Early Access"
},
"eaTime": {
"endingSoon": "[TODO: Translate] ending soon",
"hours": "[TODO: Translate] in {count}h",
"days": "[TODO: Translate] in {count}d"
},
"badges": {
"current": "当前版本",
"inLibrary": "已在库中",
"newer": "较新的版本",
"earlyAccess": "[TODO: Translate] Early Access",
"ignored": "已忽略"
},
"actions": {
@@ -1044,6 +1055,7 @@
"delete": "删除",
"ignore": "忽略",
"unignore": "取消忽略",
"earlyAccessTooltip": "[TODO: Translate] Requires early access purchase",
"resumeModelUpdates": "继续跟踪该模型的更新",
"ignoreModelUpdates": "忽略该模型的更新",
"viewLocalVersions": "查看所有本地版本",

View File

@@ -426,6 +426,10 @@
"any": "顯示任何可用更新"
}
},
"hideEarlyAccessUpdates": {
"label": "[TODO: Translate] Hide Early Access Updates",
"help": "[TODO: Translate] When enabled, models with only early access updates will not show 'Update available' badge"
},
"misc": {
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞"
@@ -1031,12 +1035,19 @@
},
"labels": {
"unnamed": "未命名版本",
"noDetails": "沒有其他資訊"
"noDetails": "沒有其他資訊",
"earlyAccess": "[TODO: Translate] Early Access"
},
"eaTime": {
"endingSoon": "[TODO: Translate] ending soon",
"hours": "[TODO: Translate] in {count}h",
"days": "[TODO: Translate] in {count}d"
},
"badges": {
"current": "目前版本",
"inLibrary": "已在庫中",
"newer": "較新版本",
"earlyAccess": "[TODO: Translate] Early Access",
"ignored": "已忽略"
},
"actions": {
@@ -1044,6 +1055,7 @@
"delete": "刪除",
"ignore": "忽略",
"unignore": "取消忽略",
"earlyAccessTooltip": "[TODO: Translate] Requires early access purchase",
"resumeModelUpdates": "恢復追蹤此模型的更新",
"ignoreModelUpdates": "忽略此模型的更新",
"viewLocalVersions": "檢視所有本地版本",

View File

@@ -204,6 +204,7 @@ class BaseModelRoutes(ABC):
service=service,
update_service=update_service,
metadata_provider_selector=get_metadata_provider,
settings_service=self._settings,
logger=logger,
)
return ModelHandlerSet(

View File

@@ -257,6 +257,7 @@ class SettingsHandler:
"auto_organize_exclusions",
"metadata_refresh_skip_paths",
"filter_presets",
"hide_early_access_updates",
)
_PROXY_KEYS = {

View File

@@ -1533,11 +1533,13 @@ class ModelUpdateHandler:
service,
update_service,
metadata_provider_selector,
settings_service,
logger: logging.Logger,
) -> None:
self._service = service
self._update_service = update_service
self._metadata_provider_selector = metadata_provider_selector
self._settings = settings_service
self._logger = logger
async def fetch_missing_civitai_license_data(
@@ -1774,6 +1776,9 @@ class ModelUpdateHandler:
{"success": False, "error": "Model not tracked"}, status=404
)
# Enrich EA versions with detailed info if needed
record = await self._enrich_early_access_details(record)
overrides = await self._build_version_context(record)
return web.json_response(
{
@@ -1812,6 +1817,78 @@ class ModelUpdateHandler:
)
return None
async def _enrich_early_access_details(self, record):
"""Fetch detailed EA info for versions missing exact end time.
Identifies versions with is_early_access=True but no early_access_ends_at,
then fetches detailed info from CivitAI to get the exact end time.
"""
if not record or not record.versions:
return record
# Find versions that need enrichment
versions_needing_update = []
for version in record.versions:
if version.is_early_access and not version.early_access_ends_at:
versions_needing_update.append(version)
if not versions_needing_update:
return record
provider = await self._get_civitai_provider()
if not provider:
return record
# Fetch detailed info for each version needing update
updated_versions = []
for version in versions_needing_update:
try:
version_info, error = await provider.get_model_version_info(
str(version.version_id)
)
if version_info and not error:
ea_ends_at = version_info.get("earlyAccessEndsAt")
if ea_ends_at:
# Create updated version with EA end time
from dataclasses import replace
updated_version = replace(
version, early_access_ends_at=ea_ends_at
)
updated_versions.append(updated_version)
self._logger.debug(
"Enriched EA info for version %s: %s",
version.version_id,
ea_ends_at,
)
except Exception as exc:
self._logger.debug(
"Failed to fetch EA details for version %s: %s",
version.version_id,
exc,
)
if not updated_versions:
return record
# Update record with enriched versions
version_map = {v.version_id: v for v in record.versions}
for updated in updated_versions:
version_map[updated.version_id] = updated
# Create new record with updated versions
from dataclasses import replace
new_record = replace(
record, versions=list(version_map.values()),
)
# Optionally persist to database for caching
# Note: We don't persist here to avoid side effects; the data will be
# refreshed on next bulk update if still needed
return new_record
async def _collect_models_missing_license(
self,
cache,
@@ -1978,6 +2055,15 @@ class ModelUpdateHandler:
version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None,
) -> Dict:
context = version_context or {}
# Check user setting for hiding early access versions
hide_early_access = False
if self._settings is not None:
try:
hide_early_access = bool(
self._settings.get("hide_early_access_updates", False)
)
except Exception:
pass
return {
"modelType": record.model_type,
"modelId": record.model_id,
@@ -1986,7 +2072,7 @@ class ModelUpdateHandler:
"inLibraryVersionIds": record.in_library_version_ids,
"lastCheckedAt": record.last_checked_at,
"shouldIgnore": record.should_ignore_model,
"hasUpdate": record.has_update(),
"hasUpdate": record.has_update(hide_early_access=hide_early_access),
"versions": [
self._serialize_version(version, context.get(version.version_id))
for version in record.versions
@@ -2002,6 +2088,24 @@ class ModelUpdateHandler:
preview_url = (
preview_override if preview_override is not None else version.preview_url
)
# Determine if version is currently in early access
# Two-phase detection: use exact end time if available, otherwise fallback to basic flag
is_early_access = False
if version.early_access_ends_at:
try:
from datetime import datetime, timezone
ea_date = datetime.fromisoformat(
version.early_access_ends_at.replace("Z", "+00:00")
)
is_early_access = ea_date > datetime.now(timezone.utc)
except (ValueError, AttributeError):
# If date parsing fails, treat as active EA (conservative)
is_early_access = True
elif getattr(version, 'is_early_access', False):
# Fallback to basic EA flag from bulk API
is_early_access = True
return {
"versionId": version.version_id,
"name": version.name,
@@ -2011,6 +2115,8 @@ class ModelUpdateHandler:
"previewUrl": preview_url,
"isInLibrary": version.is_in_library,
"shouldIgnore": version.should_ignore,
"earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access,
"filePath": context.get("file_path"),
"fileName": context.get("file_name"),
}

View File

@@ -380,6 +380,13 @@ class BaseModelService(ABC):
strategy = "same_base"
same_base_mode = strategy == "same_base"
# Check user setting for hiding early access updates
hide_early_access = False
try:
hide_early_access = bool(self.settings.get("hide_early_access_updates", False))
except Exception:
hide_early_access = False
records = None
resolved: Optional[Dict[int, bool]] = None
if same_base_mode:
@@ -388,7 +395,7 @@ class BaseModelService(ABC):
try:
records = await record_method(self.model_type, ordered_ids)
resolved = {
model_id: record.has_update()
model_id: record.has_update(hide_early_access=hide_early_access)
for model_id, record in records.items()
}
except Exception as exc:
@@ -406,7 +413,7 @@ class BaseModelService(ABC):
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
if callable(bulk_method):
try:
resolved = await bulk_method(self.model_type, ordered_ids)
resolved = await bulk_method(self.model_type, ordered_ids, hide_early_access=hide_early_access)
except Exception as exc:
logger.error(
"Failed to resolve update status in bulk for %s models (%s): %s",
@@ -419,7 +426,7 @@ class BaseModelService(ABC):
if resolved is None:
tasks = [
self.update_service.has_update(self.model_type, model_id)
self.update_service.has_update(self.model_type, model_id, hide_early_access=hide_early_access)
for model_id in ordered_ids
]
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -457,6 +464,7 @@ class BaseModelService(ABC):
flag = record.has_update_for_base(
threshold_version,
base_model,
hide_early_access=hide_early_access,
)
else:
flag = default_flag

View File

@@ -7,7 +7,8 @@ import os
import sqlite3
import time
from dataclasses import dataclass, replace
from typing import Dict, Iterable, List, Mapping, Optional, Sequence
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
from .errors import RateLimitError, ResourceNotFoundError
from .settings_manager import get_settings_manager
@@ -64,7 +65,9 @@ class ModelVersionRecord:
preview_url: Optional[str]
is_in_library: bool
should_ignore: bool
early_access_ends_at: Optional[str] = None
sort_index: int = 0
is_early_access: bool = False
@dataclass
@@ -97,8 +100,12 @@ class ModelUpdateRecord:
return [version.version_id for version in self.versions if version.is_in_library]
def has_update(self) -> bool:
"""Return True when a non-ignored remote version newer than the newest local copy is available."""
def has_update(self, hide_early_access: bool = False) -> bool:
"""Return True when a non-ignored remote version newer than the newest local copy is available.
Args:
hide_early_access: If True, exclude early access versions from update check.
"""
if self.should_ignore_model:
return False
@@ -110,22 +117,56 @@ class ModelUpdateRecord:
if max_in_library is None:
return any(
not version.is_in_library and not version.should_ignore for version in self.versions
not version.is_in_library
and not version.should_ignore
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
for version in self.versions
)
for version in self.versions:
if version.is_in_library or version.should_ignore:
continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
if version.version_id > max_in_library:
return True
return False
@staticmethod
def _is_early_access_active(version: ModelVersionRecord) -> bool:
"""Check if a version is currently in early access period.
Uses two-phase detection:
1. If exact EA end time available (from single version API), use it for precise check
2. Otherwise fallback to basic EA flag (from bulk API)
"""
# Phase 2: Precise check with exact end time
if version.early_access_ends_at:
try:
ea_date = datetime.fromisoformat(
version.early_access_ends_at.replace("Z", "+00:00")
)
return ea_date > datetime.now(timezone.utc)
except (ValueError, AttributeError):
# If date parsing fails, treat as active EA (conservative)
return True
# Phase 1: Basic EA flag from bulk API
return version.is_early_access
def has_update_for_base(
self,
local_version_id: Optional[int],
local_base_model: Optional[str],
hide_early_access: bool = False,
) -> bool:
"""Return True when a newer remote version with the same base model exists."""
"""Return True when a newer remote version with the same base model exists.
Args:
local_version_id: The current local version id.
local_base_model: The base model to filter by.
hide_early_access: If True, exclude early access versions from update check.
"""
if self.should_ignore_model:
return False
@@ -153,6 +194,8 @@ class ModelUpdateRecord:
for version in self.versions:
if version.is_in_library or version.should_ignore:
continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
version_base = _normalize_base_model(version.base_model)
if version_base != normalized_base:
continue
@@ -268,6 +311,14 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions "
"ADD COLUMN should_ignore INTEGER NOT NULL DEFAULT 0"
),
"early_access_ends_at": (
"ALTER TABLE model_update_versions "
"ADD COLUMN early_access_ends_at TEXT"
),
"is_early_access": (
"ALTER TABLE model_update_versions "
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
),
}
for column, statement in migrations.items():
@@ -367,6 +418,8 @@ class ModelUpdateService:
preview_url TEXT,
is_in_library INTEGER NOT NULL DEFAULT 0,
should_ignore INTEGER NOT NULL DEFAULT 0,
early_access_ends_at TEXT,
is_early_access INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
)
@@ -384,6 +437,8 @@ class ModelUpdateService:
"preview_url",
"is_in_library",
"should_ignore",
"early_access_ends_at",
"is_early_access",
]
defaults = {
"sort_index": "0",
@@ -394,6 +449,8 @@ class ModelUpdateService:
"preview_url": "NULL",
"is_in_library": "0",
"should_ignore": "0",
"early_access_ends_at": "NULL",
"is_early_access": "0",
}
select_parts = []
@@ -667,6 +724,8 @@ class ModelUpdateService:
is_in_library=False,
should_ignore=should_ignore,
sort_index=len(versions),
early_access_ends_at=None,
is_early_access=False,
)
)
@@ -686,16 +745,17 @@ class ModelUpdateService:
async with self._lock:
return self._get_record(model_type, model_id)
async def has_update(self, model_type: str, model_id: int) -> bool:
async def has_update(self, model_type: str, model_id: int, hide_early_access: bool = False) -> bool:
"""Determine if a model has updates pending."""
record = await self.get_record(model_type, model_id)
return record.has_update() if record else False
return record.has_update(hide_early_access=hide_early_access) if record else False
async def has_updates_bulk(
self,
model_type: str,
model_ids: Sequence[int],
hide_early_access: bool = False,
) -> Dict[int, bool]:
"""Return update availability for each model id in a single database pass."""
@@ -707,7 +767,7 @@ class ModelUpdateService:
records = self._get_records_bulk(model_type, normalized_ids)
return {
model_id: records.get(model_id).has_update() if records.get(model_id) else False
model_id: records.get(model_id).has_update(hide_early_access=hide_early_access) if records.get(model_id) else False
for model_id in normalized_ids
}
@@ -987,6 +1047,8 @@ class ModelUpdateService:
is_in_library=True,
should_ignore=ignore_map.get(missing_id, False),
sort_index=len(versions),
early_access_ends_at=None,
is_early_access=False,
)
)
@@ -1029,6 +1091,8 @@ class ModelUpdateService:
is_in_library=version_id in local_set,
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
sort_index=sort_map.get(version_id, index),
early_access_ends_at=remote_version.early_access_ends_at,
is_early_access=remote_version.is_early_access,
)
)
@@ -1055,6 +1119,8 @@ class ModelUpdateService:
is_in_library=True,
should_ignore=ignore_map.get(version_id, False),
sort_index=len(versions),
early_access_ends_at=None,
is_early_access=False,
)
)
@@ -1120,6 +1186,11 @@ class ModelUpdateService:
released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt"))
size_bytes = self._extract_size_bytes(entry.get("files"))
preview_url = self._extract_preview_url(entry.get("images"))
early_access_ends_at = _normalize_string(entry.get("earlyAccessEndsAt"))
# Check availability field from bulk API for basic EA detection
availability = _normalize_string(entry.get("availability"))
is_early_access = availability == "EarlyAccess"
return ModelVersionRecord(
version_id=version_id,
@@ -1130,7 +1201,9 @@ class ModelUpdateService:
preview_url=preview_url,
is_in_library=False,
should_ignore=False,
early_access_ends_at=early_access_ends_at,
sort_index=index,
is_early_access=is_early_access,
)
def _extract_size_bytes(self, files) -> Optional[int]:
@@ -1231,7 +1304,8 @@ class ModelUpdateService:
version_rows = conn.execute(
f"""
SELECT model_id, version_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access
FROM model_update_versions
WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC
@@ -1252,7 +1326,9 @@ class ModelUpdateService:
preview_url=row["preview_url"],
is_in_library=bool(row["is_in_library"]),
should_ignore=bool(row["should_ignore"]),
early_access_ends_at=row["early_access_ends_at"],
sort_index=_normalize_int(row["sort_index"]) or 0,
is_early_access=bool(row["is_early_access"]),
)
)
@@ -1308,8 +1384,9 @@ class ModelUpdateService:
"""
INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
version.version_id,
@@ -1322,6 +1399,8 @@ class ModelUpdateService:
version.preview_url,
1 if version.is_in_library else 0,
1 if version.should_ignore else 0,
version.early_access_ends_at,
1 if version.is_early_access else 0,
),
)
conn.commit()

View File

@@ -387,3 +387,51 @@
min-width: 0;
}
}
/* Early Access styles - Buzz theme color (#F59F00) */
.version-badge-early-access {
background: color-mix(in oklch, #F59F00 25%, transparent);
color: #E67700;
border-color: color-mix(in oklch, #F59F00 55%, transparent);
}
[data-theme="dark"] .version-badge-early-access {
background: color-mix(in oklch, #F59F00 20%, transparent);
color: #F59F00;
border-color: color-mix(in oklch, #F59F00 45%, transparent);
}
.version-meta-ea {
color: #E67700;
font-weight: 600;
}
[data-theme="dark"] .version-meta-ea {
color: #F59F00;
}
/* Early Access row - gray out effect */
.model-version-row.is-early-access {
opacity: 0.85;
filter: grayscale(40%);
transition: opacity 0.2s ease, filter 0.2s ease;
}
.model-version-row.is-early-access:hover {
opacity: 0.95;
filter: grayscale(25%);
}
/* Early Access download button - Buzz theme color (#F59F00) */
.version-action-early-access {
background: color-mix(in oklch, #F59F00 15%, transparent);
color: #E67700;
border-color: color-mix(in oklch, #F59F00 50%, transparent);
cursor: not-allowed;
}
[data-theme="dark"] .version-action-early-access {
background: color-mix(in oklch, #F59F00 12%, transparent);
color: #F59F00;
border-color: color-mix(in oklch, #F59F00 40%, transparent);
}

View File

@@ -123,7 +123,70 @@ function formatDateLabel(value) {
});
}
function buildMetaMarkup(version) {
/**
* Format EA end time as smart relative time
* - < 1 day: "in Xh" (hours)
* - 1-7 days: "in Xd" (days)
* - > 7 days: "Jan 15" (short date)
*/
function formatEarlyAccessTime(endsAt) {
if (!endsAt) {
return null;
}
const endDate = new Date(endsAt);
if (Number.isNaN(endDate.getTime())) {
return null;
}
const now = new Date();
const diffMs = endDate.getTime() - now.getTime();
const diffHours = diffMs / (1000 * 60 * 60);
const diffDays = diffHours / 24;
if (diffHours < 1) {
return translate('modals.model.versions.eaTime.endingSoon', {}, 'ending soon');
}
if (diffHours < 24) {
const hours = Math.ceil(diffHours);
return translate(
'modals.model.versions.eaTime.hours',
{ count: hours },
`in ${hours}h`
);
}
if (diffDays <= 7) {
const days = Math.ceil(diffDays);
return translate(
'modals.model.versions.eaTime.days',
{ count: days },
`in ${days}d`
);
}
// More than 7 days: show short date
return endDate.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
}
function isEarlyAccessActive(version) {
// Two-phase detection:
// 1. Use pre-computed isEarlyAccess flag if available (from backend)
// 2. Otherwise check exact end time if available
if (typeof version.isEarlyAccess === 'boolean') {
return version.isEarlyAccess;
}
if (!version.earlyAccessEndsAt) {
return false;
}
try {
return new Date(version.earlyAccessEndsAt) > new Date();
} catch {
return false;
}
}
function buildMetaMarkup(version, options = {}) {
const segments = [];
if (version.baseModel) {
segments.push(
@@ -138,6 +201,14 @@ function buildMetaMarkup(version) {
segments.push(escapeHtml(formatFileSize(version.sizeBytes)));
}
// Add early access info if applicable
if (options.showEarlyAccess && isEarlyAccessActive(version)) {
const eaTime = formatEarlyAccessTime(version.earlyAccessEndsAt);
if (eaTime) {
segments.push(`<span class="version-meta-ea"><i class="fas fa-clock"></i> ${escapeHtml(eaTime)}</span>`);
}
}
if (!segments.length) {
return escapeHtml(
translate('modals.model.versions.labels.noDetails', {}, 'No additional details')
@@ -235,6 +306,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
const strategy = state?.global?.settings?.update_flag_strategy;
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
if (!sameBaseMode) {
return Boolean(record?.hasUpdate);
@@ -278,6 +350,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
if (version.isInLibrary || version.shouldIgnore) {
return false;
}
if (hideEarlyAccess && isEarlyAccessActive(version)) {
return false;
}
const versionBase = normalizeBaseModelName(version.baseModel);
if (versionBase !== normalizedBase) {
return false;
@@ -349,6 +424,7 @@ function renderRow(version, options) {
const isNewer =
typeof latestLibraryVersionId === 'number' &&
version.versionId > latestLibraryVersionId;
const isEarlyAccess = isEarlyAccessActive(version);
const badges = [];
if (isCurrent) {
@@ -361,6 +437,10 @@ function renderRow(version, options) {
badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info'));
}
if (isEarlyAccess) {
badges.push(buildBadge(translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access'), 'early-access'));
}
if (version.shouldIgnore) {
badges.push(buildBadge(translate('modals.model.versions.badges.ignored', {}, 'Ignored'), 'muted'));
}
@@ -377,8 +457,10 @@ function renderRow(version, options) {
const actions = [];
if (!version.isInLibrary) {
// Download button with optional EA bolt icon
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
actions.push(
`<button class="version-action version-action-primary" data-version-action="download">${escapeHtml(downloadLabel)}</button>`
`<button class="version-action version-action-primary" data-version-action="download">${downloadIcon}${escapeHtml(downloadLabel)}</button>`
);
} else if (version.filePath) {
actions.push(
@@ -402,7 +484,7 @@ function renderRow(version, options) {
);
const rowAttributes = [
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}"`,
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}${isEarlyAccess ? ' is-early-access' : ''}"`,
`data-version-id="${escapeHtml(version.versionId)}"`,
];
if (linkTarget) {
@@ -419,7 +501,7 @@ function renderRow(version, options) {
</div>
<div class="version-badges">${badges.join('')}</div>
<div class="version-meta">
${buildMetaMarkup(version)}
${buildMetaMarkup(version, { showEarlyAccess: true })}
</div>
</div>
<div class="version-actions">

View File

@@ -475,6 +475,12 @@ export class SettingsManager {
updateFlagStrategySelect.value = state.global.settings.update_flag_strategy || 'same_base';
}
// Set hide early access updates setting
const hideEarlyAccessUpdatesCheckbox = document.getElementById('hideEarlyAccessUpdates');
if (hideEarlyAccessUpdatesCheckbox) {
hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false;
}
// Set optimize example images setting
const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages');
if (optimizeExampleImagesCheckbox) {

View File

@@ -34,6 +34,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
compact_mode: false,
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
update_flag_strategy: 'same_base',
hide_early_access_updates: false,
auto_organize_exclusions: [],
metadata_refresh_skip_paths: [],
});

View File

@@ -355,6 +355,23 @@
{{ t('settings.updateFlagStrategy.help') }}
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="hideEarlyAccessUpdates">{{ t('settings.hideEarlyAccessUpdates.label') }}</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="hideEarlyAccessUpdates"
onchange="settingsManager.saveToggleSetting('hideEarlyAccessUpdates', 'hide_early_access_updates')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="input-help">
{{ t('settings.hideEarlyAccessUpdates.help') }}
</div>
</div>
</div>
<!-- Default Path Customization Section -->

View File

@@ -66,6 +66,7 @@ async def test_build_version_context_includes_static_urls():
service=service,
update_service=SimpleNamespace(),
metadata_provider_selector=lambda *_: None,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)
@@ -145,6 +146,7 @@ async def test_refresh_model_updates_filters_records_without_updates():
service=service,
update_service=update_service,
metadata_provider_selector=metadata_selector,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)
@@ -207,6 +209,7 @@ async def test_refresh_model_updates_with_target_ids():
service=service,
update_service=update_service,
metadata_provider_selector=metadata_selector,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)
@@ -258,6 +261,7 @@ async def test_refresh_model_updates_accepts_snake_case_ids():
service=service,
update_service=update_service,
metadata_provider_selector=metadata_selector,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)
@@ -337,6 +341,7 @@ async def test_fetch_missing_license_data_updates_metadata(monkeypatch):
service=DummyService(cache),
update_service=SimpleNamespace(),
metadata_provider_selector=metadata_selector,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)
@@ -423,6 +428,7 @@ async def test_fetch_missing_license_data_filters_model_ids(monkeypatch):
service=DummyService(cache),
update_service=SimpleNamespace(),
metadata_provider_selector=metadata_selector,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)

View File

@@ -79,7 +79,7 @@ class StubUpdateService:
self.bulk_calls = []
self.bulk_error = bulk_error
async def has_updates_bulk(self, model_type, model_ids):
async def has_updates_bulk(self, model_type, model_ids, hide_early_access: bool = False):
self.bulk_calls.append((model_type, list(model_ids)))
if self.bulk_error:
raise RuntimeError("bulk failure")
@@ -91,7 +91,7 @@ class StubUpdateService:
results[model_id] = result
return results
async def has_update(self, model_type, model_id):
async def has_update(self, model_type, model_id, hide_early_access: bool = False):
self.calls.append((model_type, model_id))
result = self.decisions.get(model_id, False)
if isinstance(result, Exception):