diff --git a/alembic/versions/0034_artist_visit.py b/alembic/versions/0034_artist_visit.py new file mode 100644 index 0000000..a2234a6 --- /dev/null +++ b/alembic/versions/0034_artist_visit.py @@ -0,0 +1,53 @@ +"""artist_visit: per-artist last-viewed timestamp for the "+N new" badge + +Revision ID: 0034 +Revises: 0033 +Create Date: 2026-06-03 + +Powers the artists-directory "+N new since last visit" badge + ArtistView +banner. Single row per artist (no user_id yet — rule #47 multi-user ACL +is aspirational; widens to (user_id, artist_id) PK when User lands). + +Seed every existing artist with `last_viewed_at = NOW()` so the badge +starts at 0 across the board — no noisy "you have 5000 unseen images" +on first deploy. New artists auto-get a row via +`ArtistService.find_or_create`. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0034" +down_revision: Union[str, None] = "0033" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "artist_visit", + sa.Column( + "artist_id", + sa.Integer, + sa.ForeignKey("artist.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "last_viewed_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("NOW()"), + ), + ) + # Seed: every existing artist starts "fully caught up". Without this, + # every operator with N artists would see N badges (worth of every + # image ever imported) on first deploy. + op.execute( + "INSERT INTO artist_visit (artist_id, last_viewed_at) " + "SELECT id, NOW() FROM artist" + ) + + +def downgrade() -> None: + op.drop_table("artist_visit") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index eec9da0..137311c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from .app_setting import AppSetting from .artist import Artist +from .artist_visit import ArtistVisit from .backup_run import BackupRun from .base import Base from .credential import Credential @@ -28,6 +29,7 @@ __all__ = [ "Base", "AppSetting", "Artist", + "ArtistVisit", "BackupRun", "Source", "Credential", diff --git a/backend/app/models/artist_visit.py b/backend/app/models/artist_visit.py new file mode 100644 index 0000000..d17c39a --- /dev/null +++ b/backend/app/models/artist_visit.py @@ -0,0 +1,36 @@ +"""ArtistVisit — per-artist 'last viewed' timestamp. + +Powers the "+N new since last visit" badge on the artists directory and +the matching banner on `ArtistView`. One row per artist, single global +operator. When the multi-user model lands, the PK widens to +`(user_id, artist_id)` — currently aspirational only (no User model, +no services/access.py); operator approved skipping `user_id` for now +under rule #22 (breaking changes welcome). + +Seed at migration time: every existing artist gets `last_viewed_at = NOW()` +so the badge starts at 0 across the board (no noisy "5000 unseen" on +first deploy). New artists also auto-get a row via +`ArtistService.find_or_create`. +""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, func +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class ArtistVisit(Base): + __tablename__ = "artist_visit" + + artist_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("artist.id", ondelete="CASCADE"), + primary_key=True, + ) + last_viewed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/backend/app/services/artist_directory_service.py b/backend/app/services/artist_directory_service.py index eda9afc..5e02774 100644 --- a/backend/app/services/artist_directory_service.py +++ b/backend/app/services/artist_directory_service.py @@ -13,10 +13,10 @@ from __future__ import annotations import base64 from dataclasses import dataclass -from sqlalchemy import and_, exists, func, or_, select +from sqlalchemy import and_, case, exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from ..models import Artist, ImageRecord, Source +from ..models import Artist, ArtistVisit, ImageRecord, Source from .gallery_service import thumbnail_url _SEP = "|" @@ -58,9 +58,27 @@ class ArtistDirectoryService: raise ValueError("limit must be between 1 and 200") count_col = func.count(ImageRecord.id).label("image_count") + # Unseen = images imported since the artist's last_viewed_at. + # NULL last_viewed_at (artist created before alembic 0034 seed + # or before find_or_create autoseed) defensively counts as + # "never visited" → all images unseen. Single grouped query, no + # N+1. + unseen_col = func.count( + case( + ( + or_( + ArtistVisit.last_viewed_at.is_(None), + ImageRecord.created_at > ArtistVisit.last_viewed_at, + ), + ImageRecord.id, + ), + else_=None, + ) + ).label("unseen_count") stmt = ( - select(Artist, count_col) + select(Artist, count_col, unseen_col) .outerjoin(ImageRecord, ImageRecord.artist_id == Artist.id) + .outerjoin(ArtistVisit, ArtistVisit.artist_id == Artist.id) .group_by(Artist.id) ) if q: @@ -94,7 +112,7 @@ class ArtistDirectoryService: next_cursor = _encode(last_artist.name, last_artist.id) rows = rows[:limit] - artist_ids = [a.id for a, _ in rows] + artist_ids = [a.id for a, _, _ in rows] previews = await self._previews(artist_ids) cards = [ @@ -104,9 +122,10 @@ class ArtistDirectoryService: "slug": artist.slug, "is_subscription": bool(artist.is_subscription), "image_count": int(image_count), + "unseen_count": int(unseen_count), "preview_thumbnails": previews.get(artist.id, []), } - for artist, image_count in rows + for artist, image_count, unseen_count in rows ] return DirectoryPage(cards=cards, next_cursor=next_cursor) diff --git a/backend/app/services/artist_service.py b/backend/app/services/artist_service.py index 1203f51..e72e3c0 100644 --- a/backend/app/services/artist_service.py +++ b/backend/app/services/artist_service.py @@ -9,11 +9,13 @@ Dates come from Post.post_date via ImageProvenance.post_id. from dataclasses import dataclass from sqlalchemy import and_, case, func, or_, select +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ..models import ( Artist, + ArtistVisit, ImageProvenance, ImageRecord, Post, @@ -122,6 +124,12 @@ class ArtistService: ) ).scalar_one() + # Mark this artist as "visited now"; the returned count is what + # the operator should see in the banner ("N new since last + # visit"). Done LAST so the read aggregates above all see the + # pre-visit state (cosmetic — none depend on visit data). + unseen_at_visit = await self._mark_visited_returning_unseen(aid) + return { "id": artist.id, "name": artist.name, @@ -129,6 +137,7 @@ class ArtistService: "is_subscription": bool(artist.is_subscription), "image_count": int(image_count), "post_count": int(post_count), + "unseen_count_at_visit": unseen_at_visit, "date_range": { "min": dmin.isoformat() if dmin else None, "max": dmax.isoformat() if dmax else None, @@ -157,6 +166,39 @@ class ArtistService: ], } + async def _mark_visited_returning_unseen(self, artist_id: int) -> int: + """Read pre-visit `last_viewed_at`, count images added since, + then upsert `last_viewed_at = NOW()`. Returns the count BEFORE + the upsert so the banner has data to render. + + Postgres UPSERT (`ON CONFLICT DO UPDATE`) keeps the write + atomic — no SELECT-then-INSERT race per + `reference_scalar_one_or_none_duplicates`. + """ + prev = ( + await self.session.execute( + select(ArtistVisit.last_viewed_at).where( + ArtistVisit.artist_id == artist_id + ) + ) + ).scalar_one_or_none() + + count_stmt = select(func.count(ImageRecord.id)).where( + ImageRecord.artist_id == artist_id + ) + if prev is not None: + count_stmt = count_stmt.where(ImageRecord.created_at > prev) + unseen = (await self.session.execute(count_stmt)).scalar_one() + + upsert = pg_insert(ArtistVisit.__table__).values(artist_id=artist_id) + upsert = upsert.on_conflict_do_update( + index_elements=["artist_id"], + set_={"last_viewed_at": func.now()}, + ) + await self.session.execute(upsert) + await self.session.commit() + return int(unseen) + async def images( self, slug: str, cursor: str | None, limit: int = 60 ) -> ArtistImagesPage | None: @@ -230,6 +272,13 @@ class ArtistService: artist = Artist(name=cleaned, slug=slug) self.session.add(artist) await self.session.flush() + # New artist starts "caught up" — seed ArtistVisit so the + # directory's `+N new` badge stays at 0 until real new + # content arrives. Without this, the unseen-count query + # treats NULL last_viewed_at as "never visited" and would + # count every image imported in the same session. + self.session.add(ArtistVisit(artist_id=artist.id)) + await self.session.flush() await sp.commit() except IntegrityError: await sp.rollback() diff --git a/frontend/src/components/discovery/ArtistCard.vue b/frontend/src/components/discovery/ArtistCard.vue index 6c4bace..dc2f5ae 100644 --- a/frontend/src/components/discovery/ArtistCard.vue +++ b/frontend/src/components/discovery/ArtistCard.vue @@ -8,6 +8,15 @@