diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 4e5c3f1..2ab8015 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -106,6 +106,14 @@ jobs: # This lock set has no torch or transformers; the frozen app # runs entirely on the pinned, Windows-tested ONNX Runtime. pip install -r requirements-desktop.txt + if [ "$RUNNER_OS" = "Linux" ]; then + # InsightFace requests the GUI OpenCV wheel, whose private Qt/X11 + # libraries confuse linuxdeploy. The sidecar only processes pixels; + # use the same OpenCV release without a second window toolkit. + pip uninstall -y opencv-python + pip install --no-deps opencv-python-headless==4.11.0.86 + python -c 'import cv2, numpy as np; from rapidocr_onnxruntime import RapidOCR; assert cv2.resize(np.zeros((16, 16, 3), dtype=np.uint8), (8, 8)).shape == (8, 8, 3); RapidOCR(); print(cv2.getBuildInformation())' + fi - name: Build the sidecar shell: bash @@ -140,12 +148,8 @@ jobs: shell: bash run: | mkdir -p desktop/src-tauri/binaries - rm -rf desktop/src-tauri/binaries/_internal - rm -f desktop/src-tauri/binaries/photolib-server-* - # Tauri resolves sidecars by target-triple suffix. - cp -r dist/photolib-server/* desktop/src-tauri/binaries/ - mv "desktop/src-tauri/binaries/photolib-server${{ matrix.exe }}" \ - "desktop/src-tauri/binaries/photolib-server-${{ matrix.triple }}${{ matrix.exe }}" + # Preserve the complete one-folder layout in the resource directory. + cp -a dist/photolib-server desktop/src-tauri/binaries/sidecar - uses: dtolnay/rust-toolchain@stable @@ -159,15 +163,41 @@ jobs: sudo apt-get update # Tauri v2 links against webkit2gtk-4.1 (javascriptcoregtk-4.1); the # old 4.0 packages no longer satisfy the crate and fail the build. + # linuxdeploy's helper AppImages also need the FUSE 2 runtime. sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev \ libayatana-appindicator3-dev librsvg2-dev patchelf \ - libxdo-dev libssl-dev build-essential + libxdo-dev libssl-dev build-essential libfuse2 - name: Build the installer working-directory: desktop + shell: bash run: | + set -o pipefail npm ci - npx tauri build --bundles "${{ matrix.bundles }}" + mkdir -p ../artifacts + if [ "$RUNNER_OS" = "Linux" ]; then + # PyInstaller normally supplies this search path at startup. + # linuxdeploy examines the shared objects directly instead. + mapfile -t sidecar_library_dirs < <(find "$PWD/src-tauri/binaries/sidecar/_internal" -type f -name '*.so*' -printf '%h\n' | sort -u) + sidecar_library_path=$(IFS=:; echo "${sidecar_library_dirs[*]}") + export LD_LIBRARY_PATH="$sidecar_library_path${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + while IFS= read -r -d '' library; do + dependencies=$(ldd "$library" 2>/dev/null || true) + if [[ "$dependencies" == *"not found"* ]]; then + printf 'Unresolved dependencies in %s:\n%s\n' "$library" "$dependencies" + exit 1 + fi + done < <(find "$PWD/src-tauri/binaries/sidecar/_internal" -type f -name '*.so*' -print0) + fi + npx tauri build --verbose --bundles "${{ matrix.bundles }}" 2>&1 | tee ../artifacts/desktop-build.log + + - name: Preserve installer diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-log-${{ matrix.os }} + path: artifacts/desktop-build.log + if-no-files-found: ignore - name: Guard the standard Windows installer size if: matrix.os == 'windows-latest' && env.MODEL_VARIANT == 'int8' @@ -182,6 +212,19 @@ jobs: } Write-Host "Hybrid INT8 setup: $([math]::Round($installer.Length / 1MB)) MB" + - name: Install the Windows package for verification + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + $installer = Get-ChildItem desktop/src-tauri/target/release/bundle/nsis/*-setup.exe | Select-Object -First 1 + $testInstall = Join-Path $PWD 'installer-smoke' + $process = Start-Process -FilePath $installer.FullName -ArgumentList '/S', "/D=$testInstall" -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -ne 0) { throw "Installer exited with $($process.ExitCode)" } + + - name: Verify the installed sidecar layout + shell: bash + run: python tools/verify_desktop_bundle.py + - uses: actions/upload-artifact@v4 with: name: photolib-${{ matrix.os }} @@ -203,6 +246,7 @@ jobs: - uses: actions/download-artifact@v4 with: path: artifacts + pattern: photolib-* - uses: softprops/action-gh-release@v2 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 36cfe26..33efa68 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,22 @@ on: pull_request: jobs: + ui: + runs-on: ubuntu-latest + defaults: + run: + working-directory: desktop + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: desktop/package-lock.json + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run test:ui + pytest: runs-on: ubuntu-latest strategy: diff --git a/PACKAGING.md b/PACKAGING.md index d6ac05c..b2d1ba8 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -56,13 +56,16 @@ python tools/export_onnx.py --model google/siglip2-base-patch16-224 \ # 2. Freeze the server (the tracked desktop/ui is bundled directly) pip install -r requirements-desktop.txt +# Linux only: use headless OpenCV to avoid unused Qt/X11 bundle dependencies. +if [ "$(uname -s)" = "Linux" ]; then + pip uninstall -y opencv-python + pip install --no-deps opencv-python-headless==4.11.0.86 +fi PHOTOLIB_MODEL_VARIANT=int8 pyinstaller packaging/photolib.spec --noconfirm --clean # 3. Stage the complete one-folder sidecar for Tauri mkdir -p desktop/src-tauri/binaries -cp -r dist/photolib-server/* desktop/src-tauri/binaries/ -mv desktop/src-tauri/binaries/photolib-server.exe \ - desktop/src-tauri/binaries/photolib-server-x86_64-pc-windows-msvc.exe +cp -a dist/photolib-server desktop/src-tauri/binaries/sidecar cd desktop && npm ci && npx tauri build --bundles nsis ``` @@ -80,11 +83,23 @@ photolib_2.0.3_x64-setup.exe /S Windows needs the MSVC build tools and WebView2 (present on Windows 10 21H2 and later). macOS needs Xcode command line tools. Linux needs -`libwebkit2gtk` and `libgtk-3` development packages. +`libwebkit2gtk` and `libgtk-3` development packages, and `libfuse2` for +AppImage tooling. On Linux, replace the GUI OpenCV wheel as shown above +before freezing the sidecar; the app uses its own Tauri window and only +needs OpenCV for image processing. + +For a Linux Tauri build, expose the frozen sidecar's shared-library directories +in `LD_LIBRARY_PATH` during bundling. PyInstaller sets that path when launching +the sidecar, but linuxdeploy inspects its libraries directly. The CI workflow +sets the path and checks for unresolved native dependencies before bundling. ## How it starts -1. The Tauri shell spawns `photolib-server --no-browser` as a sidecar. +1. The Tauri shell resolves `sidecar/photolib-server` inside its resource + directory and spawns it with `--no-browser`. The executable stays beside + its `_internal` support folder on every platform. CI verifies model parity, + UI/API startup, and shutdown from the packaged layout after bundling (from + an installed NSIS package on Windows and extracted DEB/AppImage on Linux). 2. The server picks a **free port** — hardcoding 8000 fails on any machine where something already holds it — waits until that port is accepting health requests, then prints `PHOTOLIB_READY {"url": ...}`. diff --git a/README.md b/README.md index 9058208..aa1ab26 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ and organize albums in a desktop app built for real family libraries of moved folder or changed drive letter. - **Curation** — favorites, 0–5 star ratings, saved searches, albums, and a checksummed backup containing exact confirmed face assignments. +- **Comfortable browsing** — compact controls, visible active filters, adjustable + thumbnail sizes, and paginated albums with continuous viewer navigation. ![Photolib people view](docs/images/photolib-people.png) @@ -49,7 +51,7 @@ Download the current **Windows x64 setup** from the Nothing else is required: no Python, Node.js, account, or API key. 1. Close Photolib if an older copy is running. -2. Run the downloaded `photolib_2.0.3_x64-setup.exe`. +2. Run the downloaded Windows x64 setup executable; its version is shown on the release. 3. Open **photolib** from the Start menu and choose a photo folder. The app leaves your library data alone during upgrades. The installer is not @@ -70,10 +72,12 @@ for the cross-platform build configuration and source packaging instructions. source .venv/bin/activate python -m photolib.cli index ~/Pictures # index (recursive, incremental) -python run.py # API on http://127.0.0.1:8000 +PHOTO_WEB_DIR=desktop/ui python run.py # desktop UI + API on localhost:8000 ``` -Then open for the API, or run the frontend. +Then open for the app, or + for the API. In PowerShell, set +`$env:PHOTO_WEB_DIR = "desktop/ui"` before running `python run.py`. Searching from the terminal works too: @@ -298,6 +302,19 @@ It covers indexing, incremental updates, clustering behaviour, search ranking, filtering, pagination, the HTTP layer, and scaling of the browse index at 200k photos. +The desktop UI also has browser smoke tests for filters, saved searches, +album pagination and viewer navigation, keyboard focus, and narrow screens: + +```bash +cd desktop +npm ci +npx playwright install chromium +npm run test:ui +``` + +These browser tests use a deterministic API fixture; the Python suite tests +the actual backend and database. Both run in CI. + ## Requirements - Python 3.10+ diff --git a/desktop/package-lock.json b/desktop/package-lock.json index a026f45..81e9f5b 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,14 +1,15 @@ { "name": "photolib-desktop", - "version": "2.0.2", + "version": "2.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "photolib-desktop", - "version": "2.0.2", + "version": "2.0.3", "devDependencies": { - "@tauri-apps/cli": "^2" + "@tauri-apps/cli": "^2", + "playwright": "1.62.1" } }, "node_modules/@tauri-apps/cli": { @@ -227,6 +228,53 @@ "engines": { "node": ">= 10" } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } } } } diff --git a/desktop/package.json b/desktop/package.json index 090a5d1..71ecff8 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,9 +5,11 @@ "description": "Native desktop shell for photolib", "scripts": { "dev": "tauri dev", - "build": "tauri build" + "build": "tauri build", + "test:ui": "node --test tests/ui.test.cjs" }, "devDependencies": { - "@tauri-apps/cli": "^2" + "@tauri-apps/cli": "^2", + "playwright": "1.62.1" } } diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index c4e61ed..601da1c 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -4,10 +4,6 @@ "description": "Permissions for the photolib desktop shell. Deliberately minimal: the app needs to start its own bundled server and nothing else.", "windows": ["main"], "permissions": [ - "core:default", - { - "identifier": "shell:allow-spawn", - "allow": [{ "name": "binaries/photolib-server", "sidecar": true }] - } + "core:default" ] } diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 46e4179..09bfd8f 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -60,10 +60,28 @@ fn main() { } fn start_server(handle: tauri::AppHandle) -> Result<(), String> { + // Keep the PyInstaller executable beside its _internal directory. Tauri + // resources live outside the shell's executable directory on macOS/Linux. + let binary = if cfg!(target_os = "windows") { + "photolib-server.exe" + } else { + "photolib-server" + }; + let server_path = handle + .path() + .resource_dir() + .map_err(|err| format!("could not locate server resources: {err}"))? + .join("sidecar") + .join(binary); + if !server_path.is_file() { + return Err(format!( + "photolib-server is missing: {}", + server_path.display() + )); + } let (mut rx, child) = handle .shell() - .sidecar("photolib-server") - .map_err(|err| format!("photolib-server sidecar is missing: {err}"))? + .command(server_path) .args(["--no-browser"]) .spawn() .map_err(|err| format!("failed to start the photolib server: {err}"))?; diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 9cab227..4632487 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -22,9 +22,8 @@ "icons/icon.ico", "icons/icon.png" ], - "externalBin": ["binaries/photolib-server"], "resources": { - "binaries/_internal/": "_internal/" + "binaries/sidecar/": "sidecar/" }, "shortDescription": "A local, private photo library", "longDescription": "Search your photos by describing them, and by face. Everything runs on your own machine.", diff --git a/desktop/tests/ui.test.cjs b/desktop/tests/ui.test.cjs new file mode 100644 index 0000000..6e24a94 --- /dev/null +++ b/desktop/tests/ui.test.cjs @@ -0,0 +1,182 @@ +// Real browser interactions against the shipped UI, with a deterministic API. +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { createServer } = require('node:http'); +const { readFileSync, mkdirSync } = require('node:fs'); +const { join } = require('node:path'); +const { chromium } = require('playwright'); + +let server, browser, origin; +const photos = Array.from({ length: 55 }, (_, image_id) => ({ + image_id, filename: `holiday-${String(image_id).padStart(3, '0')}.jpg`, + taken_at: '2024-07-04T12:00:00', face_count: 0, media_type: 'image', + favorite: false, rating: 0, +})); + +before(async () => { + server = createServer((req, res) => { + const path = new URL(req.url, 'http://localhost').pathname; + const files = { '/': 'index.html', '/app.js': 'app.js', '/curation.js': 'curation.js', '/app.css': 'app.css' }; + if (!files[path]) { res.writeHead(404).end(); return; } + res.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : path.endsWith('.css') ? 'text/css' : 'text/html'); + res.end(readFileSync(join(__dirname, '../ui', files[path]))); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + origin = `http://127.0.0.1:${server.address().port}`; + browser = await chromium.launch({ headless: true }); +}); + +after(async () => { + await browser?.close(); + if (server) await new Promise(resolve => server.close(resolve)); +}); + +async function openApp(t, viewport = { width: 1280, height: 900 }) { + const page = await browser.newPage({ viewport }); + page.setDefaultTimeout(10000); + t.after(() => page.close()); + const errors = [], searches = [], saved = []; + page.on('pageerror', error => errors.push(error.message)); + t.after(() => assert.deepEqual(errors, [])); + await page.route('**/api/v1/**', async route => { + const req = route.request(); + const url = new URL(req.url()); + const path = url.pathname.slice('/api/v1'.length); + const payload = req.method() === 'POST' ? req.postDataJSON() : null; + let body; + if (path === '/health') body = { ready: true }; + else if (path === '/admin/models') body = { ready: true, models: [] }; + else if (path === '/admin/jobs') body = []; + else if (path === '/people') body = []; + else if (path === '/stats') body = { total_images: 55, total_faces: 0, total_people: 0 }; + else if (path === '/cameras') body = { cameras: [] }; + else if (path === '/timeline') body = { months: [] }; + else if (path === '/saved-searches') { + if (payload) saved.push({ ...payload, id: String(saved.length + 1) }); + body = payload ? saved.at(-1) : { searches: saved }; + } else if (path === '/search') { + searches.push(payload); + const pageSize = payload.per_page, offset = (payload.page - 1) * pageSize; + body = { results: photos.slice(offset, offset + pageSize), total: photos.length, page: payload.page, per_page: pageSize }; + } else if (path === '/albums') body = { albums: [{ album_id: 1, name: 'Summer holiday', photo_count: 55, cover_image_id: 0 }] }; + else if (path === '/albums/1/suggestions') body = { suggestions: [] }; + else if (path === '/albums/1') { + const pageNumber = Number(url.searchParams.get('page') || 1); + const pageSize = Number(url.searchParams.get('per_page') || 48); + const offset = (pageNumber - 1) * pageSize; + body = { album_id: 1, name: 'Summer holiday', photo_count: 55, total: 55, page: pageNumber, per_page: pageSize, + has_more: offset + pageSize < 55, images: photos.slice(offset, offset + pageSize) }; + } else if (/^\/images\/\d+\/details$/.test(path)) { + const id = Number(path.split('/')[2]); + body = { ...photos[id], faces: [], people: [], width: 240, height: 180 }; + } else if (/^\/images\/\d+(\/thumbnail)?$/.test(path)) { + return route.fulfill({ contentType: 'image/svg+xml', body: '' }); + } else { + errors.push(`Unexpected API request: ${req.method()} ${path}`); + return route.fulfill({ status: 404, json: { detail: 'Unexpected test request' } }); + } + return route.fulfill({ json: body }); + }); + await page.goto(origin); + await page.locator('#photoGrid .photo').first().waitFor(); + return { page, searches, saved }; +} + +test('search controls fit one row; filters and saved searches preserve their meaning', async t => { + const { page, searches, saved } = await openApp(t); + const boxes = await Promise.all(['#searchInput', '#searchMode', '#searchForm button'].map(s => page.locator(s).boundingBox())); + assert.ok(boxes.every(b => Math.abs(b.y - boxes[0].y) < 4)); + await page.getByRole('button', { name: 'Filters', exact: true }).click(); + await page.getByLabel('Favorites only', { exact: true }).check(); + await page.waitForFunction(() => document.querySelector('#filtersButton').textContent === 'Filters (1)'); + assert.equal(searches.at(-1).favorites_only, true); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + assert.equal(await page.locator('#filterSummary').innerText(), 'Favorites only'); + await page.getByRole('button', { name: 'Save / manage…' }).click(); + await page.getByLabel('Saved search name').fill('My favorites'); + await page.getByRole('button', { name: 'Save search', exact: true }).click(); + await page.waitForFunction(() => !document.querySelector('#savedSearchDialog').open); + assert.equal(saved[0].request.favorites_only, true); + await page.locator('#gridSize').evaluate(el => { el.value = '260'; el.dispatchEvent(new Event('input')); }); + await page.reload(); + await page.locator('#photoGrid .photo').first().waitFor(); + assert.equal(await page.locator('#gridSize').inputValue(), '260'); +}); + +test('album grid and viewer navigate both ways across page boundaries', async t => { + const { page } = await openApp(t); + await page.getByRole('tab', { name: 'Albums', exact: true }).click(); + await page.locator('.album-card').click(); + await page.waitForFunction(() => document.querySelector('#albumPageLabel').textContent === 'PAGE 1 / 2'); + assert.equal(await page.locator('#albumPhotos .photo').count(), 48); + await page.locator('#albumPhotos .photo').last().click(); + await page.locator('#modalNext').click(); + await page.waitForFunction(() => document.querySelector('#modalName').textContent === 'holiday-048.jpg'); + assert.equal(await page.locator('#albumPhotos .photo').count(), 7); + await page.locator('#modalPrev').click(); + await page.waitForFunction(() => document.querySelector('#modalName').textContent === 'holiday-047.jpg'); + await page.keyboard.press('Escape'); + await page.locator('#albumNextPage').click(); + await page.waitForFunction(() => document.querySelector('#albumPageLabel').textContent === 'PAGE 2 / 2'); + await page.locator('#albumPhotos .photo').first().focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => document.querySelector('#modalName').textContent === 'holiday-048.jpg'); +}); + +test('modal focus is contained, nested shortcuts close first, and focus returns', async t => { + const { page } = await openApp(t); + const first = page.locator('#photoGrid .photo').first(); + await first.click(); + assert.equal(await page.locator('#modalClose').evaluate(el => el === document.activeElement), true); + assert.equal(await page.locator('.shell').evaluate(el => el.inert), true); + for (let i = 0; i < 25; i++) { + await page.keyboard.press('Tab'); + assert.equal(await page.evaluate(() => Boolean(document.activeElement.closest('#photoModal'))), true); + } + await page.locator('#modalClose').focus(); + await page.keyboard.press('?'); + assert.equal(await page.locator('#shortcutClose').evaluate(el => el === document.activeElement), true); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#photoModal').isVisible(), true); + await page.keyboard.press('Escape'); + assert.equal(await first.evaluate(el => el === document.activeElement), true); + assert.equal(await page.locator('.shell').evaluate(el => el.inert), false); +}); + +test('selection survives album paging without using a range anchor from another page', async t => { + const { page } = await openApp(t); + await page.getByRole('tab', { name: 'Albums', exact: true }).click(); + await page.locator('.album-card').click(); + await page.locator('#albumPhotos .photo').first().waitFor(); + await page.locator('#albumPhotos .photo').last().click({ modifiers: ['Control'] }); + await page.locator('#albumNextPage').click(); + await page.waitForFunction(() => document.querySelector('#albumPageLabel').textContent === 'PAGE 2 / 2'); + await page.locator('#albumPhotos .photo').first().click({ modifiers: ['Shift'] }); + assert.equal(await page.locator('#selectionCount').innerText(), '2 SELECTED · IN ALBUM'); + assert.equal(await page.locator('#albumPhotos .photo.selected').count(), 1); + assert.equal(await page.locator('#photoModal').isVisible(), false); +}); + +test('narrow screens have no horizontal overflow', async t => { + const { page } = await openApp(t, { width: 390, height: 844 }); + const checkOverflow = async () => { + const dimensions = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth, + oversized: [...document.querySelectorAll('body *')].filter(el => el.getClientRects().length && el.getBoundingClientRect().right > innerWidth) + .slice(0, 12).map(el => el.id || el.className || el.tagName) })); + assert.ok(dimensions.scrollWidth <= dimensions.width, JSON.stringify(dimensions)); + }; + await checkOverflow(); + await page.getByRole('button', { name: 'Filters', exact: true }).click(); + await checkOverflow(); + if (process.env.PHOTOLIB_SCREENSHOTS) { + mkdirSync(process.env.PHOTOLIB_SCREENSHOTS, { recursive: true }); + await page.screenshot({ path: join(process.env.PHOTOLIB_SCREENSHOTS, 'filters-mobile.png') }); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.screenshot({ path: join(process.env.PHOTOLIB_SCREENSHOTS, 'library-desktop.png') }); + } + if (await page.locator('#filtersDialog').evaluate(el => el.open)) await page.getByRole('button', { name: 'Done', exact: true }).click(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator('#photoGrid .photo').first().click(); + await checkOverflow(); +}); diff --git a/desktop/ui/app.css b/desktop/ui/app.css index 4d46cb6..64c8dc1 100644 --- a/desktop/ui/app.css +++ b/desktop/ui/app.css @@ -103,7 +103,7 @@ button:disabled { cursor: not-allowed; opacity: .45; } .tabs-meta { margin-left: auto; color: var(--faint); } /* -- search + filters ---------------------------------------------------------- */ -.searchbar { display: grid; grid-template-columns: minmax(220px, 1fr) auto; gap: 10px; margin: 18px 0 10px; } +.searchbar { display: grid; grid-template-columns: minmax(220px, 1fr) auto auto; gap: 10px; margin: 18px 0 10px; } .search-wrap { position: relative; } .search-wrap .field { padding-left: 40px; min-height: 44px; } .search-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--faint); pointer-events: none; font-size: 16px; } @@ -112,6 +112,16 @@ button:disabled { cursor: not-allowed; opacity: .45; } .toggle { display: inline-flex; align-items: center; gap: 7px; min-height: 38px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius); background: #121316; color: var(--muted); cursor: pointer; } .toggle:has(input:checked) { border-color: var(--accent); color: var(--text); } .chip-btn.active-filter { border-color: var(--accent); color: var(--accent); } +.filter-summary { color: var(--muted); font-size: 12.5px; margin: 6px 0 12px; overflow-wrap: anywhere; } +.grid-size { display: flex; align-items: center; gap: 8px; margin-left: auto; color: var(--muted); font-size: 12px; white-space: nowrap; } +.grid-size input { width: 90px; accent-color: var(--accent); } +.settings-dialog { width: min(560px, calc(100vw - 32px)); max-height: 90vh; overflow: auto; color: var(--text); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 24px; } +.settings-dialog::backdrop { background: rgba(6,7,9,.75); } +.settings-dialog h2 { margin: 0; font-size: 18px; } +.settings-dialog p { color: var(--muted); } +.settings-dialog .folder-row { grid-template-columns: 1fr auto; margin: 20px 0; } +.filter-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.filter-fields > label:not(.toggle) { display: flex; flex-direction: column; gap: 6px; color: var(--muted); font-size: 13px; } .chips { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin: 2px 0 10px; } .chips-label { color: var(--faint); margin-right: 3px; } @@ -141,7 +151,7 @@ button:disabled { cursor: not-allowed; opacity: .45; } .tl-year { position: absolute; left: 0; bottom: -14px; color: var(--faint); font-size: 10px; } /* -- photo grid ---------------------------------------------------------------------- */ -.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(188px, 1fr)); gap: 10px; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(var(--grid-size, 180px), 100%), 1fr)); gap: 10px; } .photo { position: relative; overflow: hidden; padding: 0; border: 1px solid var(--line-soft); border-radius: var(--radius); background: var(--surface); aspect-ratio: 1 / 1; text-align: left; } .photo img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .25s ease, opacity .3s ease; } .photo:hover img { transform: scale(1.03); } @@ -149,6 +159,8 @@ button:disabled { cursor: not-allowed; opacity: .45; } .photo:hover::after { opacity: 1; } .photo-meta { position: absolute; z-index: 1; left: 10px; right: 10px; bottom: 8px; display: flex; justify-content: space-between; gap: 8px; color: #fff; opacity: 0; transition: opacity .2s ease; text-shadow: 0 1px 4px #000; } .photo:hover .photo-meta { opacity: 1; } +.photo:focus-within .photo-meta, .photo:focus-within .frame-no, .photo:focus-within .select-check { opacity: 1; } +.photo:focus-within::after { opacity: 1; } .frame-no { position: absolute; z-index: 1; top: 7px; left: 9px; color: rgba(255,255,255,.85); text-shadow: 0 1px 3px #000; opacity: 0; transition: opacity .2s ease; } .photo:hover .frame-no { opacity: 1; } .score { position: absolute; z-index: 1; top: 7px; right: 7px; padding: 2px 7px; border-radius: 999px; background: rgba(11,23,48,.78); color: var(--accent); } @@ -390,11 +402,20 @@ button:disabled { cursor: not-allowed; opacity: .45; } .hero { padding-top: 36px; } .folder-row, .searchbar { grid-template-columns: 1fr; } .notice, .panel-head, .root-row { align-items: stretch; flex-direction: column; } - .grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } + .filter-fields { grid-template-columns: 1fr; } + .grid-size { margin-left: 0; } .person-head { flex-direction: column; align-items: center; text-align: center; } .person-name-row { flex-direction: column; } + .modal { padding: 12px; } + .modal-info { flex-direction: column; align-items: stretch; } + .modal-buttons { justify-content: flex-start; flex-shrink: 1; } .picker-panel { width: min(320px, calc(100vw - 40px)); } } +@media (max-width: 480px) { + .brand-tag { display: none; } + .tabs { flex-wrap: wrap; } + .tabs-meta { flex-basis: 100%; margin-left: 0; padding: 4px 16px 8px; } +} :root { --curation-gold: #efd17e; } .curation-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 16px 24px; } .quality-explanation { font-size: 12px; line-height: 1.6; opacity: .8; flex: 1 1 280px; } diff --git a/desktop/ui/app.js b/desktop/ui/app.js index 52bac43..9e544a4 100644 --- a/desktop/ui/app.js +++ b/desktop/ui/app.js @@ -26,6 +26,8 @@ const state = { mergeArmedId: null, albums: [], currentAlbum: null, + albumPage: 1, + albumSeq: 0, albumDeleteArmed: false, loadCount: 0, selection: new Set(), // image_ids picked for a batch action @@ -46,6 +48,91 @@ const RECENT_LIMIT = 8; const $ = (id) => document.getElementById(id); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +// Custom overlays share focus management; native dialogs manage their own. +const overlayStack = []; +const overlayFocus = new Map(); +const overlayIds = ["personModal", "photoModal", "shortcutModal"]; +const focusable = (root) => [...root.querySelectorAll( + 'button, input, select, textarea, a[href], [tabindex="0"]' +)].filter(el => !el.disabled && el.getClientRects().length && !el.closest("[inert]")); + +function syncOverlayFocus() { + const top = overlayStack.at(-1); + document.querySelectorAll(".topbar, .shell, #selectionBar").forEach(el => { el.inert = Boolean(top); }); + overlayIds.forEach(id => { + $(id).inert = Boolean(top && top !== id); + $(id).style.zIndex = String(100 + Math.max(0, overlayStack.indexOf(id)) * 10); + }); +} + +function showOverlay(id, firstFocus) { + if (!$(id).classList.contains("hidden")) return; + overlayFocus.set(id, document.activeElement); + overlayStack.push(id); + $(id).classList.remove("hidden"); + syncOverlayFocus(); + (firstFocus ? $(firstFocus) : focusable($(id))[0])?.focus(); +} + +function hideOverlay(id) { + $(id).classList.add("hidden"); + const index = overlayStack.indexOf(id); + if (index < 0) return; + const wasTop = index === overlayStack.length - 1; + overlayStack.splice(index, 1); + syncOverlayFocus(); + const previous = overlayFocus.get(id); + overlayFocus.delete(id); + if (!wasTop) return; + if (previous?.isConnected && previous.getClientRects().length && !previous.closest("[inert]")) previous.focus(); + else focusable(overlayStack.length ? $(overlayStack.at(-1)) : $("librarySection"))[0]?.focus(); +} + +function initBrowseControls() { + $("filtersButton").addEventListener("click", () => $("filtersDialog").showModal()); + $("filtersClose").addEventListener("click", () => $("filtersDialog").close()); + $("saveSearchButton").addEventListener("click", () => { + $("deleteSavedSearch").disabled = !$("savedSearchSelect").value; + $("savedSearchDialog").showModal(); + $("savedSearchName").focus(); + }); + $("savedSearchClose").addEventListener("click", () => $("savedSearchDialog").close()); + const storedSize = Number(localStorage.getItem("photolib.gridSize")); + if (storedSize >= 140 && storedSize <= 300) $("gridSize").value = String(storedSize); + const resize = () => { + document.documentElement.style.setProperty("--grid-size", `${$("gridSize").value}px`); + localStorage.setItem("photolib.gridSize", $("gridSize").value); + }; + $("gridSize").addEventListener("input", resize); + resize(); + document.addEventListener("keydown", event => { + if (event.key !== "Tab" || !overlayStack.length || document.querySelector("dialog[open]")) return; + const elements = focusable($(overlayStack.at(-1))); + if (!elements.length) { event.preventDefault(); return; } + const at = elements.indexOf(document.activeElement); + if (event.shiftKey && at <= 0) { event.preventDefault(); elements.at(-1).focus(); } + else if (!event.shiftKey && (at < 0 || at === elements.length - 1)) { event.preventDefault(); elements[0].focus(); } + }); +} + +function renderFilterSummary() { + const labels = []; + if ($("dateFrom").value) labels.push(`From ${$("dateFrom").value}`); + if ($("dateTo").value) labels.push(`Through ${$("dateTo").value}`); + if ($("cameraFilter").value) labels.push($("cameraFilter").value); + if ($("mediaFilter").value) labels.push($("mediaFilter").value === "video" ? "Videos only" : "Photos only"); + if ($("locationToggle").checked) labels.push("Has location"); + if ($("favoritesOnly").checked) labels.push("Favorites only"); + if (Number($("minRating").value)) labels.push(`${$("minRating").value}+ stars`); + if (state.savedFilterExtras.folder) labels.push(`Folder: ${state.savedFilterExtras.folder}`); + if (state.savedFilterExtras.has_faces != null) labels.push(state.savedFilterExtras.has_faces ? "Has faces" : "No faces"); + if (state.untaggedOnly) labels.push("Unidentified faces"); + if (state.near) labels.push(`Within ${state.near.km} km of selected location`); + $("filtersButton").textContent = labels.length ? `Filters (${labels.length})` : "Filters"; + $("filterSummary").textContent = labels.join(" · "); + $("filterSummary").classList.toggle("hidden", !labels.length); +} + // --------------------------------------------------------------------------- // Fetch + loading strip // --------------------------------------------------------------------------- @@ -568,6 +655,7 @@ function clearFilters() { async function search(page = 1, { keepPanel = false } = {}) { clearError(); + renderFilterSummary(); if (!keepPanel) togglePeoplePanel(false); // A newer search supersedes this one; if it finishes first, this response // must not overwrite the grid with stale results. @@ -672,6 +760,7 @@ function browsePerson(personId) { function renderPhotos(result) { const grid = $("photoGrid"); + state.selectionAnchor = -1; state.results = result.results; if (!result.results.length) { grid.innerHTML = '
No photos matched. Broaden the description, widen the dates, or remove a person filter.
'; @@ -727,7 +816,7 @@ function handleSelectClick(event, tile, scope, idAt) { reflectSelection(); return true; } - if (event.ctrlKey || event.metaKey || event.target.closest(".select-check")) { + if (event.ctrlKey || event.metaKey || event.shiftKey || event.target.closest(".select-check")) { if (state.selectionScope !== scope && state.selection.size) { state.selection.clear(); // picks from two different grids never mix } @@ -920,7 +1009,8 @@ function selectAllVisible() { function toggleShortcuts(show) { const modal = $("shortcutModal"); const wanted = show ?? modal.classList.contains("hidden"); - modal.classList.toggle("hidden", !wanted); + if (wanted) showOverlay("shortcutModal", "shortcutClose"); + else hideOverlay("shortcutModal"); } // --------------------------------------------------------------------------- @@ -1045,7 +1135,7 @@ async function openPhoto(imageId, filename = "") { $("modalAlbumBtn").textContent = "Add to album"; $("modalCopyBtn").textContent = "Copy image"; $("modalRevealBtn").textContent = "Show in Explorer"; - $("photoModal").classList.remove("hidden"); + showOverlay("photoModal", "modalClose"); resetZoom(); // The grid row usually knows the media type; details confirm it below. const known = (state.modalIndex >= 0 ? ctx.list[state.modalIndex] : null) @@ -1341,7 +1431,7 @@ function initZoom() { } function closePhoto() { - $("photoModal").classList.add("hidden"); + hideOverlay("photoModal"); $("modalImage").removeAttribute("src"); delete $("modalImage").dataset.imageId; stopModalVideo(); @@ -1353,11 +1443,13 @@ function updateModalNav() { const ctx = modalContext(); const i = state.modalIndex; const inResults = i >= 0 && ctx.list.length > 0; - const pages = Math.max(1, Math.ceil(state.total / state.perPage)); - const canPage = !state.similarTo && !ctx.album; - const hasPrev = inResults && (i > 0 || (canPage && state.page > 1)); + const total = ctx.album ? state.currentAlbum.total : state.total; + const page = ctx.album ? state.albumPage : state.page; + const pages = Math.max(1, Math.ceil(total / state.perPage)); + const canPage = ctx.album || !state.similarTo; + const hasPrev = inResults && (i > 0 || (canPage && page > 1)); const hasNext = inResults && (i < ctx.list.length - 1 - || (canPage && state.page < pages)); + || (canPage && page < pages)); $("modalPrev").classList.toggle("hidden", !hasPrev); $("modalNext").classList.toggle("hidden", !hasNext); } @@ -1372,9 +1464,19 @@ async function navigatePhoto(delta) { openPhoto(photo.image_id, photo.filename || ""); return; } - // Walked off the page — fetch the neighbouring one and keep going. Albums - // and similar/pasted results are single, self-contained lists. - if (ctx.album || state.similarTo) return; + if (ctx.album) { + const nextPage = state.albumPage + delta; + const pages = Math.ceil(state.currentAlbum.total / state.perPage); + if (nextPage < 1 || nextPage > pages) return; + const albumId = state.currentAlbum.album_id; + const loaded = await openAlbum(albumId, nextPage); + if (loaded && !$("photoModal").classList.contains("hidden")) { + const photo = delta > 0 ? loaded.images[0] : loaded.images.at(-1); + if (photo) openPhoto(photo.image_id, photo.filename || ""); + } + return; + } + if (state.similarTo) return; const pages = Math.max(1, Math.ceil(state.total / state.perPage)); if (delta > 0 && state.page < pages) { await search(state.page + 1); @@ -1641,7 +1743,7 @@ async function openPerson(personId) { state.forgetArmed = false; state.mergeArmedId = null; - $("personModal").classList.remove("hidden"); + showOverlay("personModal", "personName"); $("personName").value = person.name || ""; $("personCover").innerHTML = coverHtml(person, "xl"); $("personMeta").textContent = @@ -1660,7 +1762,7 @@ async function openPerson(personId) { } function closePerson() { - $("personModal").classList.add("hidden"); + hideOverlay("personModal"); state.currentPerson = null; } @@ -2105,6 +2207,7 @@ async function loadAlbums() { } async function loadAlbumsView() { + state.albumSeq += 1; $("albumDetail").classList.add("hidden"); $("albumsHome").classList.remove("hidden"); startLoad(); @@ -2156,7 +2259,11 @@ async function createAlbum(name, imageIds = []) { return album; } -async function openAlbum(albumId) { +async function openAlbum(albumId, page = null) { + albumId = Number(albumId); + if (state.currentAlbum?.album_id !== albumId && state.selectionScope === "album") clearSelection(); + const seq = ++state.albumSeq; + const targetPage = page ?? (state.currentAlbum?.album_id === albumId ? state.albumPage : 1); state.albumDeleteArmed = false; $("albumDelete").textContent = "Delete album"; $("albumsHome").classList.add("hidden"); @@ -2165,14 +2272,22 @@ async function openAlbum(albumId) { $("albumSuggestions").innerHTML = ""; startLoad(); try { - const detail = await request(`/albums/${albumId}?limit=500`); + let detail = await request(`/albums/${albumId}?page=${targetPage}&per_page=${state.perPage}`); + if (!detail.images.length && detail.total && targetPage > 1) { + const lastPage = Math.ceil(detail.total / state.perPage); + detail = await request(`/albums/${albumId}?page=${lastPage}&per_page=${state.perPage}`); + } + if (seq !== state.albumSeq) return; state.currentAlbum = detail; + state.albumPage = detail.page; $("albumTitle").value = detail.name; $("albumMeta").textContent = `${detail.photo_count} ${detail.photo_count === 1 ? "PHOTO" : "PHOTOS"}`; renderAlbumPhotos(detail); - loadAlbumSuggestions(albumId); + if (page == null) loadAlbumSuggestions(albumId); + return detail; } catch (error) { + if (seq !== state.albumSeq) return; showError(`Could not open the album: ${error.message}`); loadAlbumsView(); } finally { @@ -2182,13 +2297,19 @@ async function openAlbum(albumId) { function renderAlbumPhotos(detail) { const grid = $("albumPhotos"); + state.selectionAnchor = -1; $("albumPhotosTitle").textContent = `Photos (${detail.images.length})`; + const pages = Math.max(1, Math.ceil(detail.total / detail.per_page)); + $("albumPageLabel").textContent = `PAGE ${detail.page} / ${pages}`; + $("albumPrevPage").disabled = detail.page <= 1; + $("albumNextPage").disabled = !detail.has_more; + $("albumPager").classList.toggle("hidden", pages <= 1); if (!detail.images.length) { grid.innerHTML = '
Empty so far. Add photos from the suggestions above, or from any photo\'s "Add to album" button.
'; return; } grid.innerHTML = detail.images.map((photo, i) => ` -
+
${escapeHtml(photo.filename || ✓ ${videoBadge(photo)} @@ -2196,6 +2317,11 @@ function renderAlbumPhotos(detail) {
`).join(""); grid.querySelectorAll(".album-photo").forEach((tile) => { + tile.addEventListener("keydown", event => { + if (event.target !== tile || !["Enter", " "].includes(event.key)) return; + event.preventDefault(); + tile.click(); + }); tile.addEventListener("click", (event) => { if (event.target.closest(".album-remove")) return; if (handleSelectClick(event, tile, "album", @@ -2563,6 +2689,7 @@ async function cancelJob() { async function init() { initCuration(); + initBrowseControls(); $("folderPath").value = localStorage.getItem("photolib.lastFolder") || ""; $("browseButton").addEventListener("click", () => chooseFolder(true)); $("indexButton").addEventListener("click", startIndexFromSetup); @@ -2593,6 +2720,8 @@ async function init() { } }); $("albumBack").addEventListener("click", loadAlbumsView); + $("albumPrevPage").addEventListener("click", () => openAlbum(state.currentAlbum.album_id, state.albumPage - 1)); + $("albumNextPage").addEventListener("click", () => openAlbum(state.currentAlbum.album_id, state.albumPage + 1)); $("albumTitle").addEventListener("blur", saveAlbumTitle); $("albumTitle").addEventListener("keydown", (event) => { if (event.key === "Enter") saveAlbumTitle(); @@ -2737,14 +2866,15 @@ async function init() { }); document.addEventListener("keydown", (event) => { + if (document.querySelector("dialog[open]")) return; const photoOpen = !$("photoModal").classList.contains("hidden"); const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName); if (event.key === "Escape") { // Close only the topmost layer, so backing out of a photo opened // from a person still leaves the person open. - if (!$("shortcutModal").classList.contains("hidden")) toggleShortcuts(false); - else if (photoOpen) closePhoto(); - else if (!$("personModal").classList.contains("hidden")) closePerson(); + if (overlayStack.at(-1) === "shortcutModal") toggleShortcuts(false); + else if (overlayStack.at(-1) === "photoModal") closePhoto(); + else if (overlayStack.at(-1) === "personModal") closePerson(); else if (!$("selAlbumPanel").classList.contains("hidden")) { toggleSelectionAlbumPanel(false); } else if (state.selection.size) clearSelection(); @@ -2758,6 +2888,7 @@ async function init() { toggleShortcuts(); return; } + if (overlayStack.length && overlayStack.at(-1) !== "photoModal") return; if (event.key === "/" && !photoOpen) { event.preventDefault(); setView("photos"); diff --git a/desktop/ui/curation.js b/desktop/ui/curation.js index 17ecc1b..d8a8d4b 100644 --- a/desktop/ui/curation.js +++ b/desktop/ui/curation.js @@ -12,6 +12,7 @@ function initCuration() { query:$("searchInput").value || null, sort:$("sortSelect").value}})}); $("savedSearchName").value = ""; await loadSavedSearches(); + $("savedSearchDialog").close(); } catch (error) { showError(error.message); } }); $("savedSearchSelect").addEventListener("change", applySavedSearch); @@ -38,6 +39,7 @@ async function loadSavedSearches() { } function applySavedSearch() { + $("deleteSavedSearch").disabled = !$("savedSearchSelect").value; const saved = savedSearchItems.find(s => s.id === $("savedSearchSelect").value); if (!saved) return; const r = saved.request; diff --git a/desktop/ui/index.html b/desktop/ui/index.html index 745a31b..0473a57 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -97,17 +97,7 @@

Every photo, findable.
Nothing leaves this computer.

- - - - - + - - - -
- - - -
+ + + + @@ -210,6 +192,11 @@

Photos

hover a photo for remove · click to open
+ @@ -360,6 +347,30 @@

Photos

+ +

Filter your photos

+
+ + + + + + + +
+

Changes update your results as you choose.

+
+ + +

Save this search

+

Your description, people, filters and sort order will be saved together.

+
+ + +
+ +
+

Compare nearby shots

diff --git a/docs/scale-and-search-review.md b/docs/scale-and-search-review.md index 725df07..aedcc6f 100644 --- a/docs/scale-and-search-review.md +++ b/docs/scale-and-search-review.md @@ -43,16 +43,28 @@ exist. Million-photo and intermittently connected drive workflows need more work saved searches, and roots. Restore provides a verification preview and reports unmatched or conflicting items. Original files, the complete generated index, caches, and model weights remain outside this curation backup. +- The September 30 usability pass removes album membership truncation and + adds API/UI pagination plus cross-page viewer navigation. Album pages hydrate + only visible image rows; ordering still reads the selected album's complete + ID/date metadata. Album lookup no longer scans every album's membership. +- Near-duplicate matching now probes neighboring hash bands, so differences + spread across all four bands are still found. Equal hashes are collapsed + before matching, large buckets are retained, and broader radii use an exact + Hamming BK-tree. Default-radius scale tests remain bounded; broad-radius or + adversarial hash collections can still be expensive. +- Browse controls now put secondary filters and saved-search editing in + dialogs, show active filter summaries, and remember the chosen thumbnail size. + Browser smoke tests exercise these controls, pagination, and modal focus. **Highest-priority remaining scale work** | Priority | Finding in current code | Recommended change | |---|---|---| -| Next | Discovery keeps all paths, sorts them, and loads a full-library Python metadata dictionary. `_existing_files` also turns any read exception into an empty catalog. | Stream discovery into a durable, source-scoped manifest; fail clearly on catalog read errors. Persist scan checkpoints and last-seen generation; expose progress/cancellation during discovery. | +| Next | Discovery keeps all paths, sorts them, and loads a full-library Python metadata dictionary. Catalog read errors now propagate rather than being treated as an empty catalog. | Stream discovery into a durable, source-scoped manifest. Persist scan checkpoints and last-seen generation; expose progress/cancellation during discovery. | | Next | Every image-table version change can rebuild the complete browse snapshot, including OCR; OCR searches loop over text in Python. | Measure invalidation under active ingestion; coalesce snapshot refreshes and add an indexed text-search path. | | Next | Broad filtered ANN search can still over-fetch up to the library size, and its heuristic does not guarantee filtered recall. Vector and scalar indexes are recreated after each non-empty indexing run. | Plan filtered queries using scalar predicates; evaluate recall versus exact ranking, maintain indexes incrementally, and schedule training/compaction based on measured thresholds. | | Next | Full reclustering computes all-pairs similarities in blocks. Memory is partially bounded but time is still quadratic; the default guard permits 400k faces. | Construct a nearest-neighbor graph with ANN, retain confirmed identities, and support resumable clustering. Do not interpret the 400k guard as a demonstrated capacity. | -| Next | Several service methods cap album and face reads at fixed limits (100k/500k), and the album detail view takes at most 500 images. | Add proper pagination/streaming and make truncation visible instead of silently omitting records. | +| Next | Several face service methods still cap reads at 100k/500k. Album truncation has been removed, but page ordering reads all membership metadata for the selected album. | Stream face maintenance reads; measure very large album ordering and consider cached ordering or cursor pagination. | **Multiple-drive design** diff --git a/openapi.yaml b/openapi.yaml index d7261b4..a6ff697 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1066,7 +1066,7 @@ paths: tags: - albums summary: Album Detail - description: The album plus its photos, newest addition first. + description: A page of album photos, newest addition first; limit remains compatible. operationId: album_detail_api_v1_albums__album_id__get parameters: - name: album_id @@ -1084,6 +1084,24 @@ paths: minimum: 1 default: 500 title: Limit + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: per_page + in: query + required: false + schema: + anyOf: + - type: integer + maximum: 200 + minimum: 1 + - type: 'null' + title: Per Page responses: '200': description: Successful Response diff --git a/photolib/api/routers/albums.py b/photolib/api/routers/albums.py index cddf2c8..efbd8da 100644 --- a/photolib/api/routers/albums.py +++ b/photolib/api/routers/albums.py @@ -33,10 +33,12 @@ def create_album(req: AlbumCreateRequest, @router.get("/{album_id}") def album_detail(album_id: int, limit: int = Query(500, ge=1, le=2000), + page: int = Query(1, ge=1), + per_page: int | None = Query(None, ge=1, le=200), service: PhotoService = Depends(get_service)): - """The album plus its photos, newest addition first.""" + """A page of album photos, newest addition first; limit remains compatible.""" try: - return service.album_detail(album_id, limit) + return service.album_detail(album_id, limit, page, per_page) except Exception as exc: raise translate_errors(exc) diff --git a/photolib/hashing.py b/photolib/hashing.py index 3f63378..b01bd25 100644 --- a/photolib/hashing.py +++ b/photolib/hashing.py @@ -81,30 +81,25 @@ def phash_file(path: os.PathLike | str) -> int: def hamming(a: int, b: int) -> int: - return bin(_unsigned(a) ^ _unsigned(b)).count("1") + return (_unsigned(a) ^ _unsigned(b)).bit_count() def group_near_duplicates(items: Iterable[Tuple[int, int]], max_distance: int = 6) -> List[List[int]]: """Group ``(image_id, phash)`` pairs into near-duplicate sets. - Uses multi-index bucketing (split the 64-bit hash into 4 x 16-bit bands): - two hashes within `max_distance <= 6` bits must agree exactly on at least - one band by the pigeonhole principle, so only same-band pairs need to be - compared. That turns an O(n^2) sweep over 200k photos into a couple of - dictionary passes. + For distances up to seven, probe four 16-bit bands within distance + ``max_distance // 4``. At least one band must satisfy that bound, so + candidates are complete even when every band differs. Larger radii use + an exact Hamming BK-tree. Equal hashes are collapsed before matching; + large buckets never silently lose matches. """ items = list(items) + if not 0 <= max_distance <= 64: + raise ValueError("max_distance must be between 0 and 64") if not items: return [] - bands: List[dict] = [dict() for _ in range(4)] - for image_id, h in items: - uh = _unsigned(h) - for b in range(4): - key = (uh >> (16 * b)) & 0xFFFF - bands[b].setdefault(key, []).append(image_id) - parent = {image_id: image_id for image_id, _ in items} def find(x: int) -> int: @@ -118,17 +113,56 @@ def union(a: int, b: int) -> None: if ra != rb: parent[rb] = ra - hashes = dict(items) - for band in bands: - for bucket in band.values(): - if len(bucket) < 2 or len(bucket) > 512: - # Huge buckets are degenerate (e.g. thousands of black - # frames); comparing them all pairwise is not worth it. + representatives: dict[int, int] = {} + for image_id, value in items: + value = _unsigned(value) + if value in representatives: + union(image_id, representatives[value]) + else: + representatives[value] = image_id + + if max_distance == 64: + first = items[0][0] + for image_id, _ in items: + union(first, image_id) + elif max_distance <= 7: + bands: List[dict] = [dict() for _ in range(4)] + masks = [0] + ([1 << bit for bit in range(16)] if max_distance >= 4 else []) + for value, image_id in representatives.items(): + candidates: set[int] = set() + for band_index, band in enumerate(bands): + key = (value >> (16 * band_index)) & 0xFFFF + for mask in masks: + candidates.update(band.get(key ^ mask, ())) + for other in candidates: + if (value ^ other).bit_count() <= max_distance: + union(image_id, representatives[other]) + for band_index, band in enumerate(bands): + key = (value >> (16 * band_index)) & 0xFFFF + band.setdefault(key, []).append(value) + elif representatives: + # A node is (hash, children keyed by distance from that hash). + root = None + for value, image_id in representatives.items(): + if root is None: + root = (value, {}) continue - for i, a in enumerate(bucket): - for b in bucket[i + 1:]: - if hamming(hashes[a], hashes[b]) <= max_distance: - union(a, b) + pending = [root] + while pending: + other, children = pending.pop() + distance = (value ^ other).bit_count() + if distance <= max_distance: + union(image_id, representatives[other]) + pending.extend(child for edge, child in children.items() + if distance - max_distance <= edge <= distance + max_distance) + node = root + while True: + distance = (value ^ node[0]).bit_count() + child = node[1].get(distance) + if child is None: + node[1][distance] = (value, {}) + break + node = child groups: dict = {} for image_id, _ in items: diff --git a/photolib/service.py b/photolib/service.py index 95cdea0..fd43c62 100644 --- a/photolib/service.py +++ b/photolib/service.py @@ -1076,7 +1076,7 @@ def duplicates(self, max_distance: int = 6, limit: int = 200) -> List[dict]: columns=["image_id", "phash", "content_hash", "file_size"]) pairs = [(int(i), int(h)) for i, h in zip(table["image_id"].to_pylist(), table["phash"].to_pylist()) - if h] + if h is not None] sizes = {int(i): int(s or 0) for i, s in zip(table["image_id"].to_pylist(), table["file_size"].to_pylist())} @@ -1581,11 +1581,23 @@ def list_albums(self) -> List[dict]: return out def get_album(self, album_id: int) -> dict: - album = next((a for a in self.list_albums() - if a["album_id"] == int(album_id)), None) - if album is None: + album_id = int(album_id) + if not self.library.has_albums(): + raise NotFound(f"Album {album_id} not found") + rows = (self.library.albums.search().where(f"album_id = {album_id}") + .limit(1).to_arrow().to_pylist()) + if not rows: raise NotFound(f"Album {album_id} not found") - return album + album = rows[0] + where = f"album_id = {album_id}" + count = self.library.album_items.count_rows(where) + cover = album["cover_image_id"] + if cover is None or int(cover) < 0: + first = (self.library.album_items.search().where(where) + .select(["image_id"]).limit(1).to_arrow().to_pylist()) + cover = first[0]["image_id"] if first else -1 + return {"album_id": album_id, "name": album["name"] or f"Album {album_id}", + "photo_count": count, "cover_image_id": int(cover)} @catalog.serialized def create_album(self, name: str) -> dict: @@ -1619,22 +1631,29 @@ def delete_album(self, album_id: int) -> dict: def album_image_ids(self, album_id: int) -> List[int]: """Album members, newest addition first.""" - rows = ( - self.library.album_items.search() - .where(f"album_id = {int(album_id)}") - .select(["image_id", "added_at"]) - .limit(100_000) - .to_arrow() - .to_pylist() - ) - rows.sort(key=lambda r: (r["added_at"] is not None, r["added_at"]), - reverse=True) - return [int(r["image_id"]) for r in rows] - - def album_detail(self, album_id: int, limit: int = 500) -> dict: + rows = self._album_members(album_id) + return [int(i) for i in rows["image_id"].to_pylist()] + + def _album_members(self, album_id: int): + # Only IDs and addition dates for this album, never image vectors. + # A second sort key keeps pages stable for batch additions. + return self.library.album_items.to_lance().to_table( + columns=["image_id", "added_at"], filter=f"album_id = {int(album_id)}" + ).sort_by([("added_at", "descending"), ("image_id", "descending")]) + + def album_detail(self, album_id: int, limit: int = 500, page: int = 1, + per_page: Optional[int] = None) -> dict: album = self.get_album(album_id) - ids = self.album_image_ids(album_id)[:limit] - return {**album, "images": self.hydrate(ids)} + page_size = per_page if per_page is not None else limit + if page < 1 or page_size < 1: + raise ValueError("page and page size must be positive") + members = self._album_members(album_id) + total = members.num_rows + offset = (page - 1) * page_size + ids = members.slice(offset, page_size)["image_id"].to_pylist() + return {**album, "photo_count": total, "total": total, "page": page, + "per_page": page_size, "has_more": offset + page_size < total, + "images": self.hydrate(ids)} @catalog.serialized def add_album_items(self, album_id: int, image_ids: Sequence[int]) -> dict: diff --git a/tests/test_albums.py b/tests/test_albums.py index 457df40..a4bd3ff 100644 --- a/tests/test_albums.py +++ b/tests/test_albums.py @@ -64,3 +64,43 @@ def test_unknown_album_is_404(client): assert client.get(f"{API}/albums/9999").status_code == 404 assert client.post(f"{API}/albums/9999/items", json={"image_ids": [1]}).status_code == 404 + + +def test_album_pages_reach_every_photo_beyond_500(client, indexed_service, monkeypatch): + import pyarrow as pa + + service = indexed_service + template = service.library.images.search().limit(1).to_arrow().to_pylist()[0] + new_rows = [{**template, "image_id": i, "path": f"sample-{i}.jpg"} + for i in range(1000, 1505)] + service.library.images.add(pa.Table.from_pylist(new_rows, schema=service.library.images.schema)) + service.index.invalidate() + album_id = service.create_album("Large album")["album_id"] + service.add_album_items(album_id, list(range(1000, 1505))) + hydrated_sizes = [] + hydrate = service.hydrate + + def track(ids): + hydrated_sizes.append(len(ids)) + return hydrate(ids) + + monkeypatch.setattr(service, "hydrate", track) + collected = [] + for page in range(1, 7): + response = client.get(f"{API}/albums/{album_id}", + params={"page": page, "per_page": 100}) + assert response.status_code == 200 + detail = response.json() + assert detail["total"] == detail["photo_count"] == 505 + assert detail["page"] == page + assert detail["has_more"] is (page < 6) + collected.extend(r["image_id"] for r in detail["images"]) + assert collected == list(range(1504, 999, -1)) + assert hydrated_sizes == [100, 100, 100, 100, 100, 5] + assert len(client.get(f"{API}/albums/{album_id}?limit=3").json()["images"]) == 3 + assert client.get(f"{API}/albums/{album_id}?page=99&per_page=100").json()["images"] == [] + + +def test_album_pagination_validates_bounds(client): + for params in ({"page": 0}, {"per_page": 0}, {"per_page": 201}): + assert client.get(f"{API}/albums/0", params=params).status_code == 422 diff --git a/tests/test_api.py b/tests/test_api.py index 2fbe20a..5971d82 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,6 +10,14 @@ API = "/api/v1" +def test_duplicate_search_retains_zero_perceptual_hashes(client, indexed_service): + ids = indexed_service.index.image_ids[:2].tolist() + indexed_service.library.images.update( + where=f"image_id IN ({', '.join(str(i) for i in ids)})", values={"phash": 0}) + groups = client.get(f"{API}/admin/duplicates").json()["groups"] + assert any(g["kind"] == "similar" and set(ids) <= set(g["image_ids"]) for g in groups) + + def test_cached_thumbnail_available_when_drive_is_offline(client, indexed_service): from pathlib import Path diff --git a/tests/test_packaging_config.py b/tests/test_packaging_config.py index baa8fe8..778b9d9 100644 --- a/tests/test_packaging_config.py +++ b/tests/test_packaging_config.py @@ -12,8 +12,8 @@ def test_tauri_bundles_pyinstaller_support_directory(): (ROOT / "desktop" / "src-tauri" / "tauri.conf.json").read_text(encoding="utf-8") ) bundle = config["bundle"] - assert bundle["externalBin"] == ["binaries/photolib-server"] - assert bundle["resources"]["binaries/_internal/"] == "_internal/" + assert not bundle.get("externalBin") + assert bundle["resources"]["binaries/sidecar/"] == "sidecar/" assert bundle["targets"] == ["nsis", "dmg", "appimage", "deb"] @@ -23,7 +23,7 @@ def test_windows_release_uses_a_clean_single_executable_installer(): ).read_text(encoding="utf-8") assert "bundles: nsis" in workflow - assert "rm -rf desktop/src-tauri/binaries/_internal" in workflow + assert "cp -a dist/photolib-server desktop/src-tauri/binaries/sidecar" in workflow assert "bundle/nsis/*-setup.exe" in workflow assert "bundle/**/*.msi" not in workflow diff --git a/tests/test_utils.py b/tests/test_utils.py index 48f4003..6a56bbc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -68,6 +68,37 @@ def test_group_near_duplicates_on_empty_input(): assert group_near_duplicates([]) == [] +def test_near_duplicates_can_differ_in_every_band(): + base = 0x0123456789ABCDEF + changed = base ^ sum(1 << bit for bit in (0, 16, 32, 48)) + assert group_near_duplicates([(1, base), (2, changed)], 6) == [[1, 2]] + + +def test_large_equal_hash_bucket_is_not_discarded(): + items = [(i, -1) for i in range(600)] + [(600, -2)] + assert group_near_duplicates(items, 1) == [list(range(601))] + + +@pytest.mark.parametrize("distance", [0, 3, 4, 6, 7, 8, 12, 20, 64]) +def test_duplicate_groups_match_exhaustive_reference(distance): + rng = np.random.default_rng(42) + hashes = [int(v) for v in rng.integers(-(2 ** 63), 2 ** 63 - 1, size=80)] + base = hashes[0] + hashes.extend([base ^ sum(1 << bit for bit in range(n)) for n in range(1, 22)]) + # Compute connected components independently from every eligible pair. + groups = [{i} for i in range(len(hashes))] + for a in range(len(hashes)): + for b in range(a): + if hamming(hashes[a], hashes[b]) <= distance: + left = next(g for g in groups if a in g) + right = next(g for g in groups if b in g) + if left is not right: + left.update(right) + groups.remove(right) + expected = sorted(sorted(g) for g in groups if len(g) > 1) + assert sorted(group_near_duplicates(enumerate(hashes), distance)) == expected + + # --------------------------------------------------------------------------- # EXIF # --------------------------------------------------------------------------- diff --git a/tools/verify_desktop_bundle.py b/tools/verify_desktop_bundle.py new file mode 100644 index 0000000..99b3a49 --- /dev/null +++ b/tools/verify_desktop_bundle.py @@ -0,0 +1,104 @@ +"""Verify the frozen backend from the layout shipped in each installer. + +Runs after Tauri bundling in CI. Uses only the standard library so the host +Python environment cannot accidentally satisfy a missing frozen dependency. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +from urllib.request import urlopen + + +ROOT = Path(__file__).resolve().parent.parent +BUNDLES = ROOT / "desktop/src-tauri/target/release/bundle" + + +def verify(sidecar: Path, work: Path) -> None: + binary = sidecar / ("photolib-server.exe" if sys.platform == "win32" else "photolib-server") + if not binary.is_file() or not (sidecar / "_internal").is_dir(): + raise RuntimeError(f"Incomplete installed sidecar: {sidecar}") + # Do not let the build-time library search path mask a packaging error. + env = os.environ.copy() + env.pop("LD_LIBRARY_PATH", None) + env.pop("LD_LIBRARY_PATH_ORIG", None) + subprocess.run([str(binary), "--verify-model", "--data-dir", str(work / "verify")], + env=env, check=True, timeout=180) + log_path = work / "server.log" + with log_path.open("w", encoding="utf-8") as log: + process = subprocess.Popen( + [str(binary), "--no-browser", "--data-dir", str(work / "data")], + stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT, env=env, + ) + try: + deadline = time.monotonic() + 90 + url = None + while time.monotonic() < deadline: + text = log_path.read_text(encoding="utf-8", errors="replace") + for line in text.splitlines(): + if line.startswith("PHOTOLIB_READY "): + url = json.loads(line.removeprefix("PHOTOLIB_READY "))["url"] + break + if url: + break + if process.poll() is not None: + raise RuntimeError(f"Installed sidecar exited early:\n{text}") + time.sleep(0.25) + if not url: + raise RuntimeError(f"Installed sidecar never became ready:\n{text}") + for path in ("/", "/api/v1/health", "/api/v1/admin/models"): + with urlopen(url.rstrip("/") + path, timeout=20) as response: + if response.status != 200: + raise RuntimeError(f"Installed sidecar returned {response.status} for {path}") + if path == "/" and b"folderPath" not in response.read(): + raise RuntimeError("Installed UI is missing the source-folder controls") + process.stdin.write(b"PHOTOLIB_SHUTDOWN\n") + process.stdin.flush() + if process.wait(timeout=20) != 0: + raise RuntimeError("Installed sidecar did not shut down cleanly") + print(f"Verified installed model, UI, API and shutdown: {sidecar}") + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=10) + process.stdin.close() + + +def main() -> None: + with tempfile.TemporaryDirectory(prefix="photolib-bundle-") as temporary: + work = Path(temporary) + if sys.platform == "win32": + verify(ROOT / "installer-smoke/sidecar", work) + elif sys.platform == "darwin": + mount = work / "dmg" + mount.mkdir() + dmg = next((BUNDLES / "dmg").glob("*.dmg")) + subprocess.run(["hdiutil", "attach", str(dmg), "-nobrowse", "-readonly", + "-mountpoint", str(mount)], check=True) + try: + app = next(mount.glob("*.app")) + verify(app / "Contents/Resources/sidecar", work) + finally: + subprocess.run(["hdiutil", "detach", str(mount)], check=True) + else: + deb = next((BUNDLES / "deb").glob("*.deb")) + extracted = work / "deb" + subprocess.run(["dpkg-deb", "--extract", str(deb), str(extracted)], check=True) + deb_work = work / "deb-test" + deb_work.mkdir() + verify(extracted / "usr/lib/photolib/sidecar", deb_work) + appimage = next((BUNDLES / "appimage").glob("*.AppImage")) + image_work = work / "appimage-test" + image_work.mkdir() + subprocess.run([str(appimage), "--appimage-extract"], cwd=image_work, + check=True, stdout=subprocess.DEVNULL) + verify(image_work / "squashfs-root/usr/lib/photolib/sidecar", image_work) + + +if __name__ == "__main__": + main()