diff --git a/app/resolve/tag_resolver.py b/app/resolve/tag_resolver.py
index 1646a0c..641912c 100644
--- a/app/resolve/tag_resolver.py
+++ b/app/resolve/tag_resolver.py
@@ -31,15 +31,32 @@ def _resolve_by_altkey(session: Session, name: str) -> Tag | None:
key = _altkey(name)
if not key:
return None
- return (
- session.execute(
- select(Tag)
- .where(func.regexp_replace(func.lower(func.btrim(Tag.name)), "[^a-z0-9]", "", "g") == key)
- .order_by(Tag.scene_count.desc())
- )
+ expr = func.regexp_replace(func.lower(func.btrim(Tag.name)), "[^a-z0-9]", "", "g")
+ hit = (
+ session.execute(select(Tag).where(expr == key).order_by(Tag.scene_count.desc()))
.scalars()
.first()
)
+ if hit is not None:
+ return hit
+ # Liczba mnoga. Alnum-klucz z natury jej NIE łapie ("blowjob" != "blowjobs"), więc
+ # duplikaty odrastały mimo tamtej prewencji (audyt 2026-08-02: 1944 pary, m.in.
+ # Blowjob/Blowjobs, Teen/Teens, Cumshot/Cumshots). Próbujemy obu kierunków: nowe
+ # "Blowjobs" trafia w istniejące "Blowjob", a nowe "Blowjob" w istniejące "Blowjobs".
+ # Tylko proste `+s` — form nieregularnych (panty/panties) świadomie nie ruszamy, bo
+ # zbyt łatwo o fałszywy zlew (glass/glasses, bra/bras).
+ alts = [key + "s"] if not key.endswith("s") else [key[:-1]]
+ for alt in alts:
+ if len(alt) < 3:
+ continue
+ hit = (
+ session.execute(select(Tag).where(expr == alt).order_by(Tag.scene_count.desc()))
+ .scalars()
+ .first()
+ )
+ if hit is not None:
+ return hit
+ return None
def _canonical_dup2_slug(session: Session, slug: str) -> str:
diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts
index f0cdfbe..ead482c 100644
--- a/mobile/src/changelog.ts
+++ b/mobile/src/changelog.ts
@@ -16,6 +16,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ id: '2026-08-02',
+ date: 'August 2026',
+ items: [
+ 'Tag and performer counts are now shortened (181k instead of 181 764), so the chips stay readable.',
+ 'Merged duplicate tags that differed only by a plural, so "Blowjob" and "Blowjobs" are one tag again.',
+ ],
+ },
{
id: '2026-07-28b',
date: 'July 2026',
diff --git a/mobile/src/components/MovieFiltersSheet.tsx b/mobile/src/components/MovieFiltersSheet.tsx
index 9b8ef64..1804b07 100644
--- a/mobile/src/components/MovieFiltersSheet.tsx
+++ b/mobile/src/components/MovieFiltersSheet.tsx
@@ -7,6 +7,7 @@
* przez chipy; backend wspiera comma-separated.
*/
import { useQuery } from '@tanstack/react-query';
+import { formatCount } from '../lib/formatCount';
import React, { useState } from 'react';
import {
ActivityIndicator,
@@ -257,7 +258,7 @@ export function MovieFiltersSheet({ visible, value, onChange, onClose }: Props)
>
{t.name}
- {t.scene_count > 0 ? ` · ${t.scene_count}` : ''}
+ {t.scene_count > 0 ? ` · ${formatCount(t.scene_count)}` : ''}
);
@@ -284,7 +285,7 @@ export function MovieFiltersSheet({ visible, value, onChange, onClose }: Props)
>
{s.name}
- {s.scene_count > 0 ? ` · ${s.scene_count}` : ''}
+ {s.scene_count > 0 ? ` · ${formatCount(s.scene_count)}` : ''}
);
@@ -336,7 +337,7 @@ export function MovieFiltersSheet({ visible, value, onChange, onClose }: Props)
>
{p.canonical_name}
- {p.scene_count > 0 ? ` · ${p.scene_count}` : ''}
+ {p.scene_count > 0 ? ` · ${formatCount(p.scene_count)}` : ''}
))}
diff --git a/mobile/src/lib/formatCount.ts b/mobile/src/lib/formatCount.ts
new file mode 100644
index 0000000..330f4f2
--- /dev/null
+++ b/mobile/src/lib/formatCount.ts
@@ -0,0 +1,20 @@
+/**
+ * Skracanie dużych liczników (user-request 2026-08-02: "jeśli jest 181 764, to napisać
+ * 181k"). Popularne tagi mają setki tysięcy scen, a pełna liczba rozpycha chipy i nic
+ * nie wnosi — przy tej skali liczy się rząd wielkości, nie dokładność.
+ *
+ * Do 999 pokazujemy dokładnie. Powyżej: 1,2k / 12k / 181k / 1,2M. Jedno miejsce po
+ * przecinku tylko gdy liczba jest jednocyfrowa w swojej skali (1,2k ale już 12k),
+ * żeby chip nie puchł. Separator dziesiętny przecinkiem, bo apka jest angielska tylko
+ * z nazwy, a i tak czytają to ludzie z całego świata — kropka myliłaby się z tysiącami.
+ */
+export function formatCount(n: number | null | undefined): string {
+ const v = typeof n === 'number' && isFinite(n) ? Math.max(0, Math.trunc(n)) : 0;
+ if (v < 1000) return String(v);
+ if (v < 1_000_000) {
+ const k = v / 1000;
+ return (k < 10 ? k.toFixed(1).replace(/\.0$/, '').replace('.', ',') : String(Math.round(k))) + 'k';
+ }
+ const m = v / 1_000_000;
+ return (m < 10 ? m.toFixed(1).replace(/\.0$/, '').replace('.', ',') : String(Math.round(m))) + 'M';
+}
diff --git a/mobile/src/screens/FavoritesScreen.tsx b/mobile/src/screens/FavoritesScreen.tsx
index 4cca111..eb68f69 100644
--- a/mobile/src/screens/FavoritesScreen.tsx
+++ b/mobile/src/screens/FavoritesScreen.tsx
@@ -4,6 +4,7 @@
// żeby PerformerScenesScreen / StudioScenesScreen mógł pokazać NEW badge na świeżych
// scenach.
import { useNavigation } from '@react-navigation/native';
+import { formatCount } from '../lib/formatCount';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import React from 'react';
@@ -411,7 +412,7 @@ function PerformerRow({
{fav.canonical_name}
- {fav.scene_count} {fav.scene_count === 1 ? 'scene' : 'scenes'}
+ {formatCount(fav.scene_count)} {fav.scene_count === 1 ? 'scene' : 'scenes'}
{fav.new_count > 0 ? (
@@ -486,7 +487,7 @@ function StudioRow({
{fav.name}
- {fav.scene_count} {fav.scene_count === 1 ? 'scene' : 'scenes'}
+ {formatCount(fav.scene_count)} {fav.scene_count === 1 ? 'scene' : 'scenes'}
{fav.network ? ` · ${fav.network}` : ''}
diff --git a/mobile/src/screens/PerformersScreen.tsx b/mobile/src/screens/PerformersScreen.tsx
index 10302a8..ad81f6a 100644
--- a/mobile/src/screens/PerformersScreen.tsx
+++ b/mobile/src/screens/PerformersScreen.tsx
@@ -1,6 +1,7 @@
// Lista performerów + tap → PerformerScenes.
// Domyślny order: scene_count desc (najpierw popularni); search po name_normalized.
import { useNavigation } from '@react-navigation/native';
+import { formatCount } from '../lib/formatCount';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useQuery } from '@tanstack/react-query';
import React, { useState } from 'react';
@@ -153,7 +154,7 @@ function PerformerRow({
- {performer.scene_count} {performer.scene_count === 1 ? 'scene' : 'scenes'}
+ {formatCount(performer.scene_count)} {performer.scene_count === 1 ? 'scene' : 'scenes'}
{performer.gender ? (
diff --git a/mobile/src/screens/TagsScreen.tsx b/mobile/src/screens/TagsScreen.tsx
index df32df4..3528b46 100644
--- a/mobile/src/screens/TagsScreen.tsx
+++ b/mobile/src/screens/TagsScreen.tsx
@@ -1,6 +1,7 @@
// Lista tagów + tap → TagScenes. Domyślny order: popular (po liczbie scen desc).
// Layout: chip-grid, bo nazwy tagów są krótkie i upacking ich w listę marnuje miejsce.
import { useNavigation } from '@react-navigation/native';
+import { formatCount } from '../lib/formatCount';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useQuery } from '@tanstack/react-query';
import React, { useState } from 'react';
@@ -131,7 +132,7 @@ function TagChip({ tag, onPress }: { tag: TagCount; onPress: () => void }) {
{tag.name}
- {tag.scene_count}
+ {formatCount(tag.scene_count)}
);