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

@@ -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()