Merge pull request #3629 from Spinnich/feat/filter-by-custom-tags

feat(roms): filter games by custom tags
This commit is contained in:
Georges-Antoine Assi
2026-06-29 10:27:18 -04:00
committed by GitHub
38 changed files with 338 additions and 6 deletions
+1
View File
@@ -652,4 +652,5 @@ class RomFiltersDict(TypedDict):
player_counts: list[str]
regions: list[str]
languages: list[str]
tags: list[str]
platforms: list[int]
+20
View File
@@ -462,6 +462,16 @@ def get_roms(
),
),
] = None,
tags: Annotated[
list[str] | None,
Query(
description=(
"Associated custom tag (parsed from the filename, e.g. Proto, Beta,"
" Demo). Multiple values are allowed by repeating the parameter, and"
" results that match any of the values will be returned."
),
),
] = None,
# Logic operators for multi-value filters
genres_logic: Annotated[
str,
@@ -523,6 +533,12 @@ def get_roms(
description="Logic operator for metadata providers filter: 'any' (OR), 'all' (AND) or 'none' (NOT).",
),
] = "any",
tags_logic: Annotated[
str,
Query(
description="Logic operator for tags filter: 'any' (OR), 'all' (AND) or 'none' (NOT).",
),
] = "any",
order_by: Annotated[
str,
Query(
@@ -587,6 +603,7 @@ def get_roms(
languages=languages,
player_counts=player_counts,
metadata_providers=metadata_providers,
tags=tags,
# Logic operators
genres_logic=genres_logic,
franchises_logic=franchises_logic,
@@ -598,6 +615,7 @@ def get_roms(
statuses_logic=statuses_logic,
player_counts_logic=player_counts_logic,
metadata_providers_logic=metadata_providers_logic,
tags_logic=tags_logic,
group_by_meta_id=group_by_meta_id,
updated_after=updated_after,
include_file_stats=True,
@@ -626,6 +644,7 @@ def get_roms(
or languages
or player_counts
or metadata_providers
or tags
or updated_after
or matched is not None
or favorite is not None
@@ -663,6 +682,7 @@ def get_roms(
player_counts=[],
regions=[],
languages=[],
tags=[],
platforms=[],
)
if with_filter_values:
@@ -369,6 +369,7 @@ class DBCollectionsHandler(DBBaseHandler):
age_ratings = convert_legacy_filter("age_ratings", "selected_age_rating")
regions = convert_legacy_filter("regions", "selected_region")
languages = convert_legacy_filter("languages", "selected_language")
tags = convert_legacy_filter("tags", "selected_tag")
statuses = convert_legacy_filter("statuses", "selected_status")
# Use the existing filter_roms method with the stored criteria
@@ -397,6 +398,7 @@ class DBCollectionsHandler(DBBaseHandler):
statuses=statuses,
regions=regions,
languages=languages,
tags=tags,
metadata_providers=criteria.get("metadata_providers"),
# Logic operators for multi-value filters
genres_logic=criteria.get("genres_logic", "any"),
@@ -408,6 +410,7 @@ class DBCollectionsHandler(DBBaseHandler):
languages_logic=criteria.get("languages_logic", "any"),
statuses_logic=criteria.get("statuses_logic", "any"),
metadata_providers_logic=criteria.get("metadata_providers_logic", "any"),
tags_logic=criteria.get("tags_logic", "any"),
user_id=user_id,
order_by=criteria.get("order_by", "name"),
order_dir=criteria.get("order_dir", "asc"),
+25 -1
View File
@@ -696,6 +696,19 @@ class DBRomsHandler(DBBaseHandler):
condition = op(Rom.languages, values, session=session)
return query.filter(~condition) if match_none else query.filter(condition)
def _filter_by_tags(
self,
query: Query,
*,
session: Session,
values: Sequence[str],
match_all: bool = False,
match_none: bool = False,
) -> Query:
op = json_array_contains_all if match_all else json_array_contains_any
condition = op(Rom.tags, values, session=session)
return query.filter(~condition) if match_none else query.filter(condition)
def _filter_by_player_counts(
self,
query: Query,
@@ -771,6 +784,7 @@ class DBRomsHandler(DBBaseHandler):
languages: Sequence[str] | None = None,
player_counts: Sequence[str] | None = None,
metadata_providers: Sequence[str] | None = None,
tags: Sequence[str] | None = None,
# Logic operators for multi-value filters
genres_logic: str = "any",
franchises_logic: str = "any",
@@ -782,6 +796,7 @@ class DBRomsHandler(DBBaseHandler):
statuses_logic: str = "any",
player_counts_logic: str = "any",
metadata_providers_logic: str = "any",
tags_logic: str = "any",
user_id: int | None = None,
updated_after: datetime | None = None,
include_file_stats: bool = False,
@@ -1015,6 +1030,7 @@ class DBRomsHandler(DBBaseHandler):
metadata_providers_logic,
self._filter_by_metadata_providers,
),
(tags, tags_logic, self._filter_by_tags),
]
for values, logic, filter_func in filters_to_apply:
@@ -1154,6 +1170,7 @@ class DBRomsHandler(DBBaseHandler):
languages=kwargs.get("languages", None),
player_counts=kwargs.get("player_counts", None),
metadata_providers=kwargs.get("metadata_providers", None),
tags=kwargs.get("tags", None),
# Logic operators for multi-value filters
genres_logic=kwargs.get("genres_logic", "any"),
franchises_logic=kwargs.get("franchises_logic", "any"),
@@ -1165,6 +1182,7 @@ class DBRomsHandler(DBBaseHandler):
statuses_logic=kwargs.get("statuses_logic", "any"),
player_counts_logic=kwargs.get("player_counts_logic", "any"),
metadata_providers_logic=kwargs.get("metadata_providers_logic", "any"),
tags_logic=kwargs.get("tags_logic", "any"),
user_id=kwargs.get("user_id", None),
group_by_meta_id=kwargs.get("group_by_meta_id", False),
include_files=kwargs.get("include_files", False),
@@ -1831,10 +1849,11 @@ class DBRomsHandler(DBBaseHandler):
player_counts = set()
regions = set()
languages = set()
tags = set()
platforms = set()
for row in session.execute(statement):
g, f, cl, co, gm, ar, pc, rg, lg, pid = row
g, f, cl, co, gm, ar, pc, rg, lg, tg, pid = row
if g:
genres.update(g)
if f:
@@ -1853,6 +1872,8 @@ class DBRomsHandler(DBBaseHandler):
regions.update(rg)
if lg:
languages.update(lg)
if tg:
tags.update(tg)
platforms.add(pid)
return {
@@ -1865,6 +1886,7 @@ class DBRomsHandler(DBBaseHandler):
"player_counts": sorted(player_counts),
"regions": sorted(regions),
"languages": sorted(languages),
"tags": sorted(tags),
"platforms": sorted(platforms),
}
@@ -1913,6 +1935,7 @@ class DBRomsHandler(DBBaseHandler):
RomMetadata.player_count,
Rom.regions,
Rom.languages,
Rom.tags,
Rom.platform_id,
)
.select_from(Rom)
@@ -1943,6 +1966,7 @@ class DBRomsHandler(DBBaseHandler):
RomMetadata.player_count,
Rom.regions,
Rom.languages,
Rom.tags,
Rom.platform_id,
)
+45
View File
@@ -128,6 +128,51 @@ def test_get_roms_filter_by_metadata_providers(
assert {item["id"] for item in body["items"]} == {rom.id}
def test_get_roms_filter_by_tags(
client: TestClient, access_token: str, rom: Rom, platform: Platform
):
rom_proto = db_rom_handler.add_rom(
Rom(
platform_id=platform.id,
name="rom_proto",
slug="rom_proto",
fs_name="rom_proto.zip",
fs_name_no_tags="rom_proto",
fs_name_no_ext="rom_proto",
fs_extension="zip",
fs_path=f"{platform.slug}/roms",
tags=["Proto"],
)
)
response = client.get(
"/api/roms",
headers={"Authorization": f"Bearer {access_token}"},
params={"platform_id": platform.id, "tags": ["Proto"]},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert {item["id"] for item in body["items"]} == {rom_proto.id}
# The selectable tag list is surfaced through filter_values.
assert "Proto" in body["filter_values"]["tags"]
# "none" logic returns the ROMs not carrying the selected tag.
response = client.get(
"/api/roms",
headers={"Authorization": f"Bearer {access_token}"},
params={
"platform_id": platform.id,
"tags": ["Proto"],
"tags_logic": "none",
},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert {item["id"] for item in body["items"]} == {rom.id}
def test_get_all_roms_with_files(
client: TestClient, access_token: str, rom: Rom, platform: Platform
):
@@ -42,3 +42,23 @@ def test_get_smart_collection_roms_passes_metadata_providers(admin_user: User, m
assert get_roms_scalar.call_args.kwargs["metadata_providers"] == ["igdb", "moby"]
assert get_roms_scalar.call_args.kwargs["metadata_providers_logic"] == "all"
def test_get_smart_collection_roms_passes_tags(admin_user: User, mocker):
get_roms_scalar = mocker.patch("handler.database.db_rom_handler.get_roms_scalar")
smart_collection = SmartCollection(
name="Prototypes and betas",
description="",
user_id=admin_user.id,
filter_criteria={
"tags": ["Proto", "Beta"],
"tags_logic": "any",
},
)
db_collection_handler.get_smart_collection_roms(
smart_collection, user_id=admin_user.id
)
assert get_roms_scalar.call_args.kwargs["tags"] == ["Proto", "Beta"]
assert get_roms_scalar.call_args.kwargs["tags_logic"] == "any"
+57
View File
@@ -954,3 +954,60 @@ def test_filter_by_metadata_providers_unknown_value_is_ignored(
metadata_providers=["igdb", "bogus"]
)
assert {r.id for r in known_and_unknown} == {rom_igdb.id}
def _add_rom_with_tags(platform: Platform, slug: str, tags: list[str]) -> Rom:
return db_rom_handler.add_rom(
Rom(
platform_id=platform.id,
name=slug,
slug=slug,
fs_name=f"{slug}.zip",
fs_name_no_tags=slug,
fs_name_no_ext=slug,
fs_extension="zip",
fs_path=f"{platform.slug}/roms",
tags=tags,
)
)
def test_filter_by_tags(rom: Rom, platform: Platform):
# `rom` fixture has no tags (untagged).
rom_proto = _add_rom_with_tags(platform, "rom_proto", ["Proto"])
rom_beta = _add_rom_with_tags(platform, "rom_beta", ["Beta"])
rom_both = _add_rom_with_tags(platform, "rom_both", ["Proto", "Beta"])
# "any" (OR): carries at least one of the selected tags.
any_proto = db_rom_handler.get_roms_scalar(tags=["Proto"])
assert {r.id for r in any_proto} == {rom_proto.id, rom_both.id}
any_either = db_rom_handler.get_roms_scalar(
tags=["Proto", "Beta"], tags_logic="any"
)
assert {r.id for r in any_either} == {rom_proto.id, rom_beta.id, rom_both.id}
# "all" (AND): carries every selected tag.
all_both = db_rom_handler.get_roms_scalar(tags=["Proto", "Beta"], tags_logic="all")
assert {r.id for r in all_both} == {rom_both.id}
# "none" (NOT): carries none of the selected tags.
none_proto = db_rom_handler.get_roms_scalar(tags=["Proto"], tags_logic="none")
assert {r.id for r in none_proto} == {rom.id, rom_beta.id}
def test_filter_by_tags_unknown_value_returns_no_matches(rom: Rom, platform: Platform):
"""A tag that no ROM carries simply matches nothing under "any" logic
(free-form text match), rather than erroring."""
_add_rom_with_tags(platform, "rom_proto", ["Proto"])
only_unknown = db_rom_handler.get_roms_scalar(tags=["Nonexistent"])
assert list(only_unknown) == []
def test_get_rom_filters_includes_tags(rom: Rom, platform: Platform):
_add_rom_with_tags(platform, "rom_proto", ["Proto"])
_add_rom_with_tags(platform, "rom_beta", ["Beta", "Demo"])
filters = db_rom_handler.get_rom_filters()
assert filters["tags"] == ["Beta", "Demo", "Proto"]
+1
View File
@@ -12,6 +12,7 @@ export type RomFiltersDict = {
player_counts: Array<string>;
regions: Array<string>;
languages: Array<string>;
tags: Array<string>;
platforms: Array<number>;
};
@@ -83,6 +83,9 @@ const {
playerCountsLogic,
selectedMetadataProviders,
metadataProvidersLogic,
filterTags,
selectedTags,
tagsLogic,
} = storeToRefs(galleryFilterStore);
// Provider options are a fixed registry (not data-derived like the other
@@ -175,6 +178,8 @@ const onFilterChange = debounce(
selectedMetadataProviders.value.length > 0
? metadataProvidersLogic.value
: null,
tags: selectedTags.value.length > 0 ? selectedTags.value.join(",") : null,
tagsLogic: selectedTags.value.length > 0 ? tagsLogic.value : null,
}).forEach(([key, value]) => {
if (value) {
url.searchParams.set(key, value);
@@ -256,6 +261,14 @@ const filters = [
setLogic: (logic: FilterLogicOperator) =>
galleryFilterStore.setLanguagesLogic(logic),
},
{
label: t("platform.tag"),
selected: selectedTags,
items: filterTags,
logic: tagsLogic,
setLogic: (logic: FilterLogicOperator) =>
galleryFilterStore.setTagsLogic(logic),
},
{
label: t("platform.player-count"),
selected: selectedPlayerCounts,
@@ -322,6 +335,8 @@ onMounted(async () => {
playerCountsLogic: urlPlayerCountsLogic,
metadataProviders: urlMetadataProviders,
metadataProvidersLogic: urlMetadataProvidersLogic,
tags: urlTags,
tagsLogic: urlTagsLogic,
} = router.currentRoute.value.query;
// Check for query params to set filters
@@ -516,6 +531,14 @@ onMounted(async () => {
}
}
if (urlTags !== undefined) {
const tags = (urlTags as string).split(",").filter((tag) => tag.trim());
galleryFilterStore.setSelectedFilterTags(tags);
if (urlTagsLogic !== undefined) {
galleryFilterStore.setTagsLogic(urlTagsLogic as FilterLogicOperator);
}
}
// Check if search term is set in the URL (empty string is ok)
const freshSearch = urlSearch !== undefined && urlSearch !== searchTerm.value;
if (freshSearch) {
+1
View File
@@ -76,6 +76,7 @@
"filters": "Филтри",
"show": "Покажи",
"tags": "Тагове",
"tag": "Таг",
"show-all": "Покажи всички",
"show-matched-only": "Покажи само разпознати ROM-ове",
"show-unmatched-only": "Покажи само неразпознати ROM-ове",
+1
View File
@@ -76,6 +76,7 @@
"filters": "Filtry",
"show": "Zobrazit",
"tags": "Štítky",
"tag": "Štítek",
"show-all": "Zobrazit vše",
"show-matched-only": "Zobrazit pouze spárované ROMy",
"show-unmatched-only": "Zobrazit pouze nespárované ROMy",
+1
View File
@@ -76,6 +76,7 @@
"filters": "Filter",
"show": "Anzeigen",
"tags": "Tags",
"tag": "Tag",
"show-all": "Alle anzeigen",
"show-matched-only": "Nur zugeordnete ROMs anzeigen",
"show-unmatched-only": "Nur nicht zugeordnete ROMs anzeigen",
+1
View File
@@ -76,6 +76,7 @@
"filters": "Filters",
"show": "Show",
"tags": "Tags",
"tag": "Tag",
"show-all": "Show all",
"show-matched-only": "Show matched ROMs only",
"show-unmatched-only": "Show unmatched ROMs only",
+1
View File
@@ -76,6 +76,7 @@
"filters": "Filters",
"show": "Show",
"tags": "Tags",
"tag": "Tag",
"show-all": "Show all",
"show-matched-only": "Show matched ROMs only",
"show-unmatched-only": "Show unmatched ROMs only",
+1
View File
@@ -65,6 +65,7 @@
"show-matched-only": "Solo emparejados",
"show-unmatched-only": "Solo sin emparejar",
"tags": "Etiquetas",
"tag": "Etiqueta",
"delete-platform-hint": "Elimina la plataforma y sus entradas de ROM. Los archivos en disco NO se eliminan.",
"edit-platform": "Editar plataforma",
"rom-count": "{n} ROM | {n} ROMs",
+1
View File
@@ -76,6 +76,7 @@
"filters": "Filtres",
"show": "Afficher",
"tags": "Tags",
"tag": "Tag",
"show-all": "Tout afficher",
"show-matched-only": "Afficher uniquement les ROMs identifiées",
"show-unmatched-only": "Afficher uniquement les ROMs non identifiées",
+1
View File
@@ -76,6 +76,7 @@
"filters": "Szűrők",
"show": "Megjelenítés",
"tags": "Címkék",
"tag": "Címke",
"show-all": "Összes megjelenítése",
"show-matched-only": "Csak egyeztetett ROM-ok megjelenítése",
"show-unmatched-only": "Csak nem egyeztetett ROM-ok megjelenítése",
+1
View File
@@ -76,6 +76,7 @@
"filters": "Filtri",
"show": "Mostra",
"tags": "Tag",
"tag": "Tag",
"show-all": "Mostra tutto",
"show-matched-only": "Mostra solo ROM con corrispondenza",
"show-unmatched-only": "Mostra solo ROM senza corrispondenza",
+1
View File
@@ -76,6 +76,7 @@
"filters": "フィルタ",
"show": "表示",
"tags": "タグ",
"tag": "タグ",
"show-all": "すべて表示",
"show-matched-only": "マッチしたROMのみ表示",
"show-unmatched-only": "マッチしていないROMのみ表示",
+1
View File
@@ -76,6 +76,7 @@
"filters": "필터",
"show": "표시",
"tags": "태그",
"tag": "태그",
"show-all": "모두 표시",
"show-matched-only": "일치된 ROM만 표시",
"show-unmatched-only": "일치되지 않은 ROM만 표시",
+1
View File
@@ -76,6 +76,7 @@
"filters": "Filtry",
"show": "Pokaż",
"tags": "Tagi",
"tag": "Tag",
"show-all": "Pokaż wszystkie",
"show-matched-only": "Pokaż tylko dopasowane ROM-y",
"show-unmatched-only": "Pokaż tylko niedopasowane ROM-y",
+1
View File
@@ -76,6 +76,7 @@
"filters": "Filtros",
"show": "Mostrar",
"tags": "Tags",
"tag": "Tag",
"show-all": "Mostrar todos",
"show-matched-only": "Mostrar apenas ROMs correspondentes",
"show-unmatched-only": "Mostrar apenas ROMs sem correspondência",
+1
View File
@@ -76,6 +76,7 @@
"filters": "Filtre",
"show": "Afișează",
"tags": "Etichete",
"tag": "Etichetă",
"show-all": "Afișează toate",
"show-matched-only": "Afișează doar ROM-urile identificate",
"show-unmatched-only": "Afișează doar ROM-urile neidentificate",
+1
View File
@@ -76,6 +76,7 @@
"filters": "Фильтры",
"show": "Показать",
"tags": "Теги",
"tag": "Тег",
"show-all": "Показать все",
"show-matched-only": "Показать только сопоставленные ROM-ы",
"show-unmatched-only": "Показать только несопоставленные ROM-ы",
+1
View File
@@ -76,6 +76,7 @@
"filters": "Filtreler",
"show": "Göster",
"tags": "Etiketler",
"tag": "Etiket",
"show-all": "Tümünü göster",
"show-matched-only": "Yalnızca eşleşen ROM'ları göster",
"show-unmatched-only": "Yalnızca eşleşmeyen ROM'ları göster",
+1
View File
@@ -76,6 +76,7 @@
"filters": "筛选器",
"show": "显示",
"tags": "标签",
"tag": "标签",
"show-all": "显示全部",
"show-matched-only": "仅显示已匹配的 ROM",
"show-unmatched-only": "仅显示未匹配的 ROM",
+1
View File
@@ -76,6 +76,7 @@
"filters": "篩選器",
"show": "顯示",
"tags": "標籤",
"tag": "標籤",
"show-all": "顯示全部",
"show-matched-only": "僅顯示已匹配的 ROM",
"show-unmatched-only": "僅顯示未匹配的 ROM",
+7
View File
@@ -166,6 +166,7 @@ export interface GetRomsParams {
selectedLanguages?: string[] | null;
selectedPlayerCounts?: string[] | null;
selectedMetadataProviders?: string[] | null;
selectedTags?: string[] | null;
selectedStatuses?: string[] | null;
// Logic operators for multi-value filters
genresLogic?: string | null;
@@ -178,6 +179,7 @@ export interface GetRomsParams {
statusesLogic?: string | null;
playerCountsLogic?: string | null;
metadataProvidersLogic?: string | null;
tagsLogic?: string | null;
// Cancellation: pass an AbortSignal to let the caller abort an
// in-flight request (e.g. search-typing → previous query aborted,
// gallery-context switch → previous platform's windows aborted).
@@ -211,6 +213,7 @@ async function getRoms({
selectedLanguages = null,
selectedPlayerCounts = null,
selectedMetadataProviders = null,
selectedTags = null,
selectedStatuses = null,
// Logic operators
genresLogic = null,
@@ -223,6 +226,7 @@ async function getRoms({
statusesLogic = null,
playerCountsLogic = null,
metadataProvidersLogic = null,
tagsLogic = null,
signal = undefined,
}: GetRomsParams) {
const params = {
@@ -275,6 +279,7 @@ async function getRoms({
selectedMetadataProviders && selectedMetadataProviders.length > 0
? selectedMetadataProviders
: undefined,
tags: selectedTags && selectedTags.length > 0 ? selectedTags : undefined,
// Logic operators
genres_logic:
selectedGenres && selectedGenres.length > 0
@@ -316,6 +321,8 @@ async function getRoms({
selectedMetadataProviders && selectedMetadataProviders.length > 0
? metadataProvidersLogic || "any"
: undefined,
tags_logic:
selectedTags && selectedTags.length > 0 ? tagsLogic || "any" : undefined,
...(filterMatched !== null ? { matched: filterMatched } : {}),
...(filterFavorites !== null ? { favorite: filterFavorites } : {}),
...(filterDuplicates !== null ? { duplicate: filterDuplicates } : {}),
+8
View File
@@ -83,6 +83,10 @@ class CachedApiService {
params.selectedMetadataProviders.length > 0
? params.selectedMetadataProviders
: undefined,
tags:
params.selectedTags && params.selectedTags.length > 0
? params.selectedTags
: undefined,
// Logic operators
genres_logic:
params.selectedGenres && params.selectedGenres.length > 0
@@ -125,6 +129,10 @@ class CachedApiService {
params.selectedMetadataProviders.length > 0
? params.metadataProvidersLogic || "any"
: undefined,
tags_logic:
params.selectedTags && params.selectedTags.length > 0
? params.tagsLogic || "any"
: undefined,
...(params.filterMatched !== null
? { matched: params.filterMatched }
: {}),
+17 -1
View File
@@ -12,7 +12,8 @@ export type FilterType =
| "regions"
| "languages"
| "playerCounts"
| "metadataProviders";
| "metadataProviders"
| "tags";
export type FilterLogicOperator = "any" | "all" | "none";
@@ -33,6 +34,7 @@ const buildDefaultFilterState = () => ({
filterRegions: [] as string[],
filterLanguages: [] as string[],
filterPlayerCounts: [] as string[],
filterTags: [] as string[],
filterStatuses: Object.keys(romStatusMap),
filterMatched: null as boolean | null, // null = all, true = matched, false = unmatched
filterFavorites: null as boolean | null, // null = all, true = favorites, false = not favorites
@@ -52,6 +54,7 @@ const buildDefaultFilterState = () => ({
selectedLanguages: [] as string[],
selectedPlayerCounts: [] as string[],
selectedMetadataProviders: [] as string[],
selectedTags: [] as string[],
selectedStatuses: [] as string[],
// Logic operators for multi-select filters
genresLogic: "any" as FilterLogicOperator,
@@ -64,6 +67,7 @@ const buildDefaultFilterState = () => ({
statusesLogic: "any" as FilterLogicOperator,
playerCountsLogic: "any" as FilterLogicOperator,
metadataProvidersLogic: "any" as FilterLogicOperator,
tagsLogic: "any" as FilterLogicOperator,
});
export default defineStore("galleryFilter", {
@@ -100,6 +104,9 @@ export default defineStore("galleryFilter", {
setFilterPlayerCounts(playerCounts: string[]) {
this.filterPlayerCounts = playerCounts;
},
setFilterTags(tags: string[]) {
this.filterTags = tags;
},
setSelectedFilterPlatform(platform: Platform) {
this.selectedPlatform = platform
? this.filterPlatforms.find((p) => p.id === platform.id) || null
@@ -164,6 +171,12 @@ export default defineStore("galleryFilter", {
setMetadataProvidersLogic(logic: FilterLogicOperator) {
this.metadataProvidersLogic = logic;
},
setSelectedFilterTags(tags: string[]) {
this.selectedTags = tags;
},
setTagsLogic(logic: FilterLogicOperator) {
this.tagsLogic = logic;
},
setSelectedFilterStatuses(statuses: string[]) {
this.selectedStatuses = statuses;
},
@@ -365,6 +378,7 @@ export default defineStore("galleryFilter", {
this.selectedLanguages.length > 0 ||
this.selectedPlayerCounts.length > 0 ||
this.selectedMetadataProviders.length > 0 ||
this.selectedTags.length > 0 ||
this.selectedStatuses.length > 0,
);
},
@@ -383,6 +397,7 @@ export default defineStore("galleryFilter", {
this.selectedLanguages = [];
this.selectedPlayerCounts = [];
this.selectedMetadataProviders = [];
this.selectedTags = [];
this.selectedStatuses = [];
this.filterMatched = null;
this.filterFavorites = null;
@@ -402,6 +417,7 @@ export default defineStore("galleryFilter", {
this.statusesLogic = "any";
this.playerCountsLogic = "any";
this.metadataProvidersLogic = "any";
this.tagsLogic = "any";
},
},
});
+3
View File
@@ -166,6 +166,7 @@ export default defineStore("roms", {
selectedLanguages: galleryFilter.selectedLanguages,
selectedPlayerCounts: galleryFilter.selectedPlayerCounts,
selectedMetadataProviders: galleryFilter.selectedMetadataProviders,
selectedTags: galleryFilter.selectedTags,
selectedStatuses: galleryFilter.selectedStatuses,
// Logic operators
genresLogic: galleryFilter.genresLogic,
@@ -178,6 +179,7 @@ export default defineStore("roms", {
statusesLogic: galleryFilter.statusesLogic,
playerCountsLogic: galleryFilter.playerCountsLogic,
metadataProvidersLogic: galleryFilter.metadataProvidersLogic,
tagsLogic: galleryFilter.tagsLogic,
};
return params;
},
@@ -221,6 +223,7 @@ export default defineStore("roms", {
galleryFilter.setFilterRegions(filter_values.regions);
galleryFilter.setFilterLanguages(filter_values.languages);
galleryFilter.setFilterPlayerCounts(filter_values.player_counts);
galleryFilter.setFilterTags(filter_values.tags);
}
},
/** @deprecated v2: use `useGalleryRoms().fetchWindowAt(position)` for
@@ -110,6 +110,8 @@ const openHandler = () => {
playerCountsLogic: galleryFilter.playerCountsLogic,
selectedMetadataProviders: galleryFilter.selectedMetadataProviders,
metadataProvidersLogic: galleryFilter.metadataProvidersLogic,
selectedTags: galleryFilter.selectedTags,
tagsLogic: galleryFilter.tagsLogic,
selectedStatuses: galleryFilter.selectedStatuses,
statusesLogic: galleryFilter.statusesLogic,
},
@@ -8,10 +8,10 @@
// true → positive, false → negative.
// • Optional platform multi-select (only on Search / Collection views
// where you can mix platforms).
// • 10 multi-select filter groups (genres / franchises / collections /
// companies / age-ratings / regions / languages / player-counts /
// metadata-providers / statuses) — each paired with an AND/OR/NONE
// logic toggle.
// • 11 multi-select filter groups (genres / franchises / collections /
// companies / age-ratings / regions / languages / tags /
// player-counts / metadata-providers / statuses) — each paired with
// an AND/OR/NONE logic toggle.
// • Reset button at the bottom.
//
// Apply is implicit — the URL composable + galleryRoms watcher refresh
@@ -90,6 +90,9 @@ const {
playerCountsLogic,
selectedMetadataProviders,
metadataProvidersLogic,
filterTags,
selectedTags,
tagsLogic,
filterStatuses,
selectedStatuses,
statusesLogic,
@@ -302,6 +305,14 @@ const multiSections = computed<MultiConfig[]>(() => [
logic: languagesLogic,
setLogic: (l) => filter.setLanguagesLogic(l),
},
{
label: t("platform.tag"),
icon: "mdi-tag-outline",
items: filterTags,
selected: selectedTags,
logic: tagsLogic,
setLogic: (l) => filter.setTagsLogic(l),
},
{
label: t("platform.player-count"),
icon: "mdi-account-group",
@@ -166,6 +166,7 @@ const {
selectedLanguages,
selectedPlayerCounts,
selectedMetadataProviders,
selectedTags,
selectedStatuses,
} = storeToRefs(galleryFilterStore);
@@ -196,6 +197,7 @@ const filterActiveCount = computed(() => {
selectedLanguages,
selectedPlayerCounts,
selectedMetadataProviders,
selectedTags,
selectedStatuses,
]) {
if (arr.value.length > 0) n += 1;
@@ -227,6 +229,7 @@ watch(
selectedLanguages,
selectedPlayerCounts,
selectedMetadataProviders,
selectedTags,
selectedStatuses,
],
() => {
@@ -28,6 +28,7 @@
// ?statuses=…&statusesLogic=…
// ?playerCounts=…&playerCountsLogic=…
// ?metadataProviders=…&metadataProvidersLogic=…
// ?tags=…&tagsLogic=…
//
// Direction notes:
// * URL → store fires on every `route.query` change (browser back /
@@ -112,6 +113,7 @@ export function useGalleryFilterUrl() {
selectedLanguages,
selectedPlayerCounts,
selectedMetadataProviders,
selectedTags,
selectedStatuses,
genresLogic,
franchisesLogic,
@@ -122,6 +124,7 @@ export function useGalleryFilterUrl() {
languagesLogic,
playerCountsLogic,
metadataProvidersLogic,
tagsLogic,
statusesLogic,
} = storeToRefs(filter);
@@ -162,6 +165,8 @@ export function useGalleryFilterUrl() {
playerCountsLogic: qLogic(q.playerCountsLogic),
metadataProviders: qList(q.metadataProviders),
metadataProvidersLogic: qLogic(q.metadataProvidersLogic),
tags: qList(q.tags),
tagsLogic: qLogic(q.tagsLogic),
statuses: qList(q.statuses),
statusesLogic: qLogic(q.statusesLogic),
};
@@ -251,6 +256,11 @@ export function useGalleryFilterUrl() {
)
filter.setMetadataProvidersLogic(url.metadataProvidersLogic);
if (!eqStrArr(url.tags, selectedTags.value))
filter.setSelectedFilterTags(url.tags);
if (url.tagsLogic && url.tagsLogic !== tagsLogic.value)
filter.setTagsLogic(url.tagsLogic);
if (!eqStrArr(url.statuses, selectedStatuses.value))
filter.setSelectedFilterStatuses(url.statuses);
if (url.statusesLogic && url.statusesLogic !== statusesLogic.value)
@@ -359,6 +369,11 @@ export function useGalleryFilterUrl() {
? metadataProvidersLogic.value
: null,
);
setList("tags", selectedTags.value);
setOrDelete(
"tagsLogic",
selectedTags.value.length > 0 ? tagsLogic.value : null,
);
setList("statuses", selectedStatuses.value);
setOrDelete(
"statusesLogic",
@@ -414,6 +429,8 @@ export function useGalleryFilterUrl() {
playerCountsLogic,
selectedMetadataProviders,
metadataProvidersLogic,
selectedTags,
tagsLogic,
selectedStatuses,
statusesLogic,
],
+3
View File
@@ -308,6 +308,7 @@ export default defineStore("v2GalleryRoms", {
selectedLanguages: galleryFilter.selectedLanguages,
selectedPlayerCounts: galleryFilter.selectedPlayerCounts,
selectedMetadataProviders: galleryFilter.selectedMetadataProviders,
selectedTags: galleryFilter.selectedTags,
selectedStatuses: galleryFilter.selectedStatuses,
genresLogic: galleryFilter.genresLogic,
franchisesLogic: galleryFilter.franchisesLogic,
@@ -319,6 +320,7 @@ export default defineStore("v2GalleryRoms", {
statusesLogic: galleryFilter.statusesLogic,
playerCountsLogic: galleryFilter.playerCountsLogic,
metadataProvidersLogic: galleryFilter.metadataProvidersLogic,
tagsLogic: galleryFilter.tagsLogic,
};
},
@@ -352,6 +354,7 @@ export default defineStore("v2GalleryRoms", {
galleryFilter.setFilterRegions(data.filter_values.regions);
galleryFilter.setFilterLanguages(data.filter_values.languages);
galleryFilter.setFilterPlayerCounts(data.filter_values.player_counts);
galleryFilter.setFilterTags(data.filter_values.tags);
}
this.metadataLoaded = true;
},
@@ -41,6 +41,8 @@ function emptySnapshot(): GalleryFilterSnapshot {
playerCountsLogic: "any",
selectedMetadataProviders: [],
metadataProvidersLogic: "any",
selectedTags: [],
tagsLogic: "any",
selectedStatuses: [],
statusesLogic: "any",
};
@@ -77,3 +79,35 @@ describe("buildSmartFilterCriteria — metadata providers", () => {
expect(row?.logic).toBe("any");
});
});
describe("buildSmartFilterCriteria — tags", () => {
it("serializes selected tags with their logic operator", () => {
const out = buildSmartFilterCriteria({
...emptySnapshot(),
selectedTags: ["Proto", "Beta"],
tagsLogic: "all",
});
expect(out.tags).toEqual(["Proto", "Beta"]);
expect(out.tags_logic).toBe("all");
});
it("omits the keys entirely when no tag is selected", () => {
const out = buildSmartFilterCriteria(emptySnapshot());
expect(out.tags).toBeUndefined();
expect(out.tags_logic).toBeUndefined();
});
it("surfaces tags in the human-readable summary", () => {
const rows = summarizeSmartFilterCriteria(
{ tags: ["Proto"], tags_logic: "any" },
tStub,
);
const row = rows.find((r) => r.key === "tags");
expect(row).toBeDefined();
expect(row?.values).toEqual(["Proto"]);
expect(row?.logic).toBe("any");
});
});
@@ -52,6 +52,8 @@ export interface SmartFilterCriteria {
player_counts_logic?: FilterLogic;
metadata_providers?: string[];
metadata_providers_logic?: FilterLogic;
tags?: string[];
tags_logic?: FilterLogic;
selected_status?: string[];
statuses_logic?: FilterLogic;
}
@@ -87,6 +89,8 @@ export interface GalleryFilterSnapshot {
playerCountsLogic: FilterLogic;
selectedMetadataProviders: string[];
metadataProvidersLogic: FilterLogic;
selectedTags: string[];
tagsLogic: FilterLogic;
selectedStatuses: string[];
statusesLogic: FilterLogic;
}
@@ -180,6 +184,10 @@ export function buildSmartFilterCriteria(
out.metadata_providers = snap.selectedMetadataProviders;
out.metadata_providers_logic = snap.metadataProvidersLogic;
}
if (snap.selectedTags.length > 0) {
out.tags = snap.selectedTags;
out.tags_logic = snap.tagsLogic;
}
if (snap.selectedStatuses.length > 0) {
out.selected_status = snap.selectedStatuses;
out.statuses_logic = snap.statusesLogic;
@@ -386,6 +394,14 @@ const FIELDS: FieldSpec[] = [
defaultLabel: "Metadata providers",
kind: "list",
},
{
storage: "tags",
logicStorage: "tags_logic",
icon: "mdi-tag-outline",
labelKey: "platform.tag",
defaultLabel: "Tags",
kind: "list",
},
{
storage: "selected_status",
logicStorage: "statuses_logic",