diff --git a/locales/de.json b/locales/de.json index f5b13d49..2444d90a 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index cfe28771..1d52dcd9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/es.json b/locales/es.json index 9eed4f91..e0b59f13 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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", diff --git a/locales/fr.json b/locales/fr.json index 6a85f0c1..a5705a36 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -426,6 +426,10 @@ "any": "Signaler n’importe 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", diff --git a/locales/he.json b/locales/he.json index a502036f..c876b918 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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": "הצג את כל הגרסאות המקומיות", diff --git a/locales/ja.json b/locales/ja.json index 1d7a46d2..da428106 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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": "ローカルの全バージョンを表示", diff --git a/locales/ko.json b/locales/ko.json index 13af8831..4ab31765 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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": "로컬 버전 모두 보기", diff --git a/locales/ru.json b/locales/ru.json index fd83b3a2..a8be1dbc 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Показать все локальные версии", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 288439a4..e2481a45 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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": "查看所有本地版本", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index c67e88b0..84c78fe3 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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": "檢視所有本地版本", diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py index fc1ac42e..c6af3de2 100644 --- a/py/routes/base_model_routes.py +++ b/py/routes/base_model_routes.py @@ -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( diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 548c2b09..46b71e85 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -257,6 +257,7 @@ class SettingsHandler: "auto_organize_exclusions", "metadata_refresh_skip_paths", "filter_presets", + "hide_early_access_updates", ) _PROXY_KEYS = { diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 59bd5c78..8a586bfc 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -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"), } diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index fbd9a6f2..f24f856d 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -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 diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index 4e686d02..c5e1eebf 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -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() diff --git a/static/css/components/lora-modal/versions.css b/static/css/components/lora-modal/versions.css index 929bf6d4..4a676b78 100644 --- a/static/css/components/lora-modal/versions.css +++ b/static/css/components/lora-modal/versions.css @@ -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); +} diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index dd4c12da..ba778244 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -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(``); + } + } + 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 ? ' ' : ''; actions.push( - `` + `` ); } 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) {