diff --git a/docs/priority_tags_help.md b/docs/priority_tags_help.md index 1a14cf35..3eab2826 100644 --- a/docs/priority_tags_help.md +++ b/docs/priority_tags_help.md @@ -32,6 +32,7 @@ When your path template contains `{first_tag}`, the app picks a folder name base - It checks the priority list from top to bottom. If a canonical tag or any of its aliases appear in the model tags, that canonical name becomes the folder name. - If no priority tags are found but the model has tags, the first tag that can be used as a folder name is chosen. - Tags that contain a comma, or that are longer than 50 characters, are treated as unusable and skipped: some uploaders pack their whole keyword list into a single tag. If every tag is unusable, the folder falls back to `no tags`. +- Civitai's structural labels, such as `base model`, describe the listing rather than the model, so the automatic fallback skips them too. Add one to your priority list if you really want it as a folder name. - If the model has no tags at all, the folder falls back to `no tags`. ### Example @@ -44,6 +45,7 @@ With a template like `/{model_type}/{first_tag}` and the priority entry list `ch | `["anime", "portrait"]` | `style` | `anime` hits the `style` entry, so its canonical label is used. | | `["portrait", "bw"]` | `portrait` | No priority match, so the first model tag is used. | | `["lora, character, rosie, ... face"]` | `no tags` | The only tag is a keyword dump, so it is skipped. | +| `["lora, character, ... face", "base model"]` | `no tags` | A keyword dump plus a Civitai label: nothing usable is left. | | `[]` | `no tags` | Nothing to match, so the fallback is applied. | ## 3. Save the Settings diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index b4b913eb..e12fa9b7 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -47,6 +47,7 @@ from ..utils.settings_paths import ( from ..utils.tag_priorities import ( PriorityTagEntry, collect_canonical_tags, + is_civitai_meta_tag, is_usable_path_tag, parse_priority_tag_string, resolve_priority_tag, @@ -1572,8 +1573,11 @@ class SettingsManager: # Fall back to the first tag that is usable as a folder name. The raw # tag list can contain keyword dumps that would become unusable folders - # and break path length limits, so skip those (#1119). + # and break path length limits, and Civitai mixes in structural labels + # like "base model" that mean nothing as a folder, so skip both (#1119). for tag in tags: + if is_civitai_meta_tag(tag): + continue if is_usable_path_tag(tag): return tag.strip() return "" diff --git a/py/utils/constants.py b/py/utils/constants.py index 9f81e0d4..09fe9330 100644 --- a/py/utils/constants.py +++ b/py/utils/constants.py @@ -273,6 +273,16 @@ CIVITAI_MODEL_TAGS = [ "action", ] +# Civitai tags that describe the listing rather than the model's content. +# Uploaders can also set these by hand, so they must not be picked as an +# automatic folder name; a user who wants one can still name it explicitly in +# their priority tag list. +CIVITAI_META_TAGS = frozenset( + { + "base model", + } +) + # Default priority tag configuration strings for each model type DEFAULT_PRIORITY_TAG_CONFIG = { "lora": ", ".join(CIVITAI_MODEL_TAGS), @@ -295,9 +305,11 @@ DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = { # Length guards for template placeholders that end up in file and folder names. # Windows enforces MAX_PATH (260 characters) on the full path and 255 on a -# single path component, and a model folder also has to leave room for the -# model file, its ".civitai.info"/".json" sidecars and preview images. Values -# stay well below those limits so the surrounding files still fit. +# single path component. A model folder also holds the model file, the +# ".metadata.json" sidecar written by LoRA Manager, preview images and the +# metadata files other tools drop next to the model (for example +# ".civitai.info", which LoRA Manager only reads), so names stay well below +# those limits. # # Tags get a much tighter budget than other names: some CivitAI uploaders dump # their whole keyword list into a single tag (see issue #1119), and such a tag diff --git a/py/utils/tag_priorities.py b/py/utils/tag_priorities.py index 6b85bc14..995d8ed0 100644 --- a/py/utils/tag_priorities.py +++ b/py/utils/tag_priorities.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Dict, Iterable, List, Optional, Sequence, Set -from .constants import MAX_PATH_TAG_LENGTH +from .constants import CIVITAI_META_TAGS, MAX_PATH_TAG_LENGTH @dataclass(frozen=True) @@ -128,3 +128,19 @@ def is_usable_path_tag(tag: object) -> bool: return False return len(candidate) <= MAX_PATH_TAG_LENGTH + + +def is_civitai_meta_tag(tag: object) -> bool: + """Return True for Civitai labels that describe the listing, not content. + + Civitai attaches structural tags such as "base model" to the same list as + real content tags. They carry no organisational meaning, so the automatic + fallback must not turn one into a folder name. A user who does want such a + folder can still put the label in their priority tag list, because explicit + priority matches bypass this check. + """ + + if not isinstance(tag, str): + return False + + return tag.strip().casefold() in CIVITAI_META_TAGS diff --git a/tests/services/test_download_manager_basic.py b/tests/services/test_download_manager_basic.py index 69a9504d..97fc974b 100644 --- a/tests/services/test_download_manager_basic.py +++ b/tests/services/test_download_manager_basic.py @@ -239,7 +239,11 @@ async def test_successful_download_uses_defaults( def test_calculate_relative_path_ignores_keyword_dump_tag(): - """The #1119 download flow: a keyword-dump tag must not become a folder.""" + """The #1119 download flow: the real tag list must not become a folder. + + The model's only two tags are the keyword dump and Civitai's "base model" + label, so nothing usable is left and the template falls back to "no tags". + """ keyword_dump = ( "lora, character, rosie, irish, redhead, auburn, freckles, green eyes, " "curly hair, woman, female, photorealistic, realistic, krea2, dark beast, " @@ -257,7 +261,7 @@ def test_calculate_relative_path_ignores_keyword_dump_tag(): "lora", ) - assert relative_path == "MappedModel/base model" + assert relative_path == "MappedModel/no tags" assert keyword_dump not in relative_path assert len(relative_path) < 50 diff --git a/tests/services/test_settings_manager.py b/tests/services/test_settings_manager.py index 3c6aee04..f06391ae 100644 --- a/tests/services/test_settings_manager.py +++ b/tests/services/test_settings_manager.py @@ -412,6 +412,40 @@ def test_resolve_priority_tag_skips_unusable_tags(manager): assert manager.resolve_priority_tag_for_model([" portrait "], "lora") == "portrait" +def test_resolve_priority_tag_skips_civitai_meta_tags(manager): + """Civitai's structural labels are not content, so they cannot be folders.""" + assert manager.resolve_priority_tag_for_model(["base model"], "lora") == "" + assert ( + manager.resolve_priority_tag_for_model(["Base Model"], "lora") == "" + ), "the meta tag check must be case-insensitive" + assert manager.resolve_priority_tag_for_model(["base model", " "], "lora") == "" + # A real tag after the label is still used. + assert ( + manager.resolve_priority_tag_for_model(["base model", "portrait"], "lora") + == "portrait" + ) + + +def test_resolve_priority_tag_meta_tag_can_be_opted_into(manager): + """An explicit priority entry still wins over the meta tag exclusion.""" + manager.settings["priority_tags"] = {"lora": "base model"} + + assert ( + manager.resolve_priority_tag_for_model(["base model", "portrait"], "lora") + == "base model" + ) + + +def test_resolve_priority_tag_real_1119_tag_list(manager): + """End to end for the reported model: both of its tags are unusable.""" + assert ( + manager.resolve_priority_tag_for_model( + [KEYWORD_DUMP_TAG, "base model"], "lora" + ) + == "" + ) + + def test_auto_set_default_roots(manager): # Clear any previously auto-set values to test fresh behavior manager.settings["default_lora_root"] = "" diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index f7d89e7b..efc35d9e 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -177,6 +177,20 @@ def test_calculate_relative_path_uses_next_usable_tag(isolated_settings): assert calculate_relative_path_for_model(model_data, "lora") == "Krea 2/portrait" +def test_calculate_relative_path_ignores_civitai_meta_tag(isolated_settings): + """Civitai's "base model" label is not content, so it is not a folder.""" + model_data = {"base_model": "Krea 2", "tags": ["base model"]} + + assert calculate_relative_path_for_model(model_data, "lora") == "Krea 2/no tags" + + +def test_calculate_relative_path_ignores_full_1119_tag_list(isolated_settings): + """The reported model carries only a keyword dump and the meta label.""" + model_data = {"base_model": "Krea 2", "tags": [KEYWORD_DUMP_TAG, "base model"]} + + assert calculate_relative_path_for_model(model_data, "lora") == "Krea 2/no tags" + + def test_calculate_relative_path_sanitizes_tag_segment(isolated_settings): """A tag with path separators must not create nested folders.""" model_data = {"base_model": "SDXL", "tags": ["a/b:c"]}