diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index 1533532..807dd77 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -1,4 +1,6 @@ -"""Gallery API: cursor scroll, timeline, jump, image detail.""" +"""Gallery API: cursor scroll, timeline, jump, image detail, facets.""" + +from datetime import UTC, datetime, timedelta from quart import Blueprint, jsonify, request @@ -8,11 +10,24 @@ from ..services.gallery_service import GalleryService gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery") +def _parse_date(raw): + """Parse a YYYY-MM-DD query value to a UTC midnight datetime, or None. + Raises ValueError (→ 400) on a malformed value.""" + if not raw: + return None + return datetime.strptime(raw, "%Y-%m-%d").replace(tzinfo=UTC) + + def _parse_filters(): - """Parse the composable gallery filters from query args. Raises - ValueError (→ 400) on malformed ids. `tag_id` accepts a single id or a - comma-separated list (AND); `media` is image|video; `sort` is - newest|oldest.""" + """Parse the composable gallery filters from query args, returning + ``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates. + + `tag_id` accepts a single id or a comma-separated list (AND); `media` is + image|video; `sort` is newest|oldest; `platform` selects one platform + (or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are boolean + flags; `date_from`/`date_to` are inclusive calendar-day bounds (date_to is + widened by a day so the whole day is covered by the service's half-open + `< date_to`).""" tag_raw = request.args.get("tag_id") tag_ids = ( [int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None @@ -25,7 +40,20 @@ def _parse_filters(): media_type = media if media in ("image", "video") else None sort = request.args.get("sort") sort = sort if sort in ("newest", "oldest") else "newest" - return tag_ids, post_id, artist_id, media_type, sort + platform = request.args.get("platform") or None + untagged = request.args.get("untagged") in ("1", "true", "yes") + no_artist = request.args.get("no_artist") in ("1", "true", "yes") + date_from = _parse_date(request.args.get("date_from")) + date_to = _parse_date(request.args.get("date_to")) + if date_to is not None: + date_to += timedelta(days=1) # inclusive of the date_to calendar day + filters = { + "tag_ids": tag_ids, "post_id": post_id, "artist_id": artist_id, + "media_type": media_type, "platform": platform, + "untagged": untagged, "no_artist": no_artist, + "date_from": date_from, "date_to": date_to, + } + return filters, sort @gallery_bp.route("/scroll", methods=["GET"]) @@ -33,7 +61,7 @@ async def scroll(): cursor = request.args.get("cursor") or None try: limit = int(request.args.get("limit", "50")) - tag_ids, post_id, artist_id, media_type, sort = _parse_filters() + filters, sort = _parse_filters() except ValueError: return jsonify({"error": "invalid filter or limit parameter"}), 400 @@ -41,9 +69,7 @@ async def scroll(): svc = GalleryService(session) try: page = await svc.scroll( - cursor=cursor, limit=limit, tag_ids=tag_ids, - post_id=post_id, artist_id=artist_id, - media_type=media_type, sort=sort, + cursor=cursor, limit=limit, sort=sort, **filters, ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 @@ -75,16 +101,13 @@ async def scroll(): @gallery_bp.route("/timeline", methods=["GET"]) async def timeline(): try: - tag_ids, post_id, artist_id, media_type, _sort = _parse_filters() + filters, _sort = _parse_filters() except ValueError: return jsonify({"error": "invalid filter parameter"}), 400 async with get_session() as session: svc = GalleryService(session) try: - buckets = await svc.timeline( - tag_ids=tag_ids, post_id=post_id, artist_id=artist_id, - media_type=media_type, - ) + buckets = await svc.timeline(**filters) except ValueError as exc: return jsonify({"error": str(exc)}), 400 return jsonify( @@ -92,21 +115,43 @@ async def timeline(): ) +@gallery_bp.route("/facets", methods=["GET"]) +async def facets(): + try: + filters, _sort = _parse_filters() + except ValueError: + return jsonify({"error": "invalid filter parameter"}), 400 + async with get_session() as session: + svc = GalleryService(session) + try: + f = await svc.facets(**filters) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify( + { + "total": f.total, + "platforms": f.platforms, + "untagged": f.untagged, + "no_artist": f.no_artist, + "date_min": f.date_min.isoformat() if f.date_min else None, + "date_max": f.date_max.isoformat() if f.date_max else None, + } + ) + + @gallery_bp.route("/jump", methods=["GET"]) async def jump(): try: year = int(request.args["year"]) month = int(request.args["month"]) - tag_ids, post_id, artist_id, media_type, sort = _parse_filters() + filters, sort = _parse_filters() except (KeyError, ValueError): return jsonify({"error": "year and month query params required"}), 400 async with get_session() as session: svc = GalleryService(session) try: cursor = await svc.jump_cursor( - year=year, month=month, tag_ids=tag_ids, - post_id=post_id, artist_id=artist_id, - media_type=media_type, sort=sort, + year=year, month=month, sort=sort, **filters, ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 7690875..5002af8 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -18,15 +18,22 @@ import base64 from dataclasses import dataclass from datetime import datetime -from sqlalchemy import Select, and_, exists, func, or_, select +from sqlalchemy import Select, and_, distinct, exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import aliased -from ..models import Artist, ImageProvenance, ImageRecord, Post, Tag +from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag from ..models.tag import image_tag CURSOR_SEPARATOR = "|" +# Reserved `platform` filter value selecting images with NO platformed +# provenance (filesystem imports). Returned by facets() as a null-valued +# bucket; the frontend maps that null back to this sentinel in the URL so the +# bucket is selectable. Underscore-wrapped so it can't collide with a real +# gallery-dl platform name (patreon/pixiv/...). +UNSOURCED_PLATFORM = "__unsourced__" + def encode_cursor(effective_date: datetime, image_id: int) -> str: raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}" @@ -92,6 +99,16 @@ class TimelineBucket: count: int +@dataclass(frozen=True) +class GalleryFacets: + total: int # images matching the FULL active filter + platforms: list[dict] # [{"value": str|None, "count": int}], null = unsourced + untagged: int # how many the Untagged flag would isolate + no_artist: int # how many the No-artist flag would isolate + date_min: datetime | None + date_max: datetime | None + + def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str: """Return the URL to fetch a thumbnail. @@ -128,15 +145,27 @@ def _require_single_filter(tag_ids, post_id, artist_id) -> None: ) -def _apply_scope(stmt, *, tag_ids, post_id, artist_id, media_type): - """Apply the composable gallery filters to a statement already joined - to Post via _outer_join_primary_post. +def _apply_scope( + stmt, *, tag_ids, post_id, artist_id, media_type, + platform=None, untagged=False, no_artist=False, + date_from=None, date_to=None, +): + """Apply the composable gallery filters to a statement. - - tag_ids: image must carry ALL of them — one correlated EXISTS per tag - (AND), which avoids the row-multiplication a multi-join would cause. + All clauses are correlated EXISTS / scalar predicates on ImageRecord, so + they AND together without row-multiplication and don't require any join to + be present on `stmt` (the artist/platform paths alias Post/Source inside + their own EXISTS). + + - tag_ids: image must carry ALL of them — one correlated EXISTS per tag. - post_id / artist_id: provenance EXISTS (post_id is exclusive, guarded by _require_single_filter). - media_type: 'image' | 'video' narrows by mime prefix. + - platform: EXISTS a provenance→source with that platform; the + UNSOURCED_PLATFORM sentinel inverts it (NO platformed provenance). + - untagged: NOT EXISTS any image_tag row. + - no_artist: ImageRecord.artist_id IS NULL. + - date_from / date_to: half-open [from, to) bounds on effective_date. """ for tid in tag_ids or []: stmt = stmt.where( @@ -152,9 +181,39 @@ def _apply_scope(stmt, *, tag_ids, post_id, artist_id, media_type): stmt = stmt.where(ImageRecord.mime.like("image/%")) elif media_type == "video": stmt = stmt.where(ImageRecord.mime.like("video/%")) + if platform is not None: + stmt = stmt.where(_platform_clause(platform)) + if untagged: + stmt = stmt.where( + ~exists().where(image_tag.c.image_record_id == ImageRecord.id) + ) + if no_artist: + stmt = stmt.where(ImageRecord.artist_id.is_(None)) + eff = _effective_date_col() + if date_from is not None: + stmt = stmt.where(eff >= date_from) + if date_to is not None: + stmt = stmt.where(eff < date_to) return stmt +def _platform_clause(platform): + """Correlated EXISTS on a provenance row whose Source carries `platform`. + The UNSOURCED_PLATFORM sentinel inverts to NOT EXISTS(any sourced + provenance) — i.e. filesystem-imported content with no platform.""" + src = aliased(Source) + if platform == UNSOURCED_PLATFORM: + return ~exists().where( + ImageProvenance.image_record_id == ImageRecord.id, + ImageProvenance.source_id == src.id, + ) + return exists().where( + ImageProvenance.image_record_id == ImageRecord.id, + ImageProvenance.source_id == src.id, + src.platform == platform, + ) + + def _provenance_clause(post_id, artist_id): """Correlated EXISTS clause (NOT a join) so an image with multiple matching provenance rows is returned exactly once and the @@ -215,6 +274,11 @@ class GalleryService: artist_id: int | None = None, media_type: str | None = None, sort: str = "newest", + platform: str | None = None, + untagged: bool = False, + no_artist: bool = False, + date_from: datetime | None = None, + date_to: datetime | None = None, ) -> GalleryPage: if limit < 1 or limit > 200: raise ValueError("limit must be between 1 and 200") @@ -226,6 +290,8 @@ class GalleryService: stmt = _apply_scope( stmt, tag_ids=tag_ids, post_id=post_id, artist_id=artist_id, media_type=media_type, + platform=platform, untagged=untagged, no_artist=no_artist, + date_from=date_from, date_to=date_to, ) descending = sort != "oldest" @@ -286,6 +352,11 @@ class GalleryService: post_id: int | None = None, artist_id: int | None = None, media_type: str | None = None, + platform: str | None = None, + untagged: bool = False, + no_artist: bool = False, + date_from: datetime | None = None, + date_to: datetime | None = None, ) -> list[TimelineBucket]: eff = _effective_date_col() year_col = func.date_part("year", eff).label("yr") @@ -298,6 +369,8 @@ class GalleryService: stmt = _apply_scope( stmt, tag_ids=tag_ids, post_id=post_id, artist_id=artist_id, media_type=media_type, + platform=platform, untagged=untagged, no_artist=no_artist, + date_from=date_from, date_to=date_to, ) stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc()) rows = (await self.session.execute(stmt)).all() @@ -307,6 +380,9 @@ class GalleryService: self, year: int, month: int, tag_ids: list[int] | None = None, post_id: int | None = None, artist_id: int | None = None, media_type: str | None = None, sort: str = "newest", + platform: str | None = None, untagged: bool = False, + no_artist: bool = False, date_from: datetime | None = None, + date_to: datetime | None = None, ) -> str | None: """Returns a cursor that, when passed to scroll() with the same sort, positions at the first image of the given year-month. None if the @@ -324,6 +400,8 @@ class GalleryService: stmt = _apply_scope( stmt, tag_ids=tag_ids, post_id=post_id, artist_id=artist_id, media_type=media_type, + platform=platform, untagged=untagged, no_artist=no_artist, + date_from=date_from, date_to=date_to, ) descending = sort != "oldest" if descending: @@ -339,6 +417,94 @@ class GalleryService: boundary = record.id + 1 if descending else record.id - 1 return encode_cursor(eff_date, boundary) + async def facets( + self, *, tag_ids: list[int] | None = None, + post_id: int | None = None, artist_id: int | None = None, + media_type: str | None = None, platform: str | None = None, + untagged: bool = False, no_artist: bool = False, + date_from: datetime | None = None, date_to: datetime | None = None, + ) -> GalleryFacets: + """Live facet counts scoped to the current filter. Each facet GROUP is + computed with all OTHER active filters applied but its OWN selection + ignored ("minus-self"), so sibling options stay visible/switchable. + No outer join is needed — every clause is a correlated EXISTS or a + column predicate on ImageRecord. + """ + _require_single_filter(tag_ids, post_id, artist_id) + common = dict( + tag_ids=tag_ids, post_id=post_id, + artist_id=artist_id, media_type=media_type, + ) + + # total — the full active filter (the headline result count). + total = (await self.session.execute( + _apply_scope( + select(func.count(ImageRecord.id)), **common, + platform=platform, untagged=untagged, no_artist=no_artist, + date_from=date_from, date_to=date_to, + ) + )).scalar_one() + + # platforms — scope minus the platform selection. Inner-join + # provenance→source and COUNT(DISTINCT image) per platform (a + # cross-posted image counts under each of its platforms). + plat_scope = dict( + common, untagged=untagged, no_artist=no_artist, + date_from=date_from, date_to=date_to, + ) + src = aliased(Source) + plat_stmt = ( + select(src.platform, func.count(distinct(ImageRecord.id))) + .select_from(ImageRecord) + .join(ImageProvenance, ImageProvenance.image_record_id == ImageRecord.id) + .join(src, src.id == ImageProvenance.source_id) + ) + plat_stmt = _apply_scope(plat_stmt, **plat_scope).group_by(src.platform) + platforms = [ + {"value": p, "count": c} + for p, c in (await self.session.execute(plat_stmt)).all() + ] + # Unsourced (filesystem) bucket — same minus-platform scope. + unsourced = (await self.session.execute( + _apply_scope( + select(func.count(ImageRecord.id)), **plat_scope, + platform=UNSOURCED_PLATFORM, + ) + )).scalar_one() + if unsourced: + platforms.append({"value": None, "count": unsourced}) + + # curation flags — each minus its OWN flag. + untagged_count = (await self.session.execute( + _apply_scope( + select(func.count(ImageRecord.id)), **common, + platform=platform, no_artist=no_artist, + date_from=date_from, date_to=date_to, untagged=True, + ) + )).scalar_one() + no_artist_count = (await self.session.execute( + _apply_scope( + select(func.count(ImageRecord.id)), **common, + platform=platform, untagged=untagged, + date_from=date_from, date_to=date_to, no_artist=True, + ) + )).scalar_one() + + # date bounds — scope minus the date params (those drive the picker). + eff = _effective_date_col() + dmin, dmax = (await self.session.execute( + _apply_scope( + select(func.min(eff), func.max(eff)), **common, + platform=platform, untagged=untagged, no_artist=no_artist, + ) + )).one() + + return GalleryFacets( + total=total, platforms=platforms, + untagged=untagged_count, no_artist=no_artist_count, + date_min=dmin, date_max=dmax, + ) + async def get_image_with_tags(self, image_id: int) -> dict | None: record = await self.session.get(ImageRecord, image_id) if record is None: diff --git a/tests/test_api_gallery.py b/tests/test_api_gallery.py index cf62d9b..f95a671 100644 --- a/tests/test_api_gallery.py +++ b/tests/test_api_gallery.py @@ -77,3 +77,40 @@ async def test_scroll_media_param(client, db): resp = await client.get("/api/gallery/scroll?limit=10&media=video") assert resp.status_code == 200 assert (await resp.get_json())["images"] == [] + + +@pytest.mark.asyncio +async def test_facets_endpoint(client, db): + await _seed(db, 3) # filesystem images: no artist, no tags, no platform + resp = await client.get("/api/gallery/facets") + assert resp.status_code == 200 + body = await resp.get_json() + assert body["total"] == 3 + assert body["untagged"] == 3 + assert body["no_artist"] == 3 + assert "platforms" in body + assert body["date_min"] is not None + assert body["date_max"] is not None + + +@pytest.mark.asyncio +async def test_facets_rejects_bad_date(client): + resp = await client.get("/api/gallery/facets?date_from=notadate") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_scroll_untagged_param(client, db): + await _seed(db, 2) + resp = await client.get("/api/gallery/scroll?untagged=1&limit=10") + assert resp.status_code == 200 + assert len((await resp.get_json())["images"]) == 2 + + +@pytest.mark.asyncio +async def test_scroll_date_from_param(client, db): + await _seed(db, 2) # all created ~now + # A future date_from excludes everything. + resp = await client.get("/api/gallery/scroll?date_from=2099-01-01&limit=10") + assert resp.status_code == 200 + assert (await resp.get_json())["images"] == [] diff --git a/tests/test_gallery_facets.py b/tests/test_gallery_facets.py new file mode 100644 index 0000000..f5ffad7 --- /dev/null +++ b/tests/test_gallery_facets.py @@ -0,0 +1,252 @@ +"""Phase-2 faceted refine: new composable filter params + the facets() +aggregate (platform / curation-flags / date-range counts, scoped live to the +current filter with per-group minus-self semantics).""" + +from datetime import UTC, datetime + +import pytest + +from backend.app.models import ( + Artist, + ImageProvenance, + ImageRecord, + Post, + Source, + Tag, + TagKind, +) +from backend.app.models.tag import image_tag +from backend.app.services.gallery_service import ( + UNSOURCED_PLATFORM, + GalleryService, +) + +pytestmark = pytest.mark.integration + +_BASE = datetime(2026, 1, 1, 12, 0, tzinfo=UTC) + + +async def _artist(db, name): + a = Artist(name=name, slug=name.lower()) + db.add(a) + await db.flush() + return a + + +async def _sourced_image(db, n, platform, artist, eff): + """A downloaded image with a Source(platform) + Post + provenance row.""" + s = Source( + artist_id=artist.id, platform=platform, + url=f"https://{platform}.test/{artist.slug}/{n}", + ) + db.add(s) + await db.flush() + p = Post(source_id=s.id, artist_id=artist.id, external_post_id=f"{platform}-{n}") + db.add(p) + await db.flush() + rec = ImageRecord( + path=f"/images/s/{platform}-{n}.jpg", sha256=f"a{n:063d}", + size_bytes=1, mime="image/jpeg", width=1, height=1, + origin="downloaded", integrity_status="ok", + artist_id=artist.id, primary_post_id=p.id, + ) + rec.created_at = eff + rec.effective_date = eff + db.add(rec) + await db.flush() + db.add(ImageProvenance(image_record_id=rec.id, post_id=p.id, source_id=s.id)) + await db.flush() + return rec + + +async def _fs_image(db, n, eff, artist=None): + """A filesystem-imported image: no provenance/source (unsourced), + artist optional.""" + rec = ImageRecord( + path=f"/images/fs/{n}.jpg", sha256=f"b{n:063d}", + size_bytes=1, mime="image/jpeg", width=1, height=1, + origin="imported_filesystem", integrity_status="unknown", + artist_id=artist.id if artist else None, + ) + rec.created_at = eff + rec.effective_date = eff + db.add(rec) + await db.flush() + return rec + + +async def _tag(db, image, name): + t = Tag(name=name, kind=TagKind.general) + db.add(t) + await db.flush() + await db.execute(image_tag.insert().values( + image_record_id=image.id, tag_id=t.id, source="manual")) + await db.flush() + return t + + +# --- facets() aggregate ---------------------------------------------------- + +@pytest.mark.asyncio +async def test_facets_platform_counts(db): + ar = await _artist(db, "Ar") + await _sourced_image(db, 1, "patreon", ar, _BASE) + await _sourced_image(db, 2, "patreon", ar, _BASE) + await _sourced_image(db, 3, "pixiv", ar, _BASE) + await _fs_image(db, 4, _BASE) # unsourced → null bucket + svc = GalleryService(db) + f = await svc.facets() + counts = {p["value"]: p["count"] for p in f.platforms} + assert counts["patreon"] == 2 + assert counts["pixiv"] == 1 + assert counts[None] == 1 + assert f.total == 4 + + +@pytest.mark.asyncio +async def test_facets_counts_image_under_each_platform(db): + """A single image cross-posted to two platforms counts under both + (DISTINCT image per platform), but is one image in total.""" + ar = await _artist(db, "Ar") + rec = await _sourced_image(db, 1, "patreon", ar, _BASE) + s2 = Source(artist_id=ar.id, platform="pixiv", url="https://pixiv.test/ar/x") + db.add(s2) + await db.flush() + p2 = Post(source_id=s2.id, artist_id=ar.id, external_post_id="pixiv-x") + db.add(p2) + await db.flush() + db.add(ImageProvenance(image_record_id=rec.id, post_id=p2.id, source_id=s2.id)) + await db.flush() + svc = GalleryService(db) + f = await svc.facets() + counts = {p["value"]: p["count"] for p in f.platforms} + assert counts["patreon"] == 1 + assert counts["pixiv"] == 1 + assert f.total == 1 + + +@pytest.mark.asyncio +async def test_facets_curation_flags(db): + ar = await _artist(db, "Ar") + tagged = await _fs_image(db, 1, _BASE, artist=ar) + await _tag(db, tagged, "x") + await _fs_image(db, 2, _BASE, artist=ar) # untagged, has artist + await _fs_image(db, 3, _BASE, artist=None) # untagged, no artist + svc = GalleryService(db) + f = await svc.facets() + assert f.untagged == 2 + assert f.no_artist == 1 + + +@pytest.mark.asyncio +async def test_facets_date_bounds(db): + ar = await _artist(db, "Ar") + await _fs_image(db, 1, datetime(2020, 1, 1, tzinfo=UTC), artist=ar) + await _fs_image(db, 2, datetime(2026, 6, 1, tzinfo=UTC), artist=ar) + svc = GalleryService(db) + f = await svc.facets() + assert f.date_min == datetime(2020, 1, 1, tzinfo=UTC) + assert f.date_max == datetime(2026, 6, 1, tzinfo=UTC) + + +@pytest.mark.asyncio +async def test_facets_platform_minus_self(db): + """The platform group ignores its OWN active selection so siblings stay + visible/switchable; total still honors the platform filter.""" + ar = await _artist(db, "Ar") + await _sourced_image(db, 1, "patreon", ar, _BASE) + await _sourced_image(db, 2, "pixiv", ar, _BASE) + svc = GalleryService(db) + f = await svc.facets(platform="patreon") + counts = {p["value"]: p["count"] for p in f.platforms} + assert counts.get("pixiv") == 1 + assert counts.get("patreon") == 1 + assert f.total == 1 + + +@pytest.mark.asyncio +async def test_facets_other_filter_scopes_platforms(db): + """A non-platform filter (media) narrows the platform counts.""" + ar = await _artist(db, "Ar") + await _sourced_image(db, 1, "patreon", ar, _BASE) + vid = await _sourced_image(db, 2, "patreon", ar, _BASE) + vid.mime = "video/mp4" + await db.flush() + svc = GalleryService(db) + f = await svc.facets(media_type="image") + counts = {p["value"]: p["count"] for p in f.platforms} + assert counts["patreon"] == 1 + + +# --- new scroll filter params ---------------------------------------------- + +@pytest.mark.asyncio +async def test_scroll_platform_filter(db): + ar = await _artist(db, "Ar") + pat = await _sourced_image(db, 1, "patreon", ar, _BASE) + await _sourced_image(db, 2, "pixiv", ar, _BASE) + svc = GalleryService(db) + page = await svc.scroll(cursor=None, limit=10, platform="patreon") + assert [i.id for i in page.images] == [pat.id] + + +@pytest.mark.asyncio +async def test_scroll_platform_unsourced(db): + ar = await _artist(db, "Ar") + await _sourced_image(db, 1, "patreon", ar, _BASE) + fs = await _fs_image(db, 2, _BASE) + svc = GalleryService(db) + page = await svc.scroll(cursor=None, limit=10, platform=UNSOURCED_PLATFORM) + assert [i.id for i in page.images] == [fs.id] + + +@pytest.mark.asyncio +async def test_scroll_untagged_filter(db): + ar = await _artist(db, "Ar") + tagged = await _fs_image(db, 1, _BASE, artist=ar) + await _tag(db, tagged, "x") + untag = await _fs_image(db, 2, _BASE, artist=ar) + svc = GalleryService(db) + page = await svc.scroll(cursor=None, limit=10, untagged=True) + assert [i.id for i in page.images] == [untag.id] + + +@pytest.mark.asyncio +async def test_scroll_no_artist_filter(db): + ar = await _artist(db, "Ar") + await _fs_image(db, 1, _BASE, artist=ar) + none = await _fs_image(db, 2, _BASE, artist=None) + svc = GalleryService(db) + page = await svc.scroll(cursor=None, limit=10, no_artist=True) + assert [i.id for i in page.images] == [none.id] + + +@pytest.mark.asyncio +async def test_scroll_date_range_filter(db): + ar = await _artist(db, "Ar") + old = await _fs_image(db, 1, datetime(2020, 1, 1, tzinfo=UTC), artist=ar) + new = await _fs_image(db, 2, datetime(2026, 6, 1, tzinfo=UTC), artist=ar) + svc = GalleryService(db) + page = await svc.scroll( + cursor=None, limit=10, date_from=datetime(2026, 1, 1, tzinfo=UTC)) + assert [i.id for i in page.images] == [new.id] + page2 = await svc.scroll( + cursor=None, limit=10, date_to=datetime(2021, 1, 1, tzinfo=UTC)) + assert [i.id for i in page2.images] == [old.id] + + +@pytest.mark.asyncio +async def test_scroll_combines_new_filters_and(db): + """platform + untagged + no_artist compose with AND.""" + ar = await _artist(db, "Ar") + # match: unsourced, untagged, no artist + match = await _fs_image(db, 1, _BASE, artist=None) + # decoys violating one clause each + await _fs_image(db, 2, _BASE, artist=ar) # has artist + tagged = await _fs_image(db, 3, _BASE, artist=None) + await _tag(db, tagged, "x") # tagged + svc = GalleryService(db) + page = await svc.scroll( + cursor=None, limit=10, + platform=UNSOURCED_PLATFORM, untagged=True, no_artist=True) + assert [i.id for i in page.images] == [match.id]