feat(library): tighten duplicates scope, drop in-app upload, polish loaders

- duplicates: restrict the /library/duplicates/groups query to photos whose
  folder path actually lives under an active SourceRoot in the user's
  settings. Nextcloud's "move to trash" flow was leaving .delete/purge-1
  Folder rows wired to the original source_root_id, leaking those entries
  into the Duplicates view as ghost paths that the user never opted into.
- discard: add a spinner to the "Delete N" and "Empty discard pile"
  buttons (and their confirm dialogs) while the destructive mutation is
  in flight, so the user gets immediate feedback for a slow operation.
- timeline: render a bottom-of-grid "Loading more photos…" indicator
  while usePhotosQuery's background cursor loop is still pulling pages.
  Backed by a tiny Zustand store the loop drives via a balanced
  start/stop (counter, not boolean, so rapid filter changes can't flip
  the flag false while a fresh loop is alive).
- remove client-side upload UI + /upload endpoint. Nextcloud is the
  authoritative ingress now; the duplicate path created confusion and
  the backend route is gone too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
claudio
2026-05-13 20:44:36 +02:00
parent 99edd7d395
commit 5e2823ae94
11 changed files with 151 additions and 1059 deletions

View File

@@ -13,12 +13,12 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy import select, func, update, true as sa_true
from sqlalchemy import select, func, update, or_, true as sa_true
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Photo
from app.models.folders import SourceRoot
from app.models.folders import SourceRoot, Folder
from app.models.user import User
from app.dependencies import get_current_user
@@ -730,6 +730,33 @@ async def get_duplicate_groups(
* "similar" — members differ at the byte level but match perceptually
"""
owner = _owner_filter(current_user, scope)
# Restrict to photos that actually live under an active SourceRoot
# in the user's settings. The folder→source_root link alone is not
# enough: Nextcloud's "move to trash" flow can leave a Folder row at
# a path like `…/files/.delete/purge-1` still wired to the original
# source_root_id, which then leaks its photos into the duplicates
# view even though that path is outside everything the user
# configured. Filtering on the folder's path being a descendant of
# the active root's path matches what the user actually expects to
# see in Settings → Source folders.
sr_path_query = select(SourceRoot.path).where(SourceRoot.is_active.is_(True))
if not (scope == "global" and current_user.role == "admin"):
sr_path_query = sr_path_query.where(SourceRoot.user_id == current_user.id)
active_root_paths = (await db.execute(sr_path_query)).scalars().all()
if not active_root_paths:
return {"groups": [], "total_groups": 0, "total_members": 0}
# Match the source root path itself OR a strict child (path + '/').
# `startswith` alone would accept `/files/Photos2` for a `/files/Photos`
# root.
folder_in_scope = or_(
*[
(Folder.path == p) | (Folder.path.like(p.rstrip('/') + '/%'))
for p in active_root_paths
]
)
rows = (
await db.execute(
select(
@@ -746,10 +773,12 @@ async def get_duplicate_groups(
Photo.media_type,
Photo.duplicate_group_id,
)
.join(Folder, Folder.id == Photo.folder_id)
.where(owner)
.where(Photo.duplicate_group_id.is_not(None))
.where(Photo.is_discarded.is_(False))
.where(Photo.is_hidden.is_(False))
.where(folder_in_scope)
.order_by(Photo.duplicate_group_id)
)
).all()