mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Add tag filtering checkpoint
This commit is contained in:
@@ -45,6 +45,7 @@ class ApiRoutes:
|
|||||||
app.router.add_post('/loras/api/save-metadata', routes.save_metadata)
|
app.router.add_post('/loras/api/save-metadata', routes.save_metadata)
|
||||||
app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route
|
app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route
|
||||||
app.router.add_post('/api/move_models_bulk', routes.move_models_bulk)
|
app.router.add_post('/api/move_models_bulk', routes.move_models_bulk)
|
||||||
|
app.router.add_get('/api/top-tags', routes.get_top_tags) # Add new route for top tags
|
||||||
|
|
||||||
# Add update check routes
|
# Add update check routes
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
@@ -142,6 +143,10 @@ class ApiRoutes:
|
|||||||
'error': 'Invalid sort parameter'
|
'error': 'Invalid sort parameter'
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
|
# Parse tags filter parameter
|
||||||
|
tags = request.query.get('tags', '').split(',')
|
||||||
|
tags = [tag.strip() for tag in tags if tag.strip()]
|
||||||
|
|
||||||
# Get paginated data with search and filters
|
# Get paginated data with search and filters
|
||||||
result = await self.scanner.get_paginated_data(
|
result = await self.scanner.get_paginated_data(
|
||||||
page=page,
|
page=page,
|
||||||
@@ -151,7 +156,8 @@ class ApiRoutes:
|
|||||||
search=search,
|
search=search,
|
||||||
fuzzy=fuzzy,
|
fuzzy=fuzzy,
|
||||||
recursive=recursive,
|
recursive=recursive,
|
||||||
base_models=base_models # Pass base models filter
|
base_models=base_models, # Pass base models filter
|
||||||
|
tags=tags # Add tags parameter
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format the response data
|
# Format the response data
|
||||||
@@ -190,6 +196,8 @@ class ApiRoutes:
|
|||||||
"file_path": lora["file_path"].replace(os.sep, "/"),
|
"file_path": lora["file_path"].replace(os.sep, "/"),
|
||||||
"file_size": lora["size"],
|
"file_size": lora["size"],
|
||||||
"modified": lora["modified"],
|
"modified": lora["modified"],
|
||||||
|
"tags": lora["tags"],
|
||||||
|
"modelDescription": lora["modelDescription"],
|
||||||
"from_civitai": lora.get("from_civitai", True),
|
"from_civitai": lora.get("from_civitai", True),
|
||||||
"usage_tips": lora.get("usage_tips", ""),
|
"usage_tips": lora.get("usage_tips", ""),
|
||||||
"notes": lora.get("notes", ""),
|
"notes": lora.get("notes", ""),
|
||||||
@@ -335,6 +343,14 @@ class ApiRoutes:
|
|||||||
local_metadata['model_name'] = civitai_metadata['model'].get('name',
|
local_metadata['model_name'] = civitai_metadata['model'].get('name',
|
||||||
local_metadata.get('model_name'))
|
local_metadata.get('model_name'))
|
||||||
|
|
||||||
|
# Fetch additional model metadata (description and tags) if we have model ID
|
||||||
|
model_id = civitai_metadata['modelId']
|
||||||
|
if model_id:
|
||||||
|
model_metadata = await client.get_model_metadata(str(model_id))
|
||||||
|
if model_metadata:
|
||||||
|
local_metadata['modelDescription'] = model_metadata.get('description', '')
|
||||||
|
local_metadata['tags'] = model_metadata.get('tags', [])
|
||||||
|
|
||||||
# Update base model
|
# Update base model
|
||||||
local_metadata['base_model'] = civitai_metadata.get('baseModel')
|
local_metadata['base_model'] = civitai_metadata.get('baseModel')
|
||||||
|
|
||||||
@@ -708,6 +724,7 @@ class ApiRoutes:
|
|||||||
|
|
||||||
# Check if we already have the description stored in metadata
|
# Check if we already have the description stored in metadata
|
||||||
description = None
|
description = None
|
||||||
|
tags = []
|
||||||
if file_path:
|
if file_path:
|
||||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||||
if os.path.exists(metadata_path):
|
if os.path.exists(metadata_path):
|
||||||
@@ -715,38 +732,70 @@ class ApiRoutes:
|
|||||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
description = metadata.get('modelDescription')
|
description = metadata.get('modelDescription')
|
||||||
|
tags = metadata.get('tags', [])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading metadata from {metadata_path}: {e}")
|
logger.error(f"Error loading metadata from {metadata_path}: {e}")
|
||||||
|
|
||||||
# If description is not in metadata, fetch from CivitAI
|
# If description is not in metadata, fetch from CivitAI
|
||||||
if not description:
|
if not description:
|
||||||
logger.info(f"Fetching model description for model ID: {model_id}")
|
logger.info(f"Fetching model metadata for model ID: {model_id}")
|
||||||
description = await self.civitai_client.get_model_description(model_id)
|
model_metadata = await self.civitai_client.get_model_metadata(model_id)
|
||||||
|
|
||||||
# Save the description to metadata if we have a file path and got a description
|
if model_metadata:
|
||||||
if file_path and description:
|
description = model_metadata.get('description')
|
||||||
try:
|
tags = model_metadata.get('tags', [])
|
||||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
|
||||||
if os.path.exists(metadata_path):
|
# Save the metadata to file if we have a file path and got metadata
|
||||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
if file_path:
|
||||||
metadata = json.load(f)
|
try:
|
||||||
|
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||||
metadata['modelDescription'] = description
|
if os.path.exists(metadata_path):
|
||||||
|
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
metadata = json.load(f)
|
||||||
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
|
||||||
logger.info(f"Saved model description to metadata for {file_path}")
|
metadata['modelDescription'] = description
|
||||||
except Exception as e:
|
metadata['tags'] = tags
|
||||||
logger.error(f"Error saving model description to metadata: {e}")
|
|
||||||
|
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
||||||
|
logger.info(f"Saved model metadata to file for {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving model metadata: {e}")
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'description': description or "<p>No model description available.</p>"
|
'description': description or "<p>No model description available.</p>",
|
||||||
|
'tags': tags
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting model description: {e}", exc_info=True)
|
logger.error(f"Error getting model metadata: {e}", exc_info=True)
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_top_tags(self, request: web.Request) -> web.Response:
|
||||||
|
"""Handle request for top tags sorted by frequency"""
|
||||||
|
try:
|
||||||
|
# Parse query parameters
|
||||||
|
limit = int(request.query.get('limit', '20'))
|
||||||
|
|
||||||
|
# Validate limit
|
||||||
|
if limit < 1 or limit > 100:
|
||||||
|
limit = 20 # Default to a reasonable limit
|
||||||
|
|
||||||
|
# Get top tags
|
||||||
|
top_tags = await self.scanner.get_top_tags(limit)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'tags': top_tags
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting top tags: {str(e)}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Internal server error'
|
||||||
|
}, status=500)
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class LoraRoutes:
|
|||||||
"folder": lora["folder"],
|
"folder": lora["folder"],
|
||||||
"sha256": lora["sha256"],
|
"sha256": lora["sha256"],
|
||||||
"file_path": lora["file_path"].replace(os.sep, "/"),
|
"file_path": lora["file_path"].replace(os.sep, "/"),
|
||||||
|
"size": lora["size"],
|
||||||
|
"tags": lora["tags"],
|
||||||
|
"modelDescription": lora["modelDescription"],
|
||||||
|
"usage_tips": lora["usage_tips"],
|
||||||
|
"notes": lora["notes"],
|
||||||
"modified": lora["modified"],
|
"modified": lora["modified"],
|
||||||
"from_civitai": lora.get("from_civitai", True),
|
"from_civitai": lora.get("from_civitai", True),
|
||||||
"civitai": self._filter_civitai_data(lora.get("civitai", {}))
|
"civitai": self._filter_civitai_data(lora.get("civitai", {}))
|
||||||
|
|||||||
@@ -163,41 +163,52 @@ class CivitaiClient:
|
|||||||
logger.error(f"Error fetching model version info: {e}")
|
logger.error(f"Error fetching model version info: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_model_description(self, model_id: str) -> Optional[str]:
|
async def get_model_metadata(self, model_id: str) -> Optional[Dict]:
|
||||||
"""Fetch the model description from Civitai API
|
"""Fetch model metadata (description and tags) from Civitai API
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_id: The Civitai model ID
|
model_id: The Civitai model ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Optional[str]: The model description HTML or None if not found
|
Optional[Dict]: A dictionary containing model metadata or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
session = await self.session
|
session = await self.session
|
||||||
headers = self._get_request_headers()
|
headers = self._get_request_headers()
|
||||||
url = f"{self.base_url}/models/{model_id}"
|
url = f"{self.base_url}/models/{model_id}"
|
||||||
|
|
||||||
logger.info(f"Fetching model description from {url}")
|
logger.info(f"Fetching model metadata from {url}")
|
||||||
|
|
||||||
async with session.get(url, headers=headers) as response:
|
async with session.get(url, headers=headers) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
logger.warning(f"Failed to fetch model description: Status {response.status}")
|
logger.warning(f"Failed to fetch model metadata: Status {response.status}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
description = data.get('description')
|
|
||||||
|
|
||||||
if description:
|
# Extract relevant metadata
|
||||||
logger.info(f"Successfully retrieved description for model {model_id}")
|
metadata = {
|
||||||
return description
|
"description": data.get("description", ""),
|
||||||
|
"tags": data.get("tags", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata["description"] or metadata["tags"]:
|
||||||
|
logger.info(f"Successfully retrieved metadata for model {model_id}")
|
||||||
|
return metadata
|
||||||
else:
|
else:
|
||||||
logger.warning(f"No description found for model {model_id}")
|
logger.warning(f"No metadata found for model {model_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching model description: {e}", exc_info=True)
|
logger.error(f"Error fetching model metadata: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Keep old method for backward compatibility, delegating to the new one
|
||||||
|
async def get_model_description(self, model_id: str) -> Optional[str]:
|
||||||
|
"""Fetch the model description from Civitai API (Legacy method)"""
|
||||||
|
metadata = await self.get_model_metadata(model_id)
|
||||||
|
return metadata.get("description") if metadata else None
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""Close the session if it exists"""
|
"""Close the session if it exists"""
|
||||||
if self._session is not None:
|
if self._session is not None:
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ class LoraFileHandler(FileSystemEventHandler):
|
|||||||
# Scan new file
|
# Scan new file
|
||||||
lora_data = await self.scanner.scan_single_lora(file_path)
|
lora_data = await self.scanner.scan_single_lora(file_path)
|
||||||
if lora_data:
|
if lora_data:
|
||||||
|
# Update tags count
|
||||||
|
for tag in lora_data.get('tags', []):
|
||||||
|
self.scanner._tags_count[tag] = self.scanner._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
cache.raw_data.append(lora_data)
|
cache.raw_data.append(lora_data)
|
||||||
new_folders.add(lora_data['folder'])
|
new_folders.add(lora_data['folder'])
|
||||||
# Update hash index
|
# Update hash index
|
||||||
@@ -109,6 +113,16 @@ class LoraFileHandler(FileSystemEventHandler):
|
|||||||
needs_resort = True
|
needs_resort = True
|
||||||
|
|
||||||
elif action == 'remove':
|
elif action == 'remove':
|
||||||
|
# Find the lora to remove so we can update tags count
|
||||||
|
lora_to_remove = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
|
||||||
|
if lora_to_remove:
|
||||||
|
# Update tags count by reducing counts
|
||||||
|
for tag in lora_to_remove.get('tags', []):
|
||||||
|
if tag in self.scanner._tags_count:
|
||||||
|
self.scanner._tags_count[tag] = max(0, self.scanner._tags_count[tag] - 1)
|
||||||
|
if self.scanner._tags_count[tag] == 0:
|
||||||
|
del self.scanner._tags_count[tag]
|
||||||
|
|
||||||
# Remove from cache and hash index
|
# Remove from cache and hash index
|
||||||
logger.info(f"Removing {file_path} from cache")
|
logger.info(f"Removing {file_path} from cache")
|
||||||
self.scanner._hash_index.remove_by_path(file_path)
|
self.scanner._hash_index.remove_by_path(file_path)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class LoraScanner:
|
|||||||
self._initialization_task: Optional[asyncio.Task] = None
|
self._initialization_task: Optional[asyncio.Task] = None
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
self.file_monitor = None # Add this line
|
self.file_monitor = None # Add this line
|
||||||
|
self._tags_count = {} # Add a dictionary to store tag counts
|
||||||
|
|
||||||
def set_file_monitor(self, monitor):
|
def set_file_monitor(self, monitor):
|
||||||
"""Set file monitor instance"""
|
"""Set file monitor instance"""
|
||||||
@@ -90,13 +91,21 @@ class LoraScanner:
|
|||||||
# Clear existing hash index
|
# Clear existing hash index
|
||||||
self._hash_index.clear()
|
self._hash_index.clear()
|
||||||
|
|
||||||
|
# Clear existing tags count
|
||||||
|
self._tags_count = {}
|
||||||
|
|
||||||
# Scan for new data
|
# Scan for new data
|
||||||
raw_data = await self.scan_all_loras()
|
raw_data = await self.scan_all_loras()
|
||||||
|
|
||||||
# Build hash index
|
# Build hash index and tags count
|
||||||
for lora_data in raw_data:
|
for lora_data in raw_data:
|
||||||
if 'sha256' in lora_data and 'file_path' in lora_data:
|
if 'sha256' in lora_data and 'file_path' in lora_data:
|
||||||
self._hash_index.add_entry(lora_data['sha256'], lora_data['file_path'])
|
self._hash_index.add_entry(lora_data['sha256'], lora_data['file_path'])
|
||||||
|
|
||||||
|
# Count tags
|
||||||
|
if 'tags' in lora_data and lora_data['tags']:
|
||||||
|
for tag in lora_data['tags']:
|
||||||
|
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
# Update cache
|
# Update cache
|
||||||
self._cache = LoraCache(
|
self._cache = LoraCache(
|
||||||
@@ -158,7 +167,7 @@ class LoraScanner:
|
|||||||
|
|
||||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name',
|
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name',
|
||||||
folder: str = None, search: str = None, fuzzy: bool = False,
|
folder: str = None, search: str = None, fuzzy: bool = False,
|
||||||
recursive: bool = False, base_models: list = None):
|
recursive: bool = False, base_models: list = None, tags: list = None) -> Dict:
|
||||||
"""Get paginated and filtered lora data
|
"""Get paginated and filtered lora data
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -170,6 +179,7 @@ class LoraScanner:
|
|||||||
fuzzy: Use fuzzy matching for search
|
fuzzy: Use fuzzy matching for search
|
||||||
recursive: Include subfolders when folder filter is applied
|
recursive: Include subfolders when folder filter is applied
|
||||||
base_models: List of base models to filter by
|
base_models: List of base models to filter by
|
||||||
|
tags: List of tags to filter by
|
||||||
"""
|
"""
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
@@ -198,6 +208,13 @@ class LoraScanner:
|
|||||||
if item.get('base_model') in base_models
|
if item.get('base_model') in base_models
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Apply tag filtering
|
||||||
|
if tags and len(tags) > 0:
|
||||||
|
filtered_data = [
|
||||||
|
item for item in filtered_data
|
||||||
|
if any(tag in item.get('tags', []) for tag in tags)
|
||||||
|
]
|
||||||
|
|
||||||
# 应用搜索过滤
|
# 应用搜索过滤
|
||||||
if search:
|
if search:
|
||||||
if fuzzy:
|
if fuzzy:
|
||||||
@@ -311,12 +328,67 @@ class LoraScanner:
|
|||||||
|
|
||||||
# Convert to dict and add folder info
|
# Convert to dict and add folder info
|
||||||
lora_data = metadata.to_dict()
|
lora_data = metadata.to_dict()
|
||||||
|
# Try to fetch missing metadata from Civitai if needed
|
||||||
|
await self._fetch_missing_metadata(file_path, lora_data)
|
||||||
rel_path = os.path.relpath(file_path, root_path)
|
rel_path = os.path.relpath(file_path, root_path)
|
||||||
folder = os.path.dirname(rel_path)
|
folder = os.path.dirname(rel_path)
|
||||||
lora_data['folder'] = folder.replace(os.path.sep, '/')
|
lora_data['folder'] = folder.replace(os.path.sep, '/')
|
||||||
|
|
||||||
return lora_data
|
return lora_data
|
||||||
|
|
||||||
|
async def _fetch_missing_metadata(self, file_path: str, lora_data: Dict) -> None:
|
||||||
|
"""Fetch missing description and tags from Civitai if needed
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the lora file
|
||||||
|
lora_data: Lora metadata dictionary to update
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if we need to fetch additional metadata from Civitai
|
||||||
|
needs_metadata_update = False
|
||||||
|
model_id = None
|
||||||
|
|
||||||
|
# Check if we have Civitai model ID but missing metadata
|
||||||
|
if lora_data.get('civitai'):
|
||||||
|
# Try to get model ID directly from the correct location
|
||||||
|
model_id = lora_data['civitai'].get('modelId')
|
||||||
|
|
||||||
|
if model_id:
|
||||||
|
model_id = str(model_id)
|
||||||
|
# Check if tags are missing or empty
|
||||||
|
tags_missing = not lora_data.get('tags') or len(lora_data.get('tags', [])) == 0
|
||||||
|
|
||||||
|
# Check if description is missing or empty
|
||||||
|
desc_missing = not lora_data.get('modelDescription') or lora_data.get('modelDescription') in (None, "")
|
||||||
|
|
||||||
|
needs_metadata_update = tags_missing or desc_missing
|
||||||
|
|
||||||
|
# Fetch missing metadata if needed
|
||||||
|
if needs_metadata_update and model_id:
|
||||||
|
logger.info(f"Fetching missing metadata for {file_path} with model ID {model_id}")
|
||||||
|
from ..services.civitai_client import CivitaiClient
|
||||||
|
client = CivitaiClient()
|
||||||
|
model_metadata = await client.get_model_metadata(model_id)
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
if model_metadata:
|
||||||
|
logger.info(f"Updating metadata for {file_path} with model ID {model_id}")
|
||||||
|
|
||||||
|
# Update tags if they were missing
|
||||||
|
if model_metadata.get('tags') and (not lora_data.get('tags') or len(lora_data.get('tags', [])) == 0):
|
||||||
|
lora_data['tags'] = model_metadata['tags']
|
||||||
|
|
||||||
|
# Update description if it was missing
|
||||||
|
if model_metadata.get('description') and (not lora_data.get('modelDescription') or lora_data.get('modelDescription') in (None, "")):
|
||||||
|
lora_data['modelDescription'] = model_metadata['description']
|
||||||
|
|
||||||
|
# Save the updated metadata back to file
|
||||||
|
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||||
|
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(lora_data, f, indent=2, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
|
||||||
|
|
||||||
async def update_preview_in_cache(self, file_path: str, preview_url: str) -> bool:
|
async def update_preview_in_cache(self, file_path: str, preview_url: str) -> bool:
|
||||||
"""Update preview URL in cache for a specific lora
|
"""Update preview URL in cache for a specific lora
|
||||||
|
|
||||||
@@ -427,6 +499,15 @@ class LoraScanner:
|
|||||||
async def update_single_lora_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool:
|
async def update_single_lora_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool:
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
|
# Find the existing item to remove its tags from count
|
||||||
|
existing_item = next((item for item in cache.raw_data if item['file_path'] == original_path), None)
|
||||||
|
if existing_item and 'tags' in existing_item:
|
||||||
|
for tag in existing_item.get('tags', []):
|
||||||
|
if tag in self._tags_count:
|
||||||
|
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||||
|
if self._tags_count[tag] == 0:
|
||||||
|
del self._tags_count[tag]
|
||||||
|
|
||||||
# Remove old path from hash index if exists
|
# Remove old path from hash index if exists
|
||||||
self._hash_index.remove_by_path(original_path)
|
self._hash_index.remove_by_path(original_path)
|
||||||
|
|
||||||
@@ -460,6 +541,11 @@ class LoraScanner:
|
|||||||
# Update folders list
|
# Update folders list
|
||||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||||
|
|
||||||
|
# Update tags count with the new/updated tags
|
||||||
|
if 'tags' in metadata:
|
||||||
|
for tag in metadata.get('tags', []):
|
||||||
|
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
# Resort cache
|
# Resort cache
|
||||||
await cache.resort()
|
await cache.resort()
|
||||||
@@ -505,3 +591,26 @@ class LoraScanner:
|
|||||||
"""Get hash for a LoRA by its file path"""
|
"""Get hash for a LoRA by its file path"""
|
||||||
return self._hash_index.get_hash(file_path)
|
return self._hash_index.get_hash(file_path)
|
||||||
|
|
||||||
|
# Add new method to get top tags
|
||||||
|
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||||
|
"""Get top tags sorted by count
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of tags to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dictionaries with tag name and count, sorted by count
|
||||||
|
"""
|
||||||
|
# Make sure cache is initialized
|
||||||
|
await self.get_cached_data()
|
||||||
|
|
||||||
|
# Sort tags by count in descending order
|
||||||
|
sorted_tags = sorted(
|
||||||
|
[{"tag": tag, "count": count} for tag, count in self._tags_count.items()],
|
||||||
|
key=lambda x: x['count'],
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return limited number
|
||||||
|
return sorted_tags[:limit]
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ async def get_file_info(file_path: str) -> Optional[LoraMetadata]:
|
|||||||
notes="",
|
notes="",
|
||||||
from_civitai=True,
|
from_civitai=True,
|
||||||
preview_url=normalize_path(preview_url),
|
preview_url=normalize_path(preview_url),
|
||||||
|
tags=[],
|
||||||
|
modelDescription=""
|
||||||
)
|
)
|
||||||
|
|
||||||
# create metadata file
|
# create metadata file
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
from .model_utils import determine_base_model
|
from .model_utils import determine_base_model
|
||||||
@@ -17,8 +17,15 @@ class LoraMetadata:
|
|||||||
preview_url: str # Preview image URL
|
preview_url: str # Preview image URL
|
||||||
usage_tips: str = "{}" # Usage tips for the model, json string
|
usage_tips: str = "{}" # Usage tips for the model, json string
|
||||||
notes: str = "" # Additional notes
|
notes: str = "" # Additional notes
|
||||||
from_civitai: bool = True # Whether the lora is from Civitai
|
from_civitai: bool = True # Whether the lora is from Civitai
|
||||||
civitai: Optional[Dict] = None # Civitai API data if available
|
civitai: Optional[Dict] = None # Civitai API data if available
|
||||||
|
tags: List[str] = None # Model tags
|
||||||
|
modelDescription: str = "" # Full model description
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
# Initialize empty lists to avoid mutable default parameter issue
|
||||||
|
if self.tags is None:
|
||||||
|
self.tags = []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: Dict) -> 'LoraMetadata':
|
def from_dict(cls, data: Dict) -> 'LoraMetadata':
|
||||||
|
|||||||
@@ -699,4 +699,43 @@
|
|||||||
[data-theme="dark"] .model-description-content pre,
|
[data-theme="dark"] .model-description-content pre,
|
||||||
[data-theme="dark"] .model-description-content code {
|
[data-theme="dark"] .model-description-content code {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Model Tags styles */
|
||||||
|
.model-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-tag {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-tag i {
|
||||||
|
font-size: 0.85em;
|
||||||
|
opacity: 0.6;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-tag:hover {
|
||||||
|
background: oklch(var(--lora-accent) / 0.1);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-tag:hover i {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
@@ -237,6 +237,44 @@
|
|||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tag filter styles */
|
||||||
|
.tag-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-count {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .tag-count {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-filter.active .tag-count {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-loading, .tags-error, .no-tags {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-error {
|
||||||
|
color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
/* Filter actions */
|
/* Filter actions */
|
||||||
.filter-actions {
|
.filter-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -276,4 +314,4 @@
|
|||||||
right: 20px;
|
right: 20px;
|
||||||
top: 140px;
|
top: 140px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,9 +32,15 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add filter parameters if active
|
// Add filter parameters if active
|
||||||
if (state.filters && state.filters.baseModel && state.filters.baseModel.length > 0) {
|
if (state.filters) {
|
||||||
// Convert the array of base models to a comma-separated string
|
if (state.filters.tags && state.filters.tags.length > 0) {
|
||||||
params.append('base_models', state.filters.baseModel.join(','));
|
// Convert the array of tags to a comma-separated string
|
||||||
|
params.append('tags', state.filters.tags.join(','));
|
||||||
|
}
|
||||||
|
if (state.filters.baseModel && state.filters.baseModel.length > 0) {
|
||||||
|
// Convert the array of base models to a comma-separated string
|
||||||
|
params.append('base_models', state.filters.baseModel.join(','));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Loading loras with params:', params.toString());
|
console.log('Loading loras with params:', params.toString());
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export function createLoraCard(lora) {
|
|||||||
card.dataset.usage_tips = lora.usage_tips;
|
card.dataset.usage_tips = lora.usage_tips;
|
||||||
card.dataset.notes = lora.notes;
|
card.dataset.notes = lora.notes;
|
||||||
card.dataset.meta = JSON.stringify(lora.civitai || {});
|
card.dataset.meta = JSON.stringify(lora.civitai || {});
|
||||||
|
|
||||||
|
// Store tags and model description
|
||||||
|
if (lora.tags && Array.isArray(lora.tags)) {
|
||||||
|
card.dataset.tags = JSON.stringify(lora.tags);
|
||||||
|
}
|
||||||
|
if (lora.modelDescription) {
|
||||||
|
card.dataset.modelDescription = lora.modelDescription;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply selection state if in bulk mode and this card is in the selected set
|
// Apply selection state if in bulk mode and this card is in the selected set
|
||||||
if (state.bulkMode && state.selectedLoras.has(lora.file_path)) {
|
if (state.bulkMode && state.selectedLoras.has(lora.file_path)) {
|
||||||
@@ -86,7 +94,9 @@ export function createLoraCard(lora) {
|
|||||||
base_model: card.dataset.base_model,
|
base_model: card.dataset.base_model,
|
||||||
usage_tips: card.dataset.usage_tips,
|
usage_tips: card.dataset.usage_tips,
|
||||||
notes: card.dataset.notes,
|
notes: card.dataset.notes,
|
||||||
civitai: JSON.parse(card.dataset.meta || '{}')
|
civitai: JSON.parse(card.dataset.meta || '{}'),
|
||||||
|
tags: JSON.parse(card.dataset.tags || '[]'),
|
||||||
|
modelDescription: card.dataset.modelDescription || ''
|
||||||
};
|
};
|
||||||
showLoraModal(loraMeta);
|
showLoraModal(loraMeta);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function showLoraModal(lora) {
|
|||||||
<i class="fas fa-save"></i>
|
<i class="fas fa-save"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
${renderTags(lora.tags || [])}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -666,4 +667,31 @@ function formatFileSize(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to render model tags
|
||||||
|
function renderTags(tags) {
|
||||||
|
if (!tags || tags.length === 0) return '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="model-tags">
|
||||||
|
${tags.map(tag => `
|
||||||
|
<span class="model-tag" onclick="copyTag('${tag.replace(/'/g, "\\'")}')">
|
||||||
|
${tag}
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</span>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tag copy functionality
|
||||||
|
window.copyTag = async function(tag) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(tag);
|
||||||
|
showToast('Tag copied to clipboard', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy failed:', err);
|
||||||
|
showToast('Copy failed', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,7 +6,8 @@ import { resetAndReload } from '../api/loraApi.js';
|
|||||||
export class FilterManager {
|
export class FilterManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.filters = {
|
this.filters = {
|
||||||
baseModel: []
|
baseModel: [],
|
||||||
|
tags: []
|
||||||
};
|
};
|
||||||
|
|
||||||
this.filterPanel = document.getElementById('filterPanel');
|
this.filterPanel = document.getElementById('filterPanel');
|
||||||
@@ -34,6 +35,77 @@ export class FilterManager {
|
|||||||
this.loadFiltersFromStorage();
|
this.loadFiltersFromStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadTopTags() {
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
|
if (tagsContainer) {
|
||||||
|
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/top-tags?limit=20');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch tags');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Top tags:', data);
|
||||||
|
if (data.success && data.tags) {
|
||||||
|
this.createTagFilterElements(data.tags);
|
||||||
|
|
||||||
|
// After creating tag elements, mark any previously selected ones
|
||||||
|
this.updateTagSelections();
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid response format');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading top tags:', error);
|
||||||
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
|
if (tagsContainer) {
|
||||||
|
tagsContainer.innerHTML = '<div class="tags-error">Failed to load tags</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createTagFilterElements(tags) {
|
||||||
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
|
tagsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (!tags.length) {
|
||||||
|
tagsContainer.innerHTML = '<div class="no-tags">No tags available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const tagEl = document.createElement('div');
|
||||||
|
tagEl.className = 'filter-tag tag-filter';
|
||||||
|
// {tag: "name", count: number}
|
||||||
|
const tagName = tag.tag;
|
||||||
|
tagEl.dataset.tag = tagName;
|
||||||
|
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
|
||||||
|
|
||||||
|
// Add click handler to toggle selection and automatically apply
|
||||||
|
tagEl.addEventListener('click', async () => {
|
||||||
|
tagEl.classList.toggle('active');
|
||||||
|
|
||||||
|
if (tagEl.classList.contains('active')) {
|
||||||
|
if (!this.filters.tags.includes(tagName)) {
|
||||||
|
this.filters.tags.push(tagName);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.filters.tags = this.filters.tags.filter(t => t !== tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
|
// Auto-apply filter when tag is clicked
|
||||||
|
await this.applyFilters(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tagsContainer.appendChild(tagEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
createBaseModelTags() {
|
createBaseModelTags() {
|
||||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||||
if (!baseModelTagsContainer) return;
|
if (!baseModelTagsContainer) return;
|
||||||
@@ -69,10 +141,13 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleFilterPanel() {
|
toggleFilterPanel() {
|
||||||
|
const wasHidden = this.filterPanel.classList.contains('hidden');
|
||||||
|
|
||||||
this.filterPanel.classList.toggle('hidden');
|
this.filterPanel.classList.toggle('hidden');
|
||||||
|
|
||||||
// Mark selected filters
|
// If the panel is being opened, load the top tags and update selections
|
||||||
if (!this.filterPanel.classList.contains('hidden')) {
|
if (wasHidden) {
|
||||||
|
this.loadTopTags();
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,10 +167,21 @@ export class FilterManager {
|
|||||||
tag.classList.remove('active');
|
tag.classList.remove('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update model tags
|
||||||
|
const modelTags = document.querySelectorAll('.tag-filter');
|
||||||
|
modelTags.forEach(tag => {
|
||||||
|
const tagName = tag.dataset.tag;
|
||||||
|
if (this.filters.tags.includes(tagName)) {
|
||||||
|
tag.classList.add('active');
|
||||||
|
} else {
|
||||||
|
tag.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateActiveFiltersCount() {
|
updateActiveFiltersCount() {
|
||||||
const totalActiveFilters = this.filters.baseModel.length;
|
const totalActiveFilters = this.filters.baseModel.length + this.filters.tags.length;
|
||||||
|
|
||||||
if (totalActiveFilters > 0) {
|
if (totalActiveFilters > 0) {
|
||||||
this.activeFiltersCount.textContent = totalActiveFilters;
|
this.activeFiltersCount.textContent = totalActiveFilters;
|
||||||
@@ -119,7 +205,19 @@ export class FilterManager {
|
|||||||
if (this.hasActiveFilters()) {
|
if (this.hasActiveFilters()) {
|
||||||
this.filterButton.classList.add('active');
|
this.filterButton.classList.add('active');
|
||||||
if (showToastNotification) {
|
if (showToastNotification) {
|
||||||
showToast(`Filtering by ${this.filters.baseModel.length} base models`, 'success');
|
const baseModelCount = this.filters.baseModel.length;
|
||||||
|
const tagsCount = this.filters.tags.length;
|
||||||
|
|
||||||
|
let message = '';
|
||||||
|
if (baseModelCount > 0 && tagsCount > 0) {
|
||||||
|
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''} and ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
||||||
|
} else if (baseModelCount > 0) {
|
||||||
|
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''}`;
|
||||||
|
} else if (tagsCount > 0) {
|
||||||
|
message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(message, 'success');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.filterButton.classList.remove('active');
|
this.filterButton.classList.remove('active');
|
||||||
@@ -132,7 +230,8 @@ export class FilterManager {
|
|||||||
async clearFilters() {
|
async clearFilters() {
|
||||||
// Clear all filters
|
// Clear all filters
|
||||||
this.filters = {
|
this.filters = {
|
||||||
baseModel: []
|
baseModel: [],
|
||||||
|
tags: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
@@ -154,7 +253,14 @@ export class FilterManager {
|
|||||||
const savedFilters = localStorage.getItem('loraFilters');
|
const savedFilters = localStorage.getItem('loraFilters');
|
||||||
if (savedFilters) {
|
if (savedFilters) {
|
||||||
try {
|
try {
|
||||||
this.filters = JSON.parse(savedFilters);
|
const parsedFilters = JSON.parse(savedFilters);
|
||||||
|
|
||||||
|
// Ensure backward compatibility with older filter format
|
||||||
|
this.filters = {
|
||||||
|
baseModel: parsedFilters.baseModel || [],
|
||||||
|
tags: parsedFilters.tags || []
|
||||||
|
};
|
||||||
|
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
@@ -168,6 +274,6 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasActiveFilters() {
|
hasActiveFilters() {
|
||||||
return this.filters.baseModel.length > 0;
|
return this.filters.baseModel.length > 0 || this.filters.tags.length > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export const state = {
|
|||||||
previewVersions: new Map(),
|
previewVersions: new Map(),
|
||||||
searchManager: null,
|
searchManager: null,
|
||||||
filters: {
|
filters: {
|
||||||
baseModel: []
|
baseModel: [],
|
||||||
|
tags: [] // Make sure tags are included in state
|
||||||
},
|
},
|
||||||
bulkMode: false,
|
bulkMode: false,
|
||||||
selectedLoras: new Set(),
|
selectedLoras: new Set(),
|
||||||
|
|||||||
@@ -62,6 +62,13 @@
|
|||||||
<!-- Tags will be dynamically inserted here -->
|
<!-- Tags will be dynamically inserted here -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-section">
|
||||||
|
<h4>Tags</h4>
|
||||||
|
<div class="filter-tags" id="modelTagsFilter">
|
||||||
|
<!-- Top tags will be dynamically inserted here -->
|
||||||
|
<div class="tags-loading">Loading tags...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="filter-actions">
|
<div class="filter-actions">
|
||||||
<button class="clear-filters-btn" onclick="filterManager.clearFilters()">
|
<button class="clear-filters-btn" onclick="filterManager.clearFilters()">
|
||||||
Clear All Filters
|
Clear All Filters
|
||||||
|
|||||||
Reference in New Issue
Block a user