fix(favorites): exclude bulk-backfill scenes from "+N new" (tube fake dates)
Some checks are pending
Backend tests / test (push) Waiting to run

Browse scrapers backfilling old catalogs stamp the tube's import/post date as
release_date, so old content (e.g. 83 MissaX classics via perverzija, ~3600/3 days
across eporner/youporn/etc.) fake-ranked as newest and flooded the favorites "+N".
NULLing the dates was a non-starter — the stub filter would hide 251k performer-less
scenes. Instead: a Scene.backfill flag marks bulk catalog imports; they stay visible but
never count as "new".

- scenes.backfill column (+ index, migration 0026); resolve_scene/_process_scene thread it.
- deep_crawl tags scenes from pages beyond the "latest" threshold (>2) as backfill;
  latest pages + TPDB/StashDB delta stay genuine. Cursor reset re-sweeps page 1 so real
  new content is always caught fresh.
- favorites +N (performers + studios) excludes backfill within the top-200 window.
- SceneOut exposes `backfill`; mobile NEW badge + NEW-first re-sort skip it (badge==count).
- Retroactive: tagged 439k existing scenes in bulk (>10 non-canonical / studio / day)
  clusters. Device check: favorite-studios +N 11626 (naive) -> 236.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-07-01 16:04:01 +02:00
parent 64aee54fb6
commit 8c7fa36437
13 changed files with 122 additions and 18 deletions

View file

@ -0,0 +1,39 @@
"""scene backfill flag: exclude bulk catalog imports from "new"
Revision ID: 0026_scene_backfill_flag
Revises: 0025_source_ranking
Create Date: 2026-07-02
Tube'y podają datę importu jako release_date, więc masowy backfill starego katalogu
(deep-crawl głębokie strony) udawał świeżość i zawyżał licznik "+N nowych" w ulubionych.
`scenes.backfill` oznacza takie sceny pozostają widoczne, ale nie liczą się jako nowe.
Ustawiane przy tworzeniu (deep_crawl page > próg); świeży latest-crawl + delta TPDB/
StashDB False. Patrz app/scheduler/deep_crawl.py, app/resolve/scene_resolver.py.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0026_scene_backfill_flag"
down_revision: str | None = "0025_source_ranking"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"scenes",
sa.Column(
"backfill",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
op.create_index("ix_scenes_backfill", "scenes", ["backfill"])
def downgrade() -> None:
op.drop_index("ix_scenes_backfill", table_name="scenes")
op.drop_column("scenes", "backfill")

View file

@ -187,6 +187,7 @@ def list_favorites(
select( select(
ScenePerformer.performer_id.label("gid"), ScenePerformer.performer_id.label("gid"),
Scene.created_at.label("created_at"), Scene.created_at.label("created_at"),
Scene.backfill.label("backfill"),
rn, rn,
) )
.join(Scene, Scene.id == ScenePerformer.scene_id) .join(Scene, Scene.id == ScenePerformer.scene_id)
@ -194,11 +195,14 @@ def list_favorites(
.where(*clauses) .where(*clauses)
.subquery() .subquery()
) )
for gid, created_at in session.execute( for gid, created_at, backfill in session.execute(
select(inner.c.gid, inner.c.created_at).where(inner.c.rn <= _FAVORITES_PAGE_CAP) select(inner.c.gid, inner.c.created_at, inner.c.backfill).where(
inner.c.rn <= _FAVORITES_PAGE_CAP
)
): ):
ls = last_seen_by_perf.get(gid) ls = last_seen_by_perf.get(gid)
if created_at is not None and ls is not None and created_at > ls: # backfill (masowy import katalogu) NIE liczy się jako nowość — patrz Scene.backfill.
if not backfill and created_at is not None and ls is not None and created_at > ls:
new_counts[gid] = new_counts.get(gid, 0) + 1 new_counts[gid] = new_counts.get(gid, 0) + 1
items: list[FavoriteOut] = [] items: list[FavoriteOut] = []
@ -334,17 +338,21 @@ def list_favorite_studios(
select( select(
Scene.studio_id.label("gid"), Scene.studio_id.label("gid"),
Scene.created_at.label("created_at"), Scene.created_at.label("created_at"),
Scene.backfill.label("backfill"),
rn, rn,
) )
.where(Scene.studio_id.in_(studio_ids)) .where(Scene.studio_id.in_(studio_ids))
.where(*clauses) .where(*clauses)
.subquery() .subquery()
) )
for gid, created_at in session.execute( for gid, created_at, backfill in session.execute(
select(inner.c.gid, inner.c.created_at).where(inner.c.rn <= _FAVORITES_PAGE_CAP) select(inner.c.gid, inner.c.created_at, inner.c.backfill).where(
inner.c.rn <= _FAVORITES_PAGE_CAP
)
): ):
ls = last_seen_by_studio.get(gid) ls = last_seen_by_studio.get(gid)
if created_at is not None and ls is not None and created_at > ls: # backfill (masowy import katalogu) NIE liczy się jako nowość — patrz Scene.backfill.
if not backfill and created_at is not None and ls is not None and created_at > ls:
new_counts[gid] = new_counts.get(gid, 0) + 1 new_counts[gid] = new_counts.get(gid, 0) + 1
items: list[FavoriteStudioOut] = [] items: list[FavoriteStudioOut] = []

View file

@ -693,6 +693,7 @@ def _build_scenes_out_batch(
external_refs=refs_by_scene.get(scene.id, []), external_refs=refs_by_scene.get(scene.id, []),
playback_sources=pb_by_scene.get(scene.id, []), playback_sources=pb_by_scene.get(scene.id, []),
created_at=scene.created_at, created_at=scene.created_at,
backfill=scene.backfill,
last_played_at=progress.last_played_at if progress else None, last_played_at=progress.last_played_at if progress else None,
finished=progress.finished if progress else False, finished=progress.finished if progress else False,
position_sec=progress.position_sec if progress else 0, position_sec=progress.position_sec if progress else 0,
@ -828,6 +829,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
external_refs=refs_out, external_refs=refs_out,
playback_sources=playback_out, playback_sources=playback_out,
created_at=scene.created_at, created_at=scene.created_at,
backfill=scene.backfill,
last_played_at=progress.last_played_at if progress else None, last_played_at=progress.last_played_at if progress else None,
finished=progress.finished if progress else False, finished=progress.finished if progress else False,
position_sec=progress.position_sec if progress else 0, position_sec=progress.position_sec if progress else 0,

View file

@ -70,6 +70,9 @@ class SceneOut(BaseModel):
# "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen — gdy # "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen — gdy
# `created_at > last_seen_at` (favorite) → badge. # `created_at > last_seen_at` (favorite) → badge.
created_at: datetime | None = None created_at: datetime | None = None
# True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej
# NEW badge nawet gdy created_at > last_seen — to stara treść, nie świeży release.
backfill: bool = False
# Watched indicator (z `scene_play_progress`): mobile dim'uje kafelek gdy # Watched indicator (z `scene_play_progress`): mobile dim'uje kafelek gdy
# `finished=True`, pokazuje progress bar gdy `position_sec > 0`. # `finished=True`, pokazuje progress bar gdy `position_sec > 0`.
last_played_at: datetime | None = None last_played_at: datetime | None = None

View file

@ -267,7 +267,13 @@ def ingest_from_connector(
return counters return counters
def _process_scene(*, source_id: uuid.UUID, raw_scene: RawScene, counters: dict[str, int]) -> None: def _process_scene(
*,
source_id: uuid.UUID,
raw_scene: RawScene,
counters: dict[str, int],
backfill: bool = False,
) -> None:
payload = raw_scene.raw or raw_scene.model_dump(mode="json") payload = raw_scene.raw or raw_scene.model_dump(mode="json")
if _has_nul(payload): if _has_nul(payload):
# Strip NUL z payloadu (→ external_records.raw JSONB) ORAZ ze structured fields # Strip NUL z payloadu (→ external_records.raw JSONB) ORAZ ze structured fields
@ -304,7 +310,7 @@ def _process_scene(*, source_id: uuid.UUID, raw_scene: RawScene, counters: dict[
counters["skipped"] += 1 counters["skipped"] += 1
return return
result = resolve_scene(session, norm=norm, source_id=source_id) result = resolve_scene(session, norm=norm, source_id=source_id, backfill=backfill)
if result.was_created: if result.was_created:
counters["new"] += 1 counters["new"] += 1

View file

@ -3,6 +3,7 @@ import uuid
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import ( from sqlalchemy import (
Boolean,
Date, Date,
DateTime, DateTime,
Enum, Enum,
@ -40,6 +41,14 @@ class Scene(UUIDPKMixin, TimestampMixin, Base):
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
code: Mapped[str | None] = mapped_column(String(128), index=True) code: Mapped[str | None] = mapped_column(String(128), index=True)
director: Mapped[str | None] = mapped_column(String(256)) director: Mapped[str | None] = mapped_column(String(256))
# True = scena wpadła masowym backfillem katalogu (deep-crawl, głębokie strony), NIE
# świeży ingest. Tube podają datę importu jako release_date, więc stary backfill
# udaje "nowość" (licznik +N w ulubionych). Ten flag pozwala go wykluczyć z "nowych"
# bez chowania sceny. Ustawiany przy TWORZENIU (deep_crawl page > próg); świeży
# latest-crawl + delta TPDB/StashDB → False. Patrz app/scheduler/deep_crawl.py.
backfill: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="false", index=True
)
class SceneExternalRef(Base): class SceneExternalRef(Base):

View file

@ -69,7 +69,12 @@ def resolve_scene(
*, *,
norm: NormalizedScene, norm: NormalizedScene,
source_id: uuid.UUID, source_id: uuid.UUID,
backfill: bool = False,
) -> SceneResolveResult: ) -> SceneResolveResult:
"""backfill=True → nowo utworzona scena to masowy import katalogu (deep-crawl głębokie
strony), NIE świeży release. Flag ląduje na Scene.backfill i wyklucza scenę z "nowych"
(licznik +N w ulubionych). Dotyczy tylko TWORZENIA match do istniejącej sceny nie
zmienia jej flagi (istniejąca mogła być świeża)."""
studio = resolve_studio(session, norm=norm.studio, source_id=source_id) if norm.studio else None studio = resolve_studio(session, norm=norm.studio, source_id=source_id) if norm.studio else None
studio_id = studio.id if studio else None studio_id = studio.id if studio else None
@ -138,7 +143,7 @@ def resolve_scene(
scene_match.title_normalized, norm.title_normalized scene_match.title_normalized, norm.title_normalized
) )
if sp_strength >= 1.0: if sp_strength >= 1.0:
new_scene = _create_canonical(session, norm=norm, studio_id=studio_id) new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill)
_attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm) _attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm)
_sync_attached_entities(session, scene=new_scene, norm=norm, source_id=source_id) _sync_attached_entities(session, scene=new_scene, norm=norm, source_id=source_id)
return SceneResolveResult( return SceneResolveResult(
@ -155,7 +160,7 @@ def resolve_scene(
penalised_score = raw_phash_score * max(dur_prox, 0.1) penalised_score = raw_phash_score * max(dur_prox, 0.1)
if 0.0 < sp_strength < 1.0: if 0.0 < sp_strength < 1.0:
penalised_score = min(penalised_score, 1.0 - sp_strength) penalised_score = min(penalised_score, 1.0 - sp_strength)
new_scene = _create_canonical(session, norm=norm, studio_id=studio_id) new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill)
_attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm) _attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm)
_sync_attached_entities(session, scene=new_scene, norm=norm, source_id=source_id) _sync_attached_entities(session, scene=new_scene, norm=norm, source_id=source_id)
session.add( session.add(
@ -189,7 +194,7 @@ def resolve_scene(
# ale auto-merge zablokowane: tworzymy nową scenę + pending review. # ale auto-merge zablokowane: tworzymy nową scenę + pending review.
if 0.0 < sp_strength < 1.0: if 0.0 < sp_strength < 1.0:
penalised_score = min(raw_phash_score, 1.0 - sp_strength) penalised_score = min(raw_phash_score, 1.0 - sp_strength)
new_scene = _create_canonical(session, norm=norm, studio_id=studio_id) new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill)
_attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm) _attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm)
_sync_attached_entities(session, scene=new_scene, norm=norm, source_id=source_id) _sync_attached_entities(session, scene=new_scene, norm=norm, source_id=source_id)
session.add( session.add(
@ -342,7 +347,7 @@ def resolve_scene(
) )
if decision == "review": if decision == "review":
new_scene = _create_canonical(session, norm=norm, studio_id=studio_id) new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill)
_attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm) _attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm)
_sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers) _sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers)
_sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id) _sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id)
@ -369,7 +374,7 @@ def resolve_scene(
) )
# Brak żadnego sensownego dopasowania → nowa kanoniczna # Brak żadnego sensownego dopasowania → nowa kanoniczna
new_scene = _create_canonical(session, norm=norm, studio_id=studio_id) new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill)
_attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm) _attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm)
_sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers) _sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers)
_sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id) _sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id)
@ -403,7 +408,11 @@ def _cap(s: str | None, n: int) -> str | None:
def _create_canonical( def _create_canonical(
session: Session, *, norm: NormalizedScene, studio_id: uuid.UUID | None session: Session,
*,
norm: NormalizedScene,
studio_id: uuid.UUID | None,
backfill: bool = False,
) -> Scene: ) -> Scene:
scene = Scene( scene = Scene(
title=norm.title, title=norm.title,
@ -415,6 +424,7 @@ def _create_canonical(
description=norm.description, description=norm.description,
code=_cap(norm.code, 128), code=_cap(norm.code, 128),
director=_cap(norm.director, 256), director=_cap(norm.director, 256),
backfill=backfill,
) )
session.add(scene) session.add(scene)
session.flush() session.flush()

View file

@ -46,6 +46,13 @@ _PAGE_CAP: dict[str, int] = {
# wracamy czysto — następny run kontynuuje. Margines 600s na dokończenie strony w toku. # wracamy czysto — następny run kontynuuje. Margines 600s na dokończenie strony w toku.
_RUN_BUDGET_SEC = 3000 _RUN_BUDGET_SEC = 3000
# Strony <= tego progu = "latest" (świeże posty tube'a) → nowe sceny stąd to genuine
# nowości. Głębsze strony = backfill starego katalogu → nowe sceny stąd dostają
# Scene.backfill=True i NIE liczą się jako "nowe" w ulubionych (tube podaje datę importu
# jako release_date, więc stary backfill inaczej udaje świeżość). Reset kursora re-sweepuje
# od strony 1, więc genuinnie nowe zawsze łapią się na stronach 1-2.
_LATEST_PAGE_THRESHOLD = 2
def _state_path() -> Path: def _state_path() -> Path:
return Path(getattr(get_settings(), "deepcrawl_state_path", None) or _DEFAULT_STATE) return Path(getattr(get_settings(), "deepcrawl_state_path", None) or _DEFAULT_STATE)
@ -142,10 +149,16 @@ def run_deep_crawl(*, pages_per_run: int = 60, sitetags: list[str] | None = None
exhausted = True exhausted = True
last_done = page last_done = page
break break
page_is_backfill = page > _LATEST_PAGE_THRESHOLD
for raw in scenes: for raw in scenes:
counters["seen"] += 1 counters["seen"] += 1
try: try:
_process_scene(source_id=source_id, raw_scene=raw, counters=counters) _process_scene(
source_id=source_id,
raw_scene=raw,
counters=counters,
backfill=page_is_backfill,
)
except Exception: except Exception:
counters["errors"] += 1 counters["errors"] += 1
last_done = page last_done = page

View file

@ -16,6 +16,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-07-02',
date: 'July 2026',
items: [
'Favorites "+N new" now counts genuinely new scenes only — bulk back-catalog imports (old content a site re-dated to today) no longer inflate the badge or show a NEW tag.',
],
},
{ {
id: '2026-07-01b', id: '2026-07-01b',
date: 'July 2026', date: 'July 2026',

View file

@ -80,7 +80,9 @@ function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }:
}; };
const dim = scene.finished === true; const dim = scene.finished === true;
const isNew = !!(seenSince && scene.created_at && scene.created_at > seenSince); // NEW = dodane od ostatniej wizyty, ale NIE backfill (masowy import starego katalogu —
// tube podaje datę importu jako release_date, więc udawałby świeżość). Spójne z licznikiem +N.
const isNew = !!(seenSince && scene.created_at && scene.created_at > seenSince && !scene.backfill);
const dur = scene.duration_sec; const dur = scene.duration_sec;
const durLabel = const durLabel =
dur && dur > 0 dur && dur > 0

View file

@ -177,7 +177,8 @@ export function PerformerScenesScreen() {
const newOnes: SceneOut[] = []; const newOnes: SceneOut[] = [];
const rest: SceneOut[] = []; const rest: SceneOut[] = [];
for (const s of items) { for (const s of items) {
if (s.created_at && s.created_at > seenSince) { // NEW-first pomija backfill (masowy import katalogu) — spójne z badge + licznikiem +N.
if (s.created_at && s.created_at > seenSince && !s.backfill) {
newOnes.push(s); newOnes.push(s);
} else { } else {
rest.push(s); rest.push(s);

View file

@ -122,7 +122,8 @@ export function StudioScenesScreen() {
const newOnes: SceneOut[] = []; const newOnes: SceneOut[] = [];
const rest: SceneOut[] = []; const rest: SceneOut[] = [];
for (const s of items) { for (const s of items) {
if (s.created_at && s.created_at > seenSince) { // NEW-first pomija backfill (masowy import katalogu) — spójne z badge + licznikiem +N.
if (s.created_at && s.created_at > seenSince && !s.backfill) {
newOnes.push(s); newOnes.push(s);
} else { } else {
rest.push(s); rest.push(s);

View file

@ -173,6 +173,9 @@ export interface SceneOut {
// Kiedy scena trafiła do bazy (ingest). Używane do oznaczenia "NEW" — gdy // Kiedy scena trafiła do bazy (ingest). Używane do oznaczenia "NEW" — gdy
// `created_at > favoriteSeenSince` (param przekazany z FavoritesScreen). // `created_at > favoriteSeenSince` (param przekazany z FavoritesScreen).
created_at?: string | null; created_at?: string | null;
// True = scena z masowego backfillu katalogu (deep-crawl) — stara treść, nie świeży
// release. NIE pokazujemy na niej NEW badge nawet gdy created_at > seenSince.
backfill?: boolean;
// Watched indicator + favorite state. Backend dolicza z scene_play_progress + favorite_scenes. // Watched indicator + favorite state. Backend dolicza z scene_play_progress + favorite_scenes.
last_played_at?: string | null; last_played_at?: string | null;
finished?: boolean; finished?: boolean;