diff --git a/app/api/scenes.py b/app/api/scenes.py index eb568af..6807359 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -59,7 +59,9 @@ def _default_scene_count(session: Session) -> int: PlaybackSource.scene_id == Scene.id, PlaybackSource.dead_at.is_(None), ) - ) + ), + # Domyślny feed wyklucza JAV (osobna sekcja) → licznik też. + ~_jav_source_exists(), ).subquery() ) total = session.execute(count_query).scalar_one() @@ -68,6 +70,23 @@ def _default_scene_count(session: Session) -> int: return total +# JAV vertical — osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu +# (domyślnie wykluczone), tylko do zakładki JAV (?jav=true). JAV to osobny namespace +# (kody typu BKD-368, azjatyckie tytuły), nie deduplikuje się z zachodnim katalogiem, +# więc scena JAV ma WYŁĄCZNIE origin JAV → wykluczenie/inkluzja po tym originie jest pewna. +JAV_ORIGINS = ("tube:javflix", "tube:javguru", "tube:vjav", "tube:supjav") + + +def _jav_source_exists(): + return exists( + select(1).where( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + PlaybackSource.origin.in_(JAV_ORIGINS), + ) + ) + + # Blacklisty (performer/studio/tag) są zwykle PUSTE (self-hosted, single-user). Mimo to # 3 NOT EXISTS klauzule doklejały się do KAŻDEJ filtrowanej listy scen i były ewaluowane # per-row — przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te @@ -222,6 +241,13 @@ def list_scenes( "z jedynym playback z hqporner (~7-min Brazzers trailer clipy zalewają katalog)." ), ), + jav: bool = Query( + default=False, + description=( + "False (default): ukrywa sceny JAV (osobny vertical). True: TYLKO sceny JAV. " + "JAV to osobna sekcja w apce, nie zalewa głównego feedu (origin javflix/javguru/vjav/supjav)." + ), + ), sort: str = Query(default="created_at", description="created_at|release_date|title|studio"), page: int = Query(default=1, ge=1), per_page: int = Query(default=50, ge=1, le=200), @@ -322,6 +348,13 @@ def list_scenes( ) ) + # JAV vertical gate: domyślnie JAV wykluczone z głównego feedu; ?jav=true → TYLKO JAV. + # (origin=... diagnostyka nadal podlega bramie — do JAV użyj jav=true.) + if jav: + base = base.where(_jav_source_exists()) + else: + base = base.where(~_jav_source_exists()) + # Blacklisty device (performer/studio/tag) — globalne wykluczenia, współdzielone z # licznikiem +N ulubionych. Puste blacklisty → [] (typowy single-user, zero kosztu). for _bl_clause in blacklist_clauses(session, device_id): @@ -373,7 +406,7 @@ def list_scenes( and not perf_id_strings and origin is None and has_playback is None and min_duration_sec is None and max_duration_sec is None and released_within_days is None - and min_quality_p is None + and min_quality_p is None and not jav ) # Count strategy: # - PURE default: cached pełny licznik katalogu (TTL 10 min). diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index 5841c91..0c76da1 100644 --- a/app/connectors/direct_scrapers/__init__.py +++ b/app/connectors/direct_scrapers/__init__.py @@ -135,6 +135,7 @@ from app.connectors.direct_scrapers.yesporn import YesPornVipScraper # noqa: E4 from app.connectors.direct_scrapers.fullmovies import FullmoviesScraper # noqa: E402 from app.connectors.direct_scrapers.hdporngg import HDPornGGScraper # noqa: E402 from app.connectors.direct_scrapers.hqfap import HQFapScraper # noqa: E402 +from app.connectors.direct_scrapers.javflix import JavflixScraper # noqa: E402 from app.connectors.direct_scrapers.neporn import NepornScraper # noqa: E402 from app.connectors.direct_scrapers.superporn import SuperpornScraper # noqa: E402 from app.connectors.direct_scrapers.eporner_api import EpornerApiScraper # noqa: E402 @@ -218,6 +219,9 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [ # bo strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP), user request. HQFapScraper, # FourK69Scraper — USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie. + # JavflixScraper — JAV vertical (osobna sekcja). origin tube:javflix jest w + # JAV_ORIGINS → list_scenes wyklucza je z głównego feedu (tylko zakładka JAV). + JavflixScraper, # NepornScraper — dołączony 2026-06-10 (user request). KVS engine (jak freshporno/ # porn00), /latest-updates/N/. JSON-LD (title+desc+uploadDate+thumb) + video:duration # meta + /models/ performerzy + /categories/ tagi. Brak studio (tytuł bywa diff --git a/app/connectors/direct_scrapers/javflix.py b/app/connectors/direct_scrapers/javflix.py index a21cbbd..d36a2af 100644 --- a/app/connectors/direct_scrapers/javflix.py +++ b/app/connectors/direct_scrapers/javflix.py @@ -13,6 +13,7 @@ Struktura (RE 2026-07-10): """ from __future__ import annotations +import html import re from app.connectors.base import RawPlaybackSource, RawScene, RawTag @@ -26,6 +27,7 @@ _NON_POST = ( "/page/", "/category/", "/categories/", "/genre/", "/maker/", "/actress/", "/actors/", "/tag/", "/tags/", "/studio/", "/label/", "/series/", "/wp-", "/18-usc", "/dmca", "/contact", "/privacy", "/about", "/2257", + "/terms", "/faq", "/policy", "/disclaimer", "/sitemap", ) _CODE_RE = re.compile(r"^([a-z]+-?\d+[a-z]?)", re.IGNORECASE) @@ -58,12 +60,17 @@ class JavflixScraper(BaseBrowseScraper): return out def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: + # Prawdziwy post video ma przyciski serwerów (`class="myLink"`). Strony statyczne + # (Terms/FAQ/DMCA) ich nie mają → pomijamy (URL-filter nie łapie wszystkich). + if 'class="myLink"' not in detail_html: + return None title = _itemprop(detail_html, "name") if not title: tm = re.search(r"([^<]+)", detail_html) title = tm.group(1).split(" – ")[0].strip() if tm else None if not title: return None + title = html.unescape(title).strip() thumb = _itemprop(detail_html, "thumbnailUrl") up = _itemprop(detail_html, "uploadDate") diff --git a/mobile/src/api.ts b/mobile/src/api.ts index dbdf576..572fa3c 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -134,6 +134,7 @@ export class GoonClient { qs.set('released_within_days', String(params.released_within_days)); if (params.min_quality_p !== undefined) qs.set('min_quality_p', String(params.min_quality_p)); if (params.include_stubs !== undefined) qs.set('include_stubs', String(params.include_stubs)); + if (params.jav) qs.set('jav', 'true'); if (params.origin) qs.set('origin', params.origin); if (params.sort) qs.set('sort', params.sort); qs.set('page', String(params.page ?? 1)); diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 29327f1..1b41963 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-07-10b', + date: 'July 2026', + items: [ + 'New JAV tab: a separate section for Japanese adult video (English-subtitled). It is kept out of the main Scenes feed, so the main list stays as it was.', + ], + }, { id: '2026-07-10', date: 'July 2026', diff --git a/mobile/src/navigation.tsx b/mobile/src/navigation.tsx index 3bcfc2a..8be729e 100644 --- a/mobile/src/navigation.tsx +++ b/mobile/src/navigation.tsx @@ -33,6 +33,8 @@ export type RootStackParamList = { Scenes: undefined; Movies: undefined; Sites: undefined; + // JAV: osobna sekcja — ScenesScreen z paramem jav → backend ?jav=true. + JAV: { jav: true } | undefined; // `origin`: raw playback_source.origin (np. 'tube:hqpornercom'). Idzie do // listScenes({origin}) — backend robi substring match. `name`: display name // do title bara (np. 'hqporner.com'). @@ -97,7 +99,7 @@ export type RootStackParamList = { const Stack = createNativeStackNavigator(); -type TopTab = 'Scenes' | 'Movies' | 'Sites'; +type TopTab = 'Scenes' | 'Movies' | 'Sites' | 'JAV'; function TopTabs({ current, @@ -106,7 +108,7 @@ function TopTabs({ current: TopTab; onNavigate: (tab: TopTab) => void; }) { - const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites']; + const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites', 'JAV']; return ( {/* Logo (mark) — spójne z ekranem logowania; węższe od wordmarku, więc Scenes/Movies/Sites @@ -241,6 +243,28 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps ), })} /> + ({ + title: '', + headerBackVisible: false, + headerLeft: () => ( + navigation.replace(t)} + /> + ), + headerRight: () => ( + + + 🚪 + + + ), + })} + /> >(); + // Zakładka JAV reużywa ten ekran z paramem jav → backend ?jav=true (osobny vertical). + const route = useRoute(); + const jav = (route.params as { jav?: boolean } | undefined)?.jav === true; const [q, setQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState(''); const [filter, setFilter] = useState(DEFAULT_FILTER); @@ -50,7 +53,7 @@ export function ScenesScreen() { hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ - queryKey: ['scenes', debouncedQ, filter], + queryKey: ['scenes', debouncedQ, filter, jav], queryFn: ({ pageParam = 1 }) => client.listScenes({ q: debouncedQ || undefined, @@ -61,8 +64,11 @@ export function ScenesScreen() { sort: filter.sort, include_stubs: filter.includeStubs || undefined, origin: filter.origin.trim() || undefined, + jav: jav || undefined, // 0 = "Any" → undefined → backend stosuje bazowy próg 60s. >0 podnosi minimum. - min_duration_sec: filter.minDurationSec > 0 ? filter.minDurationSec : undefined, + // JAV nie ma duration (javflix nie podaje) → w sekcji JAV wyłączamy próg 60s + // (explicit 0), inaczej NULL >= 60 = false i cała sekcja byłaby pusta. + min_duration_sec: jav ? 0 : filter.minDurationSec > 0 ? filter.minDurationSec : undefined, page: pageParam, per_page: PER_PAGE, }), diff --git a/mobile/src/types.ts b/mobile/src/types.ts index 0c9624d..f0704eb 100644 --- a/mobile/src/types.ts +++ b/mobile/src/types.ts @@ -151,6 +151,7 @@ export interface ScenesListParams { min_quality_p?: number; include_stubs?: boolean; origin?: string; + jav?: boolean; sort?: ScenesSort; page?: number; per_page?: number;