[
  {
    "path": ".coveragerc",
    "content": "[run]\nbranch = True\n\n[report]\nskip_covered = True\nskip_empty = True\n# show_missing = True\n\n[html]\nskip_covered = True\nskip_empty = True\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: derneuere\ncustom: https://www.paypal.com/donate/?hosted_button_id=5JWVM2UR4LM96\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n# 🛑 Before you create an issue make sure that:\n- Your issue is **strictly related to LibrePhotos** itself. Questions about setting up a reverse proxy belong in what ever reverse proxy you are using. \n- You have read the [documentation](https://docs.librephotos.com) thoroughly.\n- You have searched for a similar issue among all the former issues (even closed ones).\n- You have tried to replicate the issue with a clean install of the project.\n- You have asked for help on our Discord server [LibrePhotos](https://discord.gg/xwRvtSDGWb) if your issue involves general \"how to\" questions\n\n**When Submitting please remove every thing above this line**\n\n\n# 🐛 Bug Report\n\n* [ ] 📁 I've Included a ZIP file containing my librephotos `log` files\n* [ ] ❌ I have looked for similar issues (including closed ones)\n* [ ] 🎬 (If applicable) I've provided pictures or links to videos that clearly demonstrate the issue \n\n## 📝 Description of issue:\n\n\n## 🔁 How can we reproduce it:\n\n\n## Please provide additional information:\n- 💻 Operating system: \n- ⚙ Architecture (x86 or ARM): \n- 🔢 Librephotos version: \n- 📸 Librephotos installation method (Docker, Kubernetes, .deb, etc.): \n    * 🐋 If Docker or Kubernets, provide docker-compose image tag:\n- 📁 How is you picture library mounted (Local file system (Type), NFS, SMB, etc.): \n- ☁ If you are virtualizing librephotos, Virtualization platform (Proxmox, Xen, HyperV, etc.): \n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/enhancement-request.md",
    "content": "---\nname: Enhancement request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n**Describe the enhancement you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe why this will benefit the LibrePhotos**\nA clear and concise explanation on why this will make LibrePhotos better.\n\n**Additional context**\nAdd any other context or screenshots about the enhancement request here.\n"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "on:\n  push:\n    # Publish `dev` as Docker `latest` image.\n    branches:\n      - dev\n\njobs:\n  # Run tests.\n  # See also https://docs.docker.com/docker-hub/builds/automated-testing/\n  test:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4\n      - name: Run tests\n        run: echo \"to-do\"\n\n  # Push image to GitHub Packages.\n  # See also https://docs.docker.com/docker-hub/builds/\n  push:\n    # Ensure test job passes before pushing image.\n    needs: test\n\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push'\n\n    steps:\n      - name: Repository Dispatch\n        uses: peter-evans/repository-dispatch@v3\n        with:\n          token: ${{ secrets.REPO_ACCESS_TOKEN }}\n          repository: librephotos/librephotos-docker\n          event-type: backend-commit-event\n"
  },
  {
    "path": ".github/workflows/pre-commit.yml",
    "content": "name: Linting (using pre-commit)\n\non: [push, pull_request]\n\njobs:\n  pre-commit:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Install dependencies\n        run: pip install ruff\n      - name: Run pre-commit check\n        uses: pre-commit/action@v3.0.1\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Docker\nDockerfile\nentrypoint.sh\n\n# Static assets\nstatic/\n\n# Nuitka\nmanage.build/\nmanage.dist/\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\ncoco/\nsurvey/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# visual studio\n.vs/\n.vscode/\n\n# IntelliJ\n.idea/\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# LibrePhotos\ndensecap/data/models/densecap/densecap-pretrained-vgg16.t7\n*/*.pkl\n*/*/*.pkl\nthumbnails\nmedia\nsamplephotos\nConv2d.patch\nLinear.patch\nSequential.patch\nBatchNorm2d.patch\nAvgPool2d.patch\nReLU.patch\nrun_docker.sh\nlogs/\nplayground\napi/im2txt/data/\napi/im2txt/models/\napi/im2txt/png/\n*.ipynb\napi/im2txt/*.tar.gz\n*.db\nmedia*\nprotected_media\nreport_dynamo_export.sarif\n\n# Vim\n*.swp\n*.swo\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "default_language_version:\n  python: python3\n\nrepos:\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.9.4 # Check latest version\n    hooks:\n      - id: ruff\n        args: [\"--fix\"] # Fix lint & format\n\n      - id: ruff-format # Format code\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# LibrePhotos Backend Agent Guidelines\n\n## Build & Development Commands\n\n**Note:** All commands should be run inside the backend Docker container (`docker exec -it backend bash`).\n\n### Django Management\n- **Run Migrations**: `python manage.py migrate`\n- **Make Migrations**: `python manage.py makemigrations`\n- **Create Superuser**: `python manage.py createsuperuser`\n- **Collect Static**: `python manage.py collectstatic`\n- **Shell**: `python manage.py shell`\n- **Custom Commands**: `python manage.py <command_name>` (see `api/management/commands/`)\n\n### Running Services\n- **API Server (Gunicorn)**: Runs automatically in container\n- **Background Jobs (django-q2)**: Runs automatically via `qcluster` command\n- **Image Similarity Service**: Flask app for semantic search\n- **Thumbnail Service**: Separate process for image processing\n\n### Linting & Formatting\n- **Lint**: `ruff check .`\n- **Format**: `ruff format .`\n- **Lint + Fix**: `ruff check --fix .`\n\n### Testing\n- **Run All Tests**: `python manage.py test api.tests`\n- **Run Specific Test**: `python manage.py test api.tests.test_module`\n- **Run with Verbosity**: `python manage.py test api.tests -v 2`\n\n### Debugging\n- **PDB Breakpoint**: Add `import pdb; pdb.set_trace()` in code\n- **Attach to Container**: `docker attach $(docker ps --filter name=backend -q)`\n- **Silk Profiler**: Access `/api/silk` (dev mode only)\n- **Detach**: `Ctrl+P` then `Ctrl+Q`\n\n## Code Style & Conventions\n\n- **Formatting**: Ruff with 88 char line width (configured in `pyproject.toml`)\n- **Imports**: Sorted by isort (via Ruff)\n- **Target Python**: 3.11+\n- **Framework**: Django 5.x with Django REST Framework\n- **Async Jobs**: django-q2 with ORM broker\n- **ML Framework**: PyTorch for machine learning models\n\n## Project Structure\n\n### `api/` - Main Application\n- `models/` - Django ORM models (Photo, Face, Person, Album, etc.)\n- `views/` - API endpoints using Django REST Framework\n- `serializers/` - JSON serialization for models\n- `management/commands/` - CLI commands (`python manage.py <cmd>`)\n- `migrations/` - Database migrations\n- `tests/` - Test suite\n- `geocode/` - Reverse geocoding functionality\n- `feature/` - Feature extraction utilities\n\n### `service/` - Microservices\n- `clip_embeddings/` - CLIP model for semantic search\n- `face_recognition/` - Face detection and recognition\n- `image_captioning/` - Image captioning (im2txt, BLIP)\n- `thumbnail/` - Thumbnail generation\n- `llm/` - LLM integration for chat features\n- `tags/` - Tag extraction (places365)\n- `exif/` - EXIF metadata extraction\n\n### `image_similarity/` - Similarity Search\n- FAISS-based image retrieval index\n- Flask REST API for similarity queries\n\n### Key Files\n- `manage.py` - Django management script\n- `requirements.txt` - Python dependencies\n- `pyproject.toml` - Ruff/project configuration\n- `librephotos/settings/` - Django settings (base, dev, prod)\n- `librephotos/urls.py` - URL routing\n\n## Environment Variables\n\nKey environment variables (set in Docker or `.env`):\n- `DEBUG` - Enable debug mode (0 or 1)\n- `SECRET_KEY` - Django secret key\n- `DB_*` - Database connection settings\n- `MAPBOX_API_KEY` - For map features\n- `WEB_CONCURRENCY` - Gunicorn worker count\n\n## Common Patterns\n\n### Adding a New API Endpoint\n1. Create/update model in `api/models/`\n2. Create serializer in `api/serializers/`\n3. Create view in `api/views/`\n4. Add URL in `librephotos/urls.py`\n5. Run migrations if model changed\n\n### Adding a New Background Job\n1. Define task function in `api/all_tasks.py` or relevant module\n2. Use `@shared_task` decorator for django-q2\n3. Queue with `async_task()` or schedule in admin\n\n### Adding a New ML Model\n1. Add model loading in `api/ml_models.py`\n2. Create service wrapper in `service/<model_name>/`\n3. Integrate with API views as needed\n\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to LibrePhotos\n\nThank you for your interest in contributing to LibrePhotos! This guide will help you get started with the development process.\n\n## Table of Contents\n\n- [Development Environment Setup](#development-environment-setup)\n- [Docker & Docker Compose](#docker--docker-compose)\n- [IDE Recommendations](#ide-recommendations)\n- [Code Quality Standards](#code-quality-standards)\n- [How to Open a Pull Request](#how-to-open-a-pull-request)\n- [Getting Help](#getting-help)\n\n---\n\n## Development Environment Setup\n\n### Prerequisites\n\n- **Git** - for version control\n- **Docker** and **Docker Compose** - for running the development environment\n- **Node.js 18+** and **Yarn** - for frontend development (optional, if developing outside Docker)\n- **Python 3.11+** - for backend development (optional, if developing outside Docker)\n\n### Step 1: Clone the Repositories\n\nCreate a directory for the project and clone all required repositories:\n\n**Linux/macOS:**\n```bash\nexport codedir=~/dev/librephotos\nmkdir -p $codedir\ncd $codedir\n\ngit clone https://github.com/LibrePhotos/librephotos-frontend.git\ngit clone https://github.com/LibrePhotos/librephotos.git\ngit clone https://github.com/LibrePhotos/librephotos-docker.git\n```\n\n**Windows (PowerShell):**\n```powershell\n$Env:codedir = \"$HOME\\dev\\librephotos\"\nNew-Item -ItemType Directory -Force -Path $Env:codedir\nSet-Location $Env:codedir\n\ngit clone https://github.com/LibrePhotos/librephotos-frontend.git\ngit clone https://github.com/LibrePhotos/librephotos.git\ngit clone https://github.com/LibrePhotos/librephotos-docker.git\n```\n\n### Step 2: Configure Environment\n\nNavigate to the `librephotos-docker` directory and create your `.env` file:\n\n```bash\ncd librephotos-docker\ncp librephotos.env .env\n```\n\nEdit the `.env` file and set these critical variables:\n\n```bash\n# Path to your photo library (for testing)\nscanDirectory=/path/to/your/test/photos\n\n# Path to LibrePhotos data\ndata=./librephotos/data\n\n# IMPORTANT: Path where you cloned the repositories\ncodedir=~/dev/librephotos\n```\n\n### Step 3: Start the Development Environment\n\n```bash\ndocker compose -f docker-compose.yml -f docker-compose.dev.yml up -d\n```\n\nThis command:\n- Builds development images with hot-reload enabled\n- Mounts your local source code into the containers\n- Starts all required services (backend, frontend, database, proxy)\n\nAccess LibrePhotos at: **http://localhost:3000**\n\n### Rebuilding After Dependency Changes\n\nIf you add new dependencies to `requirements.txt` or `package.json`:\n\n```bash\n# Rebuild backend\ndocker compose -f docker-compose.yml -f docker-compose.dev.yml build --no-cache backend\n\n# Rebuild frontend\ndocker compose -f docker-compose.yml -f docker-compose.dev.yml build --no-cache frontend\n\n# Restart containers\ndocker compose -f docker-compose.yml -f docker-compose.dev.yml up -d\n```\n\n---\n\n## Docker & Docker Compose\n\n### Architecture Overview\n\nLibrePhotos uses a microservices architecture with four main containers:\n\n| Container   | Purpose                                              |\n|-------------|------------------------------------------------------|\n| `backend`   | Django API server, ML models, background jobs        |\n| `frontend`  | React web application                                |\n| `proxy`     | Nginx reverse proxy, serves static files             |\n| `db`        | PostgreSQL database                                  |\n\n### Useful Docker Commands\n\n```bash\n# View running containers\ndocker compose ps\n\n# View logs (all containers)\ndocker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f\n\n# View logs (specific container)\ndocker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f backend\n\n# Restart a container\ndocker compose -f docker-compose.yml -f docker-compose.dev.yml restart backend\n\n# Stop all containers\ndocker compose -f docker-compose.yml -f docker-compose.dev.yml down\n\n# Stop and remove volumes (fresh start)\ndocker compose -f docker-compose.yml -f docker-compose.dev.yml down -v\n\n# Execute command in container\ndocker exec -it backend bash\ndocker exec -it frontend sh\n\n# Run Django management commands\ndocker exec -it backend python manage.py migrate\ndocker exec -it backend python manage.py createsuperuser\n```\n\n### Development vs Production\n\n| Aspect            | Development (`docker-compose.dev.yml`)              | Production (`docker-compose.yml`)    |\n|-------------------|-----------------------------------------------------|--------------------------------------|\n| Source code       | Mounted from local filesystem                       | Built into image                     |\n| Hot reload        | ✅ Enabled                                           | ❌ Disabled                           |\n| Debug mode        | ✅ `DEBUG=1`                                         | ❌ `DEBUG=0`                          |\n| Build time        | Longer (builds from source)                         | Fast (pulls pre-built images)        |\n| Additional tools  | pgAdmin available on port 3001                      | Minimal                              |\n\n---\n\n## IDE Recommendations\n\n### VS Code (Recommended)\n\nVS Code is the recommended IDE with excellent Docker and Python support.\n\n**Recommended Extensions:**\n- **Python** - Python language support\n- **Pylance** - Fast Python language server\n- **Docker** - Docker container management\n- **Remote - Containers** - Develop inside Docker containers\n- **ESLint** - JavaScript/TypeScript linting\n- **Prettier** - Code formatting\n\n**Workspace Settings:**\n\nThe repository includes VS Code settings in `librephotos-docker/vscode/settings.json` that are automatically mounted into the backend container.\n\n**Attaching to Backend Container:**\n\nFor the best development experience, you can attach VS Code directly to the running backend container:\n\n1. Install the \"Remote - Containers\" extension\n2. Open Command Palette (`Ctrl+Shift+P`)\n3. Run \"Remote-Containers: Attach to Running Container\"\n4. Select the `backend` container\n5. Open the `/code` folder\n\n### PyCharm\n\nPyCharm Professional supports Docker interpreters natively:\n\n1. Go to Settings → Project → Python Interpreter\n2. Add Interpreter → On Docker Compose\n3. Select the `docker-compose.yml` and `docker-compose.dev.yml` files\n4. Choose the `backend` service\n\n### Other IDEs\n\nAny IDE with Python and TypeScript support will work. Key requirements:\n- Python 3.11+ interpreter support\n- ESLint/Prettier integration for frontend\n- Docker integration (optional but helpful)\n\n---\n\n## Code Quality Standards\n\n### Backend (Python/Django)\n\n**Linting and Formatting:**\n\nWe use `ruff` for linting and formatting (configured in `pyproject.toml`):\n\n```bash\n# Inside the backend container\ncd /code\npip install ruff\nruff check .\nruff format .\n```\n\n**Pre-commit Hooks:**\n\nInstall pre-commit hooks for automatic formatting:\n\n```bash\npip install pre-commit\npre-commit install\n```\n\n**Code Style:**\n- Line length: 88 characters\n- Use type hints where practical\n- Follow PEP 8 naming conventions\n- Write docstrings for public functions\n\n### Frontend (React/TypeScript)\n\n**Linting and Formatting:**\n\n```bash\n# Inside frontend container or locally\nyarn lint:error        # Check for errors\nyarn lint:warning:fix  # Fix linting issues\n```\n\n**Code Style:**\n- Line length: 120 characters\n- Use Prettier for formatting (configured in `prettier.config.cjs`)\n- Prefer TypeScript types over interfaces (project convention)\n- Use functional components with hooks\n- Follow the slice pattern for Redux state management\n\n### Pull Request Checklist\n\nBefore submitting a PR, ensure:\n\n- [ ] Code follows the project's style guidelines\n- [ ] All linting passes without errors\n- [ ] New features include tests (if applicable)\n- [ ] Documentation is updated (if needed)\n- [ ] Commit messages are clear and descriptive\n- [ ] The PR addresses a single concern/feature\n\n---\n\n## How to Open a Pull Request\n\n### Step 1: Fork the Repository\n\n1. Navigate to the repository you want to contribute to on GitHub\n2. Click the \"Fork\" button in the top right corner\n3. Clone your fork locally:\n\n```bash\ngit clone https://github.com/YOUR-USERNAME/librephotos.git\ncd librephotos\ngit remote add upstream https://github.com/LibrePhotos/librephotos.git\n```\n\n### Step 2: Create a Feature Branch\n\nAlways create a new branch for your work:\n\n```bash\ngit checkout -b feature/my-awesome-feature\n# or\ngit checkout -b fix/bug-description\n```\n\n### Step 3: Make Your Changes\n\n1. Write your code following the code quality standards above\n2. Test your changes thoroughly\n3. Commit your changes with descriptive messages:\n\n```bash\ngit add .\ngit commit -m \"feat: add support for XYZ\"\n# or\ngit commit -m \"fix: resolve issue with ABC\"\n```\n\n**Commit Message Guidelines:**\n- Use present tense (\"add feature\" not \"added feature\")\n- Keep the first line under 72 characters\n- Reference issues when applicable: `fix: resolve login bug (#123)`\n\n### Step 4: Push and Create Pull Request\n\n```bash\ngit push origin feature/my-awesome-feature\n```\n\nThen on GitHub:\n1. Navigate to your fork\n2. Click \"Compare & pull request\"\n3. Fill out the PR template with:\n   - Clear description of changes\n   - Reference to related issues\n   - Screenshots (for UI changes)\n   - Testing instructions\n\n### Step 5: Respond to Review\n\n- Address reviewer feedback promptly\n- Make requested changes in new commits\n- Be open to suggestions and discussion\n\n---\n\n## Getting Help\n\n- **Discord:** [Join our Discord server](https://discord.gg/xwRvtSDGWb)\n- **GitHub Issues:** [Report bugs or request features](https://github.com/LibrePhotos/librephotos/issues)\n- **Documentation:** [docs.librephotos.com](https://docs.librephotos.com)\n- **Development Videos:** [Niaz Faridani-Rad's YouTube channel](https://www.youtube.com/channel/UCZJ2pk2BPKxwbuCV9LWDR0w)\n\n### Debugging Tips\n\n**Backend (Django):**\n\nUse `pdb` for debugging:\n\n```python\nimport pdb; pdb.set_trace()\n```\n\nThen attach to the container:\n\n```bash\ndocker attach $(docker ps --filter name=backend -q)\n```\n\nPress `Ctrl+P` followed by `Ctrl+Q` to detach without stopping the container.\n\n**Frontend (React):**\n\n- Use React DevTools browser extension\n- Use Redux DevTools for state debugging\n- Enable WDYR by setting `WDYR=True` in your `.env`\n\n**API Documentation:**\n\nAfter starting LibrePhotos, access the API docs at:\n- Swagger: http://localhost:3000/api/swagger\n- ReDoc: http://localhost:3000/api/redoc\n\n---\n\n## License\n\nBy contributing to LibrePhotos, you agree that your contributions will be licensed under the MIT License.\n\nThank you for contributing! 🎉\n\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2017 Hooram Nam\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "[![Discord](https://img.shields.io/discord/784619049208250388?style=plastic)][discord] [![Website](https://img.shields.io/website?down_color=lightgrey&down_message=offline&style=plastic&up_color=blue&up_message=online&url=https%3A%2F%2Flibrephotos.com)](https://librephotos.com/)\n[![Read the docs](https://img.shields.io/static/v1?label=Read&message=the%20docs&color=blue&style=plastic)](https://docs.librephotos.com/) [![GitHub contributors](https://img.shields.io/github/contributors/librephotos/librephotos?style=plastic)](https://github.com/LibrePhotos/librephotos/graphs/contributors) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=plastic)](https://github.com/LibrePhotos/librephotos/blob/dev/LICENSE)\n<a href=\"https://hosted.weblate.org/engage/librephotos/\">\n<img src=\"https://hosted.weblate.org/widgets/librephotos/-/librephotos-frontend/svg-badge.svg\" alt=\"Translation status\" />\n</a>\n\n# LibrePhotos\n\n![](https://github.com/LibrePhotos/librephotos/blob/dev/screenshots/mockups_main_fhd.png?raw=true)\n<sub>Mockup designed by rawpixel.com / Freepik</sub>\n\nA self-hosted, open-source photo management service with automatic face recognition, object detection, and semantic search — powered by modern machine learning.\n\n- **Stable** demo is available here: https://demo1.librephotos.com/ . User is ```demo```, password is ```demo1234``` (with sample images).\n- Latest **development** demo is available here: https://demo2.librephotos.com/ (same user/password)\n- You can watch development videos on [Niaz Faridani-Rad's channel](https://www.youtube.com/channel/UCZJ2pk2BPKxwbuCV9LWDR0w)\n- You can join our [Discord][discord].\n\n## Installation\n\nStep-by-step installation instructions are available in our [documentation](https://docs.librephotos.com/docs/installation/standard-install).\n\n### System Requirements\n\n| Resource | Minimum | Recommended |\n|----------|---------|-------------|\n| RAM      | 4 GB    | 8 GB+       |\n| Storage  | 10 GB (plus your photo library) | SSD recommended |\n| CPU      | 2 cores | 4+ cores    |\n| OS       | Any Docker-compatible OS | Linux |\n\n> **Note:** Machine learning features (face recognition, scene classification, image captioning) are memory-intensive. 8 GB+ RAM is strongly recommended for smooth operation.\n\n## Features\n\n  - Support for all types of photos including raw photos\n  - Support for videos\n  - Timeline view\n  - Scans pictures on the file system\n  - Multiuser support\n  - Generate albums based on events like \"Thursday in Berlin\"\n  - Face recognition / Face classification\n  - Reverse geocoding\n  - Object / Scene detection\n  - Semantic image search\n  - Search by metadata\n\n## Tech Stack\n\n### Backend\n\n- **Framework:** [Django 5](https://www.djangoproject.com/) with [Django REST Framework](https://www.django-rest-framework.org/)\n- **Database:** [PostgreSQL](https://www.postgresql.org/)\n- **Task Queue:** [Django-Q2](https://github.com/django-q2/django-q2)\n- **Image Conversion:** [ImageMagick](https://github.com/ImageMagick/ImageMagick)\n- **Video Conversion:** [FFmpeg](https://github.com/FFmpeg/FFmpeg)\n- **Exif Support:** [ExifTool](https://github.com/exiftool/exiftool)\n\n### Frontend\n\n- **UI:** [React 18](https://react.dev/) with [TypeScript](https://www.typescriptlang.org/)\n- **Build Tool:** [Vite](https://vite.dev/)\n- **Component Library:** [Mantine](https://mantine.dev/)\n- **Routing:** [TanStack Router](https://tanstack.com/router)\n- **Data Fetching:** [TanStack Query](https://tanstack.com/query)\n- **Maps:** [MapLibre GL](https://maplibre.org/)\n- **Internationalization:** [i18next](https://www.i18next.com/)\n\n### Machine Learning\n\n- **Face detection:** [face_recognition](https://github.com/ageitgey/face_recognition)\n- **Face classification/clustering:** [scikit-learn](https://scikit-learn.org/) and [hdbscan](https://github.com/scikit-learn-contrib/hdbscan)\n- **Image captioning:** [im2txt](https://github.com/HughKu/Im2txt)\n- **Scene classification:** [places365](http://places.csail.mit.edu/)\n- **Reverse geocoding:** [geopy](https://github.com/geopy/geopy)\n\n### Infrastructure\n\n- **Deployment:** [Docker](https://www.docker.com/) & [Docker Compose](https://docs.docker.com/compose/)\n- **Reverse Proxy:** [Nginx](https://nginx.org/)\n\n### API Documentation\n\nAfter starting LibrePhotos, interactive API docs are available at:\n\n- **Swagger UI:** `http://localhost:3000/api/swagger`\n- **ReDoc:** `http://localhost:3000/api/redoc`\n\n## How to help out\n\n- ⭐ **Star** this repository if you like this project!\n- 🚀 **Developing**: Get started in less than 30 minutes by following [this guide](https://docs.librephotos.com/docs/development/dev-install). Also see our [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup, code quality standards, and PR guidelines.\n- 🗒️ **Documentation**: Improving the documentation is as simple as submitting a pull request [here](https://github.com/LibrePhotos/librephotos.docs)\n- 🧪 **Testing**: If you want to help find bugs, use the ```dev``` tag and update it regularly. If you find a bug, open an issue.\n- 🧑‍🤝‍🧑 **Outreach**: Talk about this project with other people and help them to get started too!\n- 🌐 **Translations**: Make LibrePhotos accessible to more people with [weblate](https://hosted.weblate.org/engage/librephotos/).\n- 💸 [**Donate**](https://github.com/sponsors/derneuere) to the developers of LibrePhotos\n\n## Related Projects\n\n| Repository | Description |\n|------------|-------------|\n| [librephotos-frontend](https://github.com/LibrePhotos/librephotos-frontend) | React/TypeScript web frontend |\n| [librephotos-docker](https://github.com/LibrePhotos/librephotos-docker) | Docker Compose deployment configurations |\n| [librephotos.docs](https://github.com/LibrePhotos/librephotos.docs) | Documentation website source |\n| [librephotos-mobile](https://github.com/LibrePhotos/librephotos-mobile) | Mobile application |\n\n## License\n\nThis project is licensed under the [MIT License](LICENSE).\n\n[discord]: https://discord.gg/xwRvtSDGWb\n"
  },
  {
    "path": "api/__init__.py",
    "content": "default_app_config = \"api.apps.ApiConfig\"\n"
  },
  {
    "path": "api/admin.py",
    "content": "from django.contrib import admin\nfrom django_q.tasks import AsyncTask\n\nfrom .models import (\n    AlbumAuto,\n    AlbumDate,\n    AlbumPlace,\n    AlbumThing,\n    AlbumUser,\n    Cluster,\n    Face,\n    File,\n    LongRunningJob,\n    Person,\n    Photo,\n    User,\n    Thumbnail,\n)\n\n\ndef deduplicate_faces_function(queryset):\n    for photo in queryset:\n        # Get all faces in the photo\n        faces = Face.objects.filter(photo=photo)\n        # Check if there are any faces which have similar bounding boxes\n        for face in faces:\n            margin = int((face.location_right - face.location_left) * 0.05)\n            similar_faces = Face.objects.filter(\n                photo=photo,\n                location_top__lte=face.location_top + margin,\n                location_top__gte=face.location_top - margin,\n                location_right__lte=face.location_right + margin,\n                location_right__gte=face.location_right - margin,\n                location_bottom__lte=face.location_bottom + margin,\n                location_bottom__gte=face.location_bottom - margin,\n                location_left__lte=face.location_left + margin,\n                location_left__gte=face.location_left - margin,\n            )\n            if len(similar_faces) > 1:\n                # Divide between faces with a person label and faces without\n                faces_with_person_label = []\n                faces_without_person_label = []\n                for similar_face in similar_faces:\n                    if similar_face.person:\n                        faces_with_person_label.append(similar_face)\n                    else:\n                        faces_without_person_label.append(similar_face)\n                # If there are faces with a person label, keep the first one and delete the rest\n                for similar_face in faces_with_person_label[1:]:\n                    similar_face.delete()\n                # If there are faces with a person label, delete all of them\n                if len(faces_with_person_label) > 0:\n                    for similar_face in faces_without_person_label:\n                        similar_face.delete()\n                # Otherwise, keep the first face and delete the rest\n                else:\n                    for similar_face in faces_without_person_label[1:]:\n                        similar_face.delete()\n\n\n@admin.register(Face)\nclass FaceAdmin(admin.ModelAdmin):\n    list_display = (\n        \"id\",\n        \"cluster_person\",\n        \"cluster_probability\",\n        \"classification_person\",\n        \"cluster\",\n        \"photo\",\n        \"person\",\n    )\n    list_filter = (\"person\", \"cluster\")\n\n\n@admin.register(Photo)\nclass PhotoAdmin(admin.ModelAdmin):\n    actions = [\"deduplicate_faces\"]\n    list_display = [\n        \"image_hash\",\n        \"owner\",\n        \"main_file\",\n        \"last_modified\",\n        \"added_on\",\n        \"size\",\n    ]\n    list_filter = [\"owner\"]\n\n    def deduplicate_faces(self, request, queryset):\n        AsyncTask(\n            deduplicate_faces_function,\n            queryset=queryset,\n        ).run()\n\n\n@admin.register(Thumbnail)\nclass ThumbnailAdmin(admin.ModelAdmin):\n    list_display = [\"photo\", \"aspect_ratio\"]\n    raw_id_fields = [\"photo\"]\n\n\nadmin.site.register(Person)\nadmin.site.register(AlbumAuto)\nadmin.site.register(AlbumUser)\nadmin.site.register(AlbumThing)\nadmin.site.register(AlbumDate)\nadmin.site.register(AlbumPlace)\nadmin.site.register(Cluster)\nadmin.site.register(LongRunningJob)\nadmin.site.register(File)\nadmin.site.register(User)\n"
  },
  {
    "path": "api/all_tasks.py",
    "content": "import io\nimport os\nimport zipfile\n\nfrom django.conf import settings\nfrom django.utils import timezone\nfrom django_q.tasks import AsyncTask, schedule\n\nfrom api import util\nfrom api.models.long_running_job import LongRunningJob\n\n\ndef create_download_job(job_type, user, photos, filename):\n    lrj = LongRunningJob.create_job(\n        user=user,\n        job_type=job_type,\n    )\n    if job_type == LongRunningJob.JOB_DOWNLOAD_PHOTOS:\n        AsyncTask(\n            zip_photos_task, job_id=lrj.job_id, user=user, photos=photos, filename=filename\n        ).run()\n\n    return lrj.job_id\n\n\ndef zip_photos_task(job_id, user, photos, filename):\n    lrj = LongRunningJob.objects.get(job_id=job_id)\n    lrj.start()\n    count = len(photos)\n    lrj.update_progress(current=0, target=count)\n    output_directory = os.path.join(settings.MEDIA_ROOT, \"zip\")\n    zip_file_name = filename\n    done_count = 0\n    try:\n        if not os.path.exists(output_directory):\n            os.mkdir(output_directory)\n        mf = io.BytesIO()\n        files_added = {}  # Track files by path to avoid duplicates\n\n        for photo in photos:\n            done_count = done_count + 1\n\n            # Collect all files for this photo.\n            # NOTE: main_file is not guaranteed to be included in Photo.files.\n            all_files = []\n            if getattr(photo, \"main_file\", None) is not None:\n                all_files.append(photo.main_file)\n            all_files.extend(list(photo.files.all()))\n\n            # Back-compat: some datasets may still represent RAW+JPEG / Live Photo variants\n            # as deprecated stacks. Include those stack members' files too.\n            try:\n                variant_stacks = photo.stacks.filter(\n                    stack_type__in=[\"raw_jpeg\", \"live_photo\"]\n                ).prefetch_related(\"photos\", \"photos__files\", \"photos__main_file\")\n                for stack in variant_stacks:\n                    for stack_photo in stack.photos.all():\n                        if getattr(stack_photo, \"main_file\", None) is not None:\n                            all_files.append(stack_photo.main_file)\n                        all_files.extend(list(stack_photo.files.all()))\n            except Exception:\n                # If stacks aren't available for some reason, just proceed with variants.\n                pass\n\n            # Include embedded media variants for every collected file (not just main_file)\n            for file_obj in list(all_files):\n                try:\n                    if file_obj and file_obj.embedded_media.exists():\n                        all_files.extend(list(file_obj.embedded_media.all()))\n                except Exception:\n                    continue\n            \n            # Add each file to the zip\n            for file_obj in all_files:\n                if not file_obj or not file_obj.path:\n                    continue\n                    \n                # Skip if file doesn't exist on disk\n                if not os.path.exists(file_obj.path):\n                    util.logger.warning(f\"File not found, skipping: {file_obj.path}\")\n                    continue\n                \n                # Skip if already added (avoid duplicates)\n                if file_obj.path in files_added:\n                    continue\n                \n                file_name = os.path.basename(file_obj.path)\n                \n                # Handle duplicate filenames in the zip\n                if file_name in files_added.values():\n                    # Find a unique name by prepending a counter\n                    counter = 1\n                    base_name, ext = os.path.splitext(file_name)\n                    while f\"{base_name}_{counter}{ext}\" in files_added.values():\n                        counter += 1\n                    file_name = f\"{base_name}_{counter}{ext}\"\n                \n                files_added[file_obj.path] = file_name\n                \n                with zipfile.ZipFile(mf, mode=\"a\", compression=zipfile.ZIP_DEFLATED) as zf:\n                    zf.write(file_obj.path, arcname=file_name)\n            \n            lrj.update_progress(current=done_count, target=count)\n        \n        with open(os.path.join(output_directory, zip_file_name), \"wb\") as output_file:\n            output_file.write(mf.getvalue())\n\n    except Exception as e:\n        util.logger.error(f\"Error while converting files to zip: {e}\")\n\n    lrj.complete()\n    # scheduling a task to delete the zip file after a day\n    execution_time = timezone.now() + timezone.timedelta(days=1)\n    schedule(\"api.all_tasks.delete_zip_file\", filename, next_run=execution_time)\n    return os.path.join(output_directory, zip_file_name)\n\n\ndef delete_zip_file(filename):\n    file_path = os.path.join(settings.MEDIA_ROOT, \"zip\", filename)\n    try:\n        if not os.path.exists(file_path):\n            util.logger.error(f\"Error while deleting file not found at : {file_path}\")\n            return\n        else:\n            os.remove(file_path)\n            util.logger.info(f\"file deleted sucessfully at path : {file_path}\")\n            return\n\n    except Exception as e:\n        util.logger.error(f\"Error while deleting file: {e}\")\n        return e\n"
  },
  {
    "path": "api/api_util.py",
    "content": "import os\nimport random\nimport stat\n\nfrom api.models import (\n    LongRunningJob,\n    Photo,\n)\nfrom api.serializers.job import LongRunningJobSerializer\nfrom api.util import logger\n\n\ndef get_current_job():\n    job_detail = None\n    running_job = (\n        LongRunningJob.objects.filter(finished=False).order_by(\"-started_at\").first()\n    )\n    if running_job:\n        job_detail = LongRunningJobSerializer(running_job).data\n    return job_detail\n\n\ndef shuffle(list):\n    random.shuffle(list)\n    return list\n\n\ndef is_hidden(filepath):\n    name = os.path.basename(os.path.abspath(filepath))\n    return name.startswith(\".\") or has_hidden_attribute(filepath)\n\n\ndef has_hidden_attribute(filepath):\n    try:\n        return bool(os.stat(filepath).st_file_attributes & stat.FILE_ATTRIBUTE_HIDDEN)\n    except Exception:\n        return False\n\n\ndef path_to_dict(path, recurse=2):\n    d = {\"title\": os.path.basename(path), \"absolute_path\": path}\n    if recurse > 0:\n        d[\"children\"] = [\n            path_to_dict(os.path.join(path, x), recurse - 1)\n            for x in os.scandir(path)\n            if os.path.isdir(os.path.join(path, x))\n            and not is_hidden(os.path.join(path, x))\n        ]\n    else:\n        d[\"children\"] = []\n    # sort children by title alphabetically (case insensitive)\n    d[\"children\"] = sorted(d[\"children\"], key=lambda k: k[\"title\"].lower())\n    return d\n\n\ndef get_search_term_examples(user):\n    default_search_terms = [\n        \"for people\",\n        \"for places\",\n        \"for things\",\n        \"for time\",\n        \"for file path or file name\",\n    ]\n\n    possible_ids = list(\n        Photo.objects.filter(owner=user)\n        .exclude(caption_instance__captions_json={})\n        .exclude(caption_instance__captions_json__isnull=True)[:1000]\n        .values_list(\"image_hash\", flat=True)\n    )\n    if len(possible_ids) > 99:\n        possible_ids = random.choices(possible_ids, k=100)\n    logger.info(f\"{len(possible_ids)} possible ids\")\n    try:\n        samples = (\n            Photo.objects.filter(owner=user)\n            .exclude(caption_instance__captions_json={})\n            .exclude(caption_instance__captions_json__isnull=True)\n            .filter(image_hash__in=possible_ids)\n            .prefetch_related(\"faces\")\n            .prefetch_related(\"faces__person\")\n            .prefetch_related(\"caption_instance\")\n            .all()\n        )\n    except ValueError:\n        return default_search_terms\n\n    search_data = []\n    search_terms = default_search_terms\n    logger.info(\"Getting search terms for user %s\", user.id)\n    logger.info(\"Found %s photos\", len(samples))\n    for p in samples:\n        faces = p.faces.all()\n        terms_loc = \"\"\n        if (\n            p.geolocation_json\n            and p.geolocation_json != {}\n            and \"features\" in p.geolocation_json\n        ):\n            terms_loc = [\n                f[\"text\"]\n                for f in p.geolocation_json[\"features\"][-5:]\n                if \"text\" in f and not f[\"text\"].isdigit()\n            ]\n        terms_time = \"\"\n        if p.exif_timestamp:\n            terms_time = [str(p.exif_timestamp.year)]\n        terms_people = []\n        if p.faces.count() > 0:\n            terms_people = [\n                f.person.name.split(\" \")[0] if f.person else \"\" for f in faces\n            ]\n        terms_things = \"\"\n        if (\n            p.caption_instance\n            and p.caption_instance.captions_json\n            and p.caption_instance.captions_json.get(\"places365\") is not None\n        ):\n            terms_things = p.caption_instance.captions_json[\"places365\"][\"categories\"]\n\n        terms = {\n            \"loc\": terms_loc,\n            \"time\": terms_time,\n            \"people\": terms_people,\n            \"things\": terms_things,\n        }\n\n        search_data.append(terms)\n        search_terms = []\n        for datum in search_data:\n            term_time = \"\"\n            term_thing = \"\"\n            term_loc = \"\"\n            term_people = \"\"\n            if datum[\"loc\"]:\n                term_loc = random.choice(datum[\"loc\"])\n                search_terms.append(term_loc)\n            if datum[\"time\"]:\n                term_time = random.choice(datum[\"time\"])\n                search_terms.append(term_time)\n            if datum[\"things\"]:\n                term_thing = random.choice(datum[\"things\"])\n                search_terms.append(term_thing)\n            if datum[\"people\"]:\n                term_people = random.choice(datum[\"people\"])\n                search_terms.append(term_people)\n\n            search_term_loc_people = \" \".join(shuffle([term_loc, term_people]))\n            if random.random() > 0.3:\n                search_terms.append(search_term_loc_people)\n\n            search_term_time_people = \" \".join(shuffle([term_time, term_people]))\n            if random.random() > 0.3:\n                search_terms.append(search_term_time_people)\n\n            search_term_people_thing = \" \".join(shuffle([term_people, term_thing]))\n            if random.random() > 0.9:\n                search_terms.append(search_term_people_thing)\n\n            search_term_all = \" \".join(\n                shuffle([term_loc, term_people, term_time, term_thing])\n            )\n            if random.random() > 0.95:\n                search_terms.append(search_term_all)\n\n            search_term_loc_time = \" \".join(shuffle([term_loc, term_time]))\n            if random.random() > 0.3:\n                search_terms.append(search_term_loc_time)\n\n            search_term_loc_thing = \" \".join(shuffle([term_loc, term_thing]))\n            if random.random() > 0.9:\n                search_terms.append(search_term_loc_thing)\n\n            search_term_time_thing = \" \".join(shuffle([term_time, term_thing]))\n            if random.random() > 0.9:\n                search_terms.append(search_term_time_thing)\n\n    return list(filter(lambda x: len(x), set([x.strip() for x in search_terms])))\n"
  },
  {
    "path": "api/apps.py",
    "content": "from django.apps import AppConfig\n\n\nclass ApiConfig(AppConfig):\n    name = \"api\"\n    verbose_name = \"LibrePhotos\"\n"
  },
  {
    "path": "api/autoalbum.py",
    "content": "from datetime import datetime, timedelta\n\nimport numpy as np\nimport pytz\nfrom django.db.models import Q\n\nfrom api.models import (\n    AlbumAuto,\n    AlbumDate,\n    AlbumPlace,\n    AlbumThing,\n    AlbumUser,\n    Face,\n    File,\n    LongRunningJob,\n    Photo,\n)\nfrom api.util import logger\n\n\ndef regenerate_event_titles(user, job_id):\n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_GENERATE_AUTO_ALBUM_TITLES,\n        job_id=job_id,\n    )\n    try:\n        aus = AlbumAuto.objects.filter(owner=user).prefetch_related(\"photos\")\n        target_count = len(aus)\n        for idx, au in enumerate(aus):\n            logger.info(f\"job {job_id}: {idx}\")\n            au._generate_title()\n            au.save()\n            lrj.update_progress(current=idx + 1, target=target_count)\n\n        lrj.complete()\n        logger.info(f\"job {job_id}: updated lrj entry to db\")\n\n    except Exception as e:\n        logger.exception(\"An error occurred\")\n        lrj.fail(error=e)\n\n    return 1\n\n\ndef generate_event_albums(user, job_id):\n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_GENERATE_AUTO_ALBUMS,\n        job_id=job_id,\n    )\n\n    try:\n        photos = (\n            Photo.objects.filter(Q(owner=user))\n            .exclude(Q(exif_timestamp=None))\n            .only(\"exif_timestamp\")\n        )\n\n        def group(photos, dt=timedelta(hours=6)):\n            photos_with_timestamp = sorted(photos, key=lambda p: p.exif_timestamp)\n            groups = []\n            for idx, photo in enumerate(photos_with_timestamp):\n                if len(groups) == 0:\n                    groups.append([])\n                    groups[-1].append(photo)\n                # Photos are sorted by timestamp, so we can just check the last photo of the last group\n                # to see if it is within the time delta\n                elif photo.exif_timestamp - groups[-1][-1].exif_timestamp < dt:\n                    groups[-1].append(photo)\n                # If the photo is not within the time delta, we create a new group\n                else:\n                    groups.append([])\n                    groups[-1].append(photo)\n            return groups\n\n        # Group images that are on the same 1 day and 12 hours interval\n        groups = group(photos, dt=timedelta(days=1, hours=12))\n        target_count = len(groups)\n        logger.info(\n            f\"job {job_id}: made {target_count} groups out of {len(photos)} images\"\n        )\n\n        album_locations = []\n\n        date_format = \"%Y:%m:%d %H:%M:%S\"\n        for idx, group in enumerate(groups):\n            key = group[0].exif_timestamp - timedelta(hours=11, minutes=59)\n            lastKey = group[-1].exif_timestamp + timedelta(hours=11, minutes=59)\n            logger.info(str(key.date) + \" - \" + str(lastKey.date))\n            logger.info(\n                f\"job {job_id}: processing auto album with date: \"\n                + key.strftime(date_format)\n                + \" to \"\n                + lastKey.strftime(date_format)\n            )\n            items = group\n            if len(group) >= 2:\n                qs = AlbumAuto.objects.filter(owner=user).filter(\n                    timestamp__range=(key, lastKey)\n                )\n                if qs.count() == 0:\n                    album = AlbumAuto(\n                        created_on=datetime.utcnow().replace(tzinfo=pytz.utc),\n                        owner=user,\n                    )\n                    album.timestamp = key\n                    album.save()\n\n                    logger.info(f\"job {job_id}: generate auto album {album.id}\")\n                    locs = []\n                    for item in items:\n                        album.photos.add(item)\n                        item.save()\n                        if item.exif_gps_lat and item.exif_gps_lon:\n                            locs.append([item.exif_gps_lat, item.exif_gps_lon])\n                    if len(locs) > 0:\n                        album_location = np.mean(np.array(locs), 0)\n                        album_locations.append(album_location)\n                        album.gps_lat = album_location[0]\n                        album.gps_lon = album_location[1]\n                    else:\n                        album_locations.append([])\n                    album._generate_title()\n                    album.save()\n                    continue\n                if qs.count() == 1:\n                    album = qs.first()\n                    logger.info(f\"job {job_id}: update auto album {album.id}\")\n                    for item in items:\n                        if item in album.photos.all():\n                            continue\n                        album.photos.add(item)\n                        item.save()\n                    album._generate_title()\n                    album.save()\n                    continue\n                if qs.count() > 1:\n                    # To-Do: Merge both auto albums\n                    logger.info(\n                        f\"job {job_id}: found multiple auto albums for date {key.strftime(date_format)}\"\n                    )\n                    continue\n\n            lrj.update_progress(current=idx + 1, target=target_count)\n\n        lrj.complete()\n\n    except Exception as e:\n        logger.exception(\"An error occurred\")\n        lrj.fail(error=e)\n\n    return 1\n\n\n# To-Do: This does not belong here\ndef delete_missing_photos(user, job_id):\n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_DELETE_MISSING_PHOTOS,\n        job_id=job_id,\n    )\n    try:\n        missing_photos = Photo.objects.filter(\n            Q(owner=user) & Q(files=None) | Q(main_file=None)\n        )\n        for missing_photo in missing_photos:\n            album_dates = AlbumDate.objects.filter(photos=missing_photo)\n            for album_date in album_dates:\n                album_date.photos.remove(missing_photo)\n            album_things = AlbumThing.objects.filter(photos=missing_photo)\n            for album_thing in album_things:\n                album_thing.photos.remove(missing_photo)\n            album_places = AlbumPlace.objects.filter(photos=missing_photo)\n            for album_place in album_places:\n                album_place.photos.remove(missing_photo)\n            album_users = AlbumUser.objects.filter(photos=missing_photo)\n            for album_user in album_users:\n                album_user.photos.remove(missing_photo)\n            faces = Face.objects.filter(photo=missing_photo)\n            faces.delete()\n            # To-Do: Remove thumbnails\n\n        missing_photos.delete()\n\n        missing_files = File.objects.filter(Q(hash__endswith=user) & Q(missing=True))\n        missing_files.delete()\n\n        lrj.complete()\n    except Exception as e:\n        logger.exception(\"An error occurred\")\n        lrj.fail(error=e)\n    return 1\n"
  },
  {
    "path": "api/background_tasks.py",
    "content": "from tqdm import tqdm\nfrom django.db import models\n\nfrom api.models import Photo\nfrom api.models.photo_caption import PhotoCaption\nfrom api.util import logger\n\n\ndef generate_captions(overwrite=False):\n    if overwrite:\n        photos = Photo.objects.all()\n    else:\n        # Find photos that don't have search captions in PhotoSearch model\n        photos = Photo.objects.filter(\n            models.Q(search_instance__isnull=True)\n            | models.Q(search_instance__search_captions__isnull=True)\n        )\n    logger.info(\"%d photos to be processed for caption generation\" % photos.count())\n    for photo in photos:\n        logger.info(\"generating captions for %s\" % photo.main_file.path)\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n        caption_instance.generate_tag_captions()\n        photo.save()\n\n\ndef geolocate(overwrite=False):\n    if overwrite:\n        photos = Photo.objects.all()\n    else:\n        photos = Photo.objects.filter(geolocation_json={})\n    logger.info(\"%d photos to be geolocated\" % photos.count())\n    for photo in photos:\n        try:\n            logger.info(\"geolocating %s\" % photo.main_file.path)\n            photo._geolocate()\n            photo._add_location_to_album_dates()\n        except Exception:\n            logger.exception(\"could not geolocate photo: %s\", photo)\n\n\ndef add_photos_to_album_things():\n    photos = Photo.objects.all()\n    for photo in tqdm(photos):\n        photo._add_to_album_place()\n"
  },
  {
    "path": "api/batch_jobs.py",
    "content": "import os\n\nfrom django.db.models import Q\n\nfrom api import util\nfrom api.image_similarity import build_image_similarity_index\nfrom api.models.long_running_job import LongRunningJob\nfrom api.models.photo import Photo\nfrom api.semantic_search import create_clip_embeddings\n\n\ndef batch_calculate_clip_embedding(user):\n    import torch\n\n    lrj = LongRunningJob.create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_CALCULATE_CLIP_EMBEDDINGS,\n        start_now=True,\n    )\n\n    count = Photo.objects.filter(\n        Q(owner=user) & Q(clip_embeddings__isnull=True)\n    ).count()\n    lrj.update_progress(current=0, target=count)\n    \n    if not torch.cuda.is_available():\n        num_threads = 1\n        torch.set_num_threads(num_threads)\n        os.environ[\"OMP_NUM_THREADS\"] = str(num_threads)\n    else:\n        torch.multiprocessing.set_start_method(\"spawn\", force=True)\n\n    BATCH_SIZE = 64\n    util.logger.info(f\"Using threads: {torch.get_num_threads()}\")\n\n    done_count = 0\n    while done_count < count:\n        try:\n            objs = list(\n                Photo.objects.filter(Q(owner=user) & Q(clip_embeddings__isnull=True))[\n                    :BATCH_SIZE\n                ]\n            )\n            done_count += len(objs)\n\n            if len(objs) == 0:\n                break\n            valid_objs = []\n            for obj in objs:\n                # Thumbnail could have been deleted\n                if obj.thumbnail.thumbnail_big and os.path.exists(\n                    obj.thumbnail.thumbnail_big.path\n                ):\n                    valid_objs.append(obj)\n            imgs = list(map(lambda obj: obj.thumbnail.thumbnail_big.path, valid_objs))\n            if len(valid_objs) == 0:\n                continue\n\n            imgs_emb, magnitudes = create_clip_embeddings(imgs)\n\n            for obj, img_emb, magnitude in zip(valid_objs, imgs_emb, magnitudes):\n                obj.clip_embeddings = img_emb.tolist()\n                obj.clip_embeddings_magnitude = magnitude\n                obj.save()\n        except Exception as e:\n            util.logger.error(f\"Error calculating clip embeddings: {e}\")\n\n        lrj.update_progress(current=done_count, target=count)\n\n    build_image_similarity_index(user)\n    lrj.complete()\n"
  },
  {
    "path": "api/burst_detection_rules.py",
    "content": "\"\"\"\nBurst detection rules module for grouping photos taken in rapid succession.\n\nThis module provides a rules-based system for detecting burst sequences,\nfollowing the same pattern as date_time_extractor.py. Rules are stored\nas JSON in each user's profile and applied sequentially.\n\nRules are divided into two categories:\n- Hard criteria: Deterministic detection based on EXIF data and filename patterns\n  (e.g., BurstMode tag, SequenceNumber, filename patterns like IMG_001_BURST001)\n- Soft criteria: Estimation based on timestamp proximity and visual similarity\n  (e.g., photos within 2 seconds of each other from the same camera)\n\nBy default, only hard criteria rules are enabled.\n\"\"\"\n\nimport json\nimport os\nimport re\nfrom datetime import timedelta\n\nfrom api.metadata.tags import Tags\nfrom api.util import logger\n\n\nclass BurstRuleTypes:\n    \"\"\"Types of burst detection rules.\"\"\"\n\n    # Hard criteria (deterministic)\n    EXIF_BURST_MODE = \"exif_burst_mode\"\n    EXIF_SEQUENCE_NUMBER = \"exif_sequence_number\"\n    FILENAME_PATTERN = \"filename_pattern\"\n\n    # Soft criteria (estimation)\n    TIMESTAMP_PROXIMITY = \"timestamp_proximity\"\n    VISUAL_SIMILARITY = \"visual_similarity\"\n\n\nclass BurstRuleCategory:\n    \"\"\"Categories for burst detection rules.\"\"\"\n\n    HARD = \"hard\"  # Deterministic (EXIF, filenames)\n    SOFT = \"soft\"  # Estimation (timestamps, visual similarity)\n\n\n# Predefined filename patterns for burst detection\nBURST_FILENAME_PATTERNS = {\n    # Pattern name: (regex, description)\n    \"burst_suffix\": (\n        r\"_BURST\\d+\",\n        \"Files with _BURST followed by numbers (e.g., IMG_001_BURST001.jpg)\",\n    ),\n    \"sequence_suffix\": (\n        r\"_\\d{3,}$\",\n        \"Files ending with 3+ digit sequence number (e.g., IMG_001.jpg, IMG_002.jpg)\",\n    ),\n    \"bracketed_sequence\": (\n        r\"\\(\\d+\\)$\",\n        \"Files with bracketed numbers at end (e.g., photo (1).jpg, photo (2).jpg)\",\n    ),\n    \"samsung_burst\": (r\"_\\d{3}_COVER\", \"Samsung burst cover images\"),\n    \"iphone_burst\": (r\"IMG_\\d{4}_\\d+\", \"iPhone burst sequence pattern\"),\n}\n\n\nclass BurstDetectionRule:\n    \"\"\"\n    A rule for detecting burst sequences.\n\n    Each rule has:\n    - id: Unique identifier\n    - name: Human-readable name\n    - rule_type: One of BurstRuleTypes\n    - category: 'hard' or 'soft' (for UI grouping)\n    - enabled: Whether the rule is active\n    - is_default: Whether this is a default rule\n    - Type-specific parameters (e.g., interval_ms for timestamp_proximity)\n\n    Additionally, each rule can have conditions:\n    - condition_path: Regex to match full path\n    - condition_filename: Regex to match filename\n    - condition_exif: Format: \"TAG_NAME//regex_pattern\"\n    \"\"\"\n\n    def __init__(self, params):\n        self.id = params.get(\"id\")\n        self.name = params.get(\"name\", \"Unnamed rule\")\n        self.rule_type = params[\"rule_type\"]\n        self.category = params.get(\"category\", BurstRuleCategory.HARD)\n        self.enabled = params.get(\"enabled\", True)\n        self.is_default = params.get(\"is_default\", True)\n        self.params = params\n\n    def get_required_exif_tags(self):\n        \"\"\"Return set of EXIF tags needed by this rule.\"\"\"\n        tags = set()\n\n        # Add condition tag if present\n        condition_exif = self.params.get(\"condition_exif\")\n        if condition_exif:\n            tag_name = condition_exif.split(\"//\", maxsplit=1)[0]\n            tags.add(tag_name)\n\n        # Add rule-specific tags\n        if self.rule_type == BurstRuleTypes.EXIF_BURST_MODE:\n            tags.add(Tags.BURST_MODE)\n            tags.add(Tags.CONTINUOUS_DRIVE)\n        elif self.rule_type == BurstRuleTypes.EXIF_SEQUENCE_NUMBER:\n            tags.add(Tags.SEQUENCE_NUMBER)\n            tags.add(Tags.IMAGE_NUMBER)\n            tags.add(Tags.SUBSEC_TIME_ORIGINAL)\n\n        return tags\n\n    def _check_condition_path(self, path):\n        \"\"\"Check if path matches condition_path regex.\"\"\"\n        condition = self.params.get(\"condition_path\")\n        if condition:\n            return re.search(condition, path) is not None\n        return True\n\n    def _check_condition_filename(self, path):\n        \"\"\"Check if filename matches condition_filename regex.\"\"\"\n        condition = self.params.get(\"condition_filename\")\n        if condition:\n            filename = os.path.basename(path)\n            return re.search(condition, filename) is not None\n        return True\n\n    def _check_condition_exif(self, exif_tags):\n        \"\"\"Check if EXIF tag value matches condition_exif pattern.\"\"\"\n        condition = self.params.get(\"condition_exif\")\n        if not condition:\n            return True\n\n        parts = condition.split(\"//\", maxsplit=1)\n        if len(parts) != 2:\n            logger.warning(f\"Invalid condition_exif format: {condition}\")\n            return False\n\n        tag_name, pattern = parts\n        tag_value = exif_tags.get(tag_name)\n        if not tag_value:\n            return False\n        return re.search(pattern, str(tag_value)) is not None\n\n    def check_conditions(self, path, exif_tags):\n        \"\"\"Check all conditions for this rule.\"\"\"\n        return (\n            self._check_condition_path(path)\n            and self._check_condition_filename(path)\n            and self._check_condition_exif(exif_tags)\n        )\n\n    def is_burst_photo(self, photo, exif_tags):\n        \"\"\"\n        Check if a photo is part of a burst sequence according to this rule.\n\n        Args:\n            photo: Photo model instance\n            exif_tags: Dict of EXIF tag name -> value\n\n        Returns:\n            Tuple of (is_burst: bool, group_key: str or None)\n            group_key can be used to group photos into the same burst\n        \"\"\"\n        if not self.enabled:\n            return False, None\n\n        path = photo.main_file.path if photo.main_file else \"\"\n\n        if not self.check_conditions(path, exif_tags):\n            return False, None\n\n        if self.rule_type == BurstRuleTypes.EXIF_BURST_MODE:\n            return self._check_exif_burst_mode(photo, exif_tags)\n        elif self.rule_type == BurstRuleTypes.EXIF_SEQUENCE_NUMBER:\n            return self._check_exif_sequence_number(photo, exif_tags)\n        elif self.rule_type == BurstRuleTypes.FILENAME_PATTERN:\n            return self._check_filename_pattern(photo, exif_tags)\n        else:\n            # Soft rules don't use is_burst_photo - they use group_by_proximity\n            return False, None\n\n    def _check_exif_burst_mode(self, photo, exif_tags):\n        \"\"\"Check if EXIF BurstMode or ContinuousDrive indicates burst.\"\"\"\n        burst_mode = exif_tags.get(Tags.BURST_MODE)\n        continuous_drive = exif_tags.get(Tags.CONTINUOUS_DRIVE)\n\n        # BurstMode: 1 = On (Canon, etc.)\n        if burst_mode and str(burst_mode) in (\"1\", \"On\", \"True\", \"Yes\"):\n            # Group by timestamp (rounded to second) + camera model\n            camera = exif_tags.get(Tags.CAMERA, \"unknown\")\n            timestamp = photo.exif_timestamp\n            if timestamp:\n                group_key = f\"burst_{camera}_{timestamp.strftime('%Y%m%d_%H%M%S')}\"\n                return True, group_key\n            return True, None\n\n        # ContinuousDrive: Continuous, etc.\n        if continuous_drive and continuous_drive.lower() in (\"continuous\", \"on\", \"1\"):\n            camera = exif_tags.get(Tags.CAMERA, \"unknown\")\n            timestamp = photo.exif_timestamp\n            if timestamp:\n                group_key = f\"burst_{camera}_{timestamp.strftime('%Y%m%d_%H%M%S')}\"\n                return True, group_key\n            return True, None\n\n        return False, None\n\n    def _check_exif_sequence_number(self, photo, exif_tags):\n        \"\"\"Check if photo has sequence number indicating burst.\"\"\"\n        sequence_num = exif_tags.get(Tags.SEQUENCE_NUMBER)\n\n        # If we have a sequence number, it's likely part of a burst\n        if sequence_num is not None:\n            try:\n                int(sequence_num)  # Validate it's a valid number\n                # Sequence numbers suggest burst\n                # Group by directory + base timestamp\n                camera = exif_tags.get(Tags.CAMERA, \"unknown\")\n                timestamp = photo.exif_timestamp\n                if timestamp:\n                    # Round to same second for grouping\n                    group_key = f\"seq_{camera}_{timestamp.strftime('%Y%m%d_%H%M%S')}\"\n                    return True, group_key\n                return True, None\n            except (ValueError, TypeError):\n                pass\n\n        return False, None\n\n    def _check_filename_pattern(self, photo, exif_tags):\n        \"\"\"Check if filename matches burst pattern.\"\"\"\n        if not photo.main_file:\n            return False, None\n\n        filename = os.path.basename(photo.main_file.path)\n        basename = os.path.splitext(filename)[0]\n\n        # Get pattern type from params, default to checking all patterns\n        pattern_type = self.params.get(\"pattern_type\", \"all\")\n        custom_pattern = self.params.get(\"custom_pattern\")\n\n        if custom_pattern:\n            if re.search(custom_pattern, basename):\n                # Extract base name for grouping (remove trailing numbers/burst suffix)\n                base = re.sub(r\"(_BURST\\d+|_\\d{3,}|\\(\\d+\\))$\", \"\", basename)\n                directory = os.path.dirname(photo.main_file.path)\n                group_key = f\"filename_{directory}_{base}\"\n                return True, group_key\n        elif pattern_type == \"all\":\n            # Check all predefined patterns\n            for pattern_name, (pattern, _) in BURST_FILENAME_PATTERNS.items():\n                if re.search(pattern, basename, re.IGNORECASE):\n                    base = re.sub(\n                        r\"(_BURST\\d+|_\\d{3,}|\\(\\d+\\)|_COVER)$\",\n                        \"\",\n                        basename,\n                        flags=re.IGNORECASE,\n                    )\n                    directory = os.path.dirname(photo.main_file.path)\n                    group_key = f\"filename_{directory}_{base}\"\n                    return True, group_key\n        else:\n            # Check specific pattern\n            if pattern_type in BURST_FILENAME_PATTERNS:\n                pattern, _ = BURST_FILENAME_PATTERNS[pattern_type]\n                if re.search(pattern, basename, re.IGNORECASE):\n                    base = re.sub(\n                        r\"(_BURST\\d+|_\\d{3,}|\\(\\d+\\)|_COVER)$\",\n                        \"\",\n                        basename,\n                        flags=re.IGNORECASE,\n                    )\n                    directory = os.path.dirname(photo.main_file.path)\n                    group_key = f\"filename_{directory}_{base}\"\n                    return True, group_key\n\n        return False, None\n\n\ndef check_filename_pattern(photo, pattern_type=\"any\"):\n    \"\"\"\n    Check if a photo's filename matches a burst pattern.\n\n    Standalone function for testing and external use.\n\n    Args:\n        photo: Photo model instance with main_file\n        pattern_type: \"any\" to check all patterns, or specific pattern name\n                     (e.g., \"burst_suffix\", \"sequence_suffix\", \"bracketed_sequence\",\n                      \"samsung_burst\", \"iphone_burst\")\n\n    Returns:\n        Tuple of (matches: bool, group_key: str or None)\n        group_key can be used to group photos into the same burst\n    \"\"\"\n    if not photo.main_file:\n        return False, None\n\n    filename = os.path.basename(photo.main_file.path)\n    basename = os.path.splitext(filename)[0]\n    directory = os.path.dirname(photo.main_file.path)\n\n    if pattern_type == \"any\" or pattern_type == \"all\":\n        # Check all predefined patterns\n        for pattern_name, (pattern, _) in BURST_FILENAME_PATTERNS.items():\n            if re.search(pattern, basename, re.IGNORECASE):\n                base = re.sub(\n                    r\"(_BURST\\d+|_\\d{3,}|\\(\\d+\\)|_COVER)$\",\n                    \"\",\n                    basename,\n                    flags=re.IGNORECASE,\n                )\n                group_key = f\"filename_{directory}_{base}\"\n                return True, group_key\n    else:\n        # Check specific pattern\n        if pattern_type in BURST_FILENAME_PATTERNS:\n            pattern, _ = BURST_FILENAME_PATTERNS[pattern_type]\n            if re.search(pattern, basename, re.IGNORECASE):\n                base = re.sub(\n                    r\"(_BURST\\d+|_\\d{3,}|\\(\\d+\\)|_COVER)$\",\n                    \"\",\n                    basename,\n                    flags=re.IGNORECASE,\n                )\n                group_key = f\"filename_{directory}_{base}\"\n                return True, group_key\n\n    return False, None\n\n\ndef group_photos_by_timestamp(photos, interval_ms=2000, require_same_camera=True):\n    \"\"\"\n    Group photos by timestamp proximity (soft criterion).\n\n    Args:\n        photos: QuerySet of Photo objects ordered by exif_timestamp\n        interval_ms: Maximum milliseconds between consecutive burst shots\n        require_same_camera: If True, only group photos from same camera\n\n    Returns:\n        List of lists, each inner list is a group of Photo objects\n    \"\"\"\n    if not photos:\n        return []\n\n    interval = timedelta(milliseconds=interval_ms)\n    groups = []\n    current_group = []\n    prev_photo = None\n    prev_camera = None\n\n    for photo in photos:\n        if not photo.exif_timestamp:\n            continue\n\n        # Get camera info for same-camera check\n        camera = None\n        if require_same_camera and hasattr(photo, \"metadata\") and photo.metadata:\n            camera = f\"{photo.metadata.camera_make or ''}_{photo.metadata.camera_model or ''}\"\n\n        if prev_photo is None:\n            current_group = [photo]\n            prev_photo = photo\n            prev_camera = camera\n            continue\n\n        # Check time difference\n        time_diff = photo.exif_timestamp - prev_photo.exif_timestamp\n\n        # Check if same camera (if required)\n        same_camera = True\n        if require_same_camera and camera and prev_camera:\n            same_camera = camera == prev_camera\n\n        if time_diff <= interval and same_camera:\n            # Part of same burst\n            current_group.append(photo)\n        else:\n            # End of current burst, save if we have a group\n            if len(current_group) >= 2:\n                groups.append(current_group)\n            # Start new group\n            current_group = [photo]\n\n        prev_photo = photo\n        prev_camera = camera\n\n    # Don't forget the last group\n    if len(current_group) >= 2:\n        groups.append(current_group)\n\n    return groups\n\n\ndef group_photos_by_visual_similarity(photos, similarity_threshold=15):\n    \"\"\"\n    Group photos by visual similarity (soft criterion).\n\n    Uses perceptual hash comparison to group visually similar photos.\n\n    Args:\n        photos: List of Photo objects\n        similarity_threshold: Maximum hamming distance (lower = more similar)\n\n    Returns:\n        List of lists, each inner list is a group of visually similar Photo objects\n    \"\"\"\n    from api.perceptual_hash import hamming_distance\n\n    if not photos:\n        return []\n\n    # Filter photos with perceptual hashes\n    photos_with_hash = [p for p in photos if p.perceptual_hash]\n\n    if len(photos_with_hash) < 2:\n        return []\n\n    # Simple clustering: group consecutive similar photos\n    groups = []\n    current_group = [photos_with_hash[0]]\n\n    for i in range(1, len(photos_with_hash)):\n        photo = photos_with_hash[i]\n        prev_photo = photos_with_hash[i - 1]\n\n        distance = hamming_distance(photo.perceptual_hash, prev_photo.perceptual_hash)\n\n        if distance <= similarity_threshold:\n            current_group.append(photo)\n        else:\n            if len(current_group) >= 2:\n                groups.append(current_group)\n            current_group = [photo]\n\n    if len(current_group) >= 2:\n        groups.append(current_group)\n\n    return groups\n\n\n# Default rules configuration\n# Hard criteria rules (enabled by default)\nDEFAULT_HARD_RULES = [\n    {\n        \"id\": 1,\n        \"name\": \"EXIF Burst Mode Tag\",\n        \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n        \"category\": BurstRuleCategory.HARD,\n        \"enabled\": True,\n        \"is_default\": True,\n        \"description\": \"Detects photos where camera was in burst mode (using MakerNotes:BurstMode or MakerNotes:ContinuousDrive EXIF tags)\",\n    },\n    {\n        \"id\": 2,\n        \"name\": \"EXIF Sequence Number\",\n        \"rule_type\": BurstRuleTypes.EXIF_SEQUENCE_NUMBER,\n        \"category\": BurstRuleCategory.HARD,\n        \"enabled\": True,\n        \"is_default\": True,\n        \"description\": \"Groups photos by EXIF sequence number (MakerNotes:SequenceNumber) taken at the same time\",\n    },\n    {\n        \"id\": 3,\n        \"name\": \"Filename Burst Pattern\",\n        \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n        \"category\": BurstRuleCategory.HARD,\n        \"enabled\": True,\n        \"is_default\": True,\n        \"pattern_type\": \"all\",\n        \"description\": \"Detects burst sequences from filename patterns (e.g., IMG_001_BURST001, photo (1), photo (2))\",\n    },\n]\n\n# Soft criteria rules (disabled by default)\nDEFAULT_SOFT_RULES = [\n    {\n        \"id\": 101,\n        \"name\": \"Timestamp Proximity\",\n        \"rule_type\": BurstRuleTypes.TIMESTAMP_PROXIMITY,\n        \"category\": BurstRuleCategory.SOFT,\n        \"enabled\": False,\n        \"is_default\": True,\n        \"interval_ms\": 2000,\n        \"require_same_camera\": True,\n        \"description\": \"Groups photos taken within a short time interval (configurable, default 2 seconds)\",\n    },\n    {\n        \"id\": 102,\n        \"name\": \"Visual Similarity\",\n        \"rule_type\": BurstRuleTypes.VISUAL_SIMILARITY,\n        \"category\": BurstRuleCategory.SOFT,\n        \"enabled\": False,\n        \"is_default\": True,\n        \"similarity_threshold\": 15,\n        \"description\": \"Groups visually similar consecutive photos using perceptual hash comparison\",\n    },\n]\n\n# Other available rules (not included by default)\nOTHER_RULES = [\n    {\n        \"id\": 4,\n        \"name\": \"Filename Burst Suffix Only\",\n        \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n        \"category\": BurstRuleCategory.HARD,\n        \"enabled\": False,\n        \"is_default\": False,\n        \"pattern_type\": \"burst_suffix\",\n        \"description\": \"Only detect files with explicit _BURST suffix in filename\",\n    },\n    {\n        \"id\": 5,\n        \"name\": \"Custom Filename Pattern\",\n        \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n        \"category\": BurstRuleCategory.HARD,\n        \"enabled\": False,\n        \"is_default\": False,\n        \"pattern_type\": \"custom\",\n        \"custom_pattern\": \"\",\n        \"description\": \"Use a custom regex pattern to match burst filenames\",\n    },\n    {\n        \"id\": 103,\n        \"name\": \"Timestamp Proximity (Loose)\",\n        \"rule_type\": BurstRuleTypes.TIMESTAMP_PROXIMITY,\n        \"category\": BurstRuleCategory.SOFT,\n        \"enabled\": False,\n        \"is_default\": False,\n        \"interval_ms\": 5000,\n        \"require_same_camera\": False,\n        \"description\": \"Groups photos taken within 5 seconds, regardless of camera\",\n    },\n]\n\n\ndef get_default_burst_detection_rules():\n    \"\"\"Get default burst detection rules as JSON-serializable list.\"\"\"\n    return DEFAULT_HARD_RULES + DEFAULT_SOFT_RULES\n\n\ndef get_all_predefined_burst_rules():\n    \"\"\"Get all predefined burst detection rules (default + optional).\"\"\"\n    return DEFAULT_HARD_RULES + DEFAULT_SOFT_RULES + OTHER_RULES\n\n\ndef _as_json(configs):\n    \"\"\"Convert rule configs to JSON string.\"\"\"\n    return json.dumps(configs, default=lambda x: x.__dict__)\n\n\n# Pre-computed JSON strings for API responses\nDEFAULT_RULES_JSON = _as_json(get_default_burst_detection_rules())\nPREDEFINED_RULES_JSON = _as_json(get_all_predefined_burst_rules())\n\n\ndef as_rules(configs):\n    \"\"\"Convert list of rule configs to list of BurstDetectionRule objects.\"\"\"\n    return [BurstDetectionRule(config) for config in configs]\n\n\ndef get_hard_rules(rules):\n    \"\"\"Filter to only hard criteria rules.\"\"\"\n    return [r for r in rules if r.category == BurstRuleCategory.HARD and r.enabled]\n\n\ndef get_soft_rules(rules):\n    \"\"\"Filter to only soft criteria rules.\"\"\"\n    return [r for r in rules if r.category == BurstRuleCategory.SOFT and r.enabled]\n\n\ndef get_enabled_rules(rules):\n    \"\"\"Filter to only enabled rules.\"\"\"\n    return [r for r in rules if r.enabled]\n"
  },
  {
    "path": "api/cluster_manager.py",
    "content": "import numpy as np\n\nfrom api.models.cluster import UNKNOWN_CLUSTER_ID, Cluster, get_unknown_cluster\nfrom api.models.face import Face\nfrom api.models.person import Person, get_or_create_person\nfrom api.models.user import User\nfrom api.util import logger\n\n\nclass ClusterManager:\n    @staticmethod\n    def try_add_cluster(\n        user: User, cluster_id: int, faces: list[Face], padLen: int = 1\n    ) -> list[Cluster]:\n        added_clusters: list[Cluster] = []\n        known_faces: list[Face] = []\n        face_ids_by_cluster: dict[int, list[int]] = dict()\n        unknown_faces: list[Face] = []\n        unknown_ids: list[int] = []\n        encoding_by_person: dict[int, list[np.ndarray]] = dict()\n\n        face: Face\n        new_cluster: Cluster\n        unknown_cluster: Cluster = get_unknown_cluster(user=user)\n        labelStr = str(cluster_id).zfill(padLen)\n        for face in faces:\n            if not face.person:\n                unknown_faces.append(face)\n                unknown_ids.append(face.id)\n            else:\n                known_faces.append(face)\n\n        if cluster_id == UNKNOWN_CLUSTER_ID:\n            logger.info(\"Adding unknown cluster\")\n            logger.info(\n                \"Adding unknown %d faces to unknown cluster\" % len(unknown_faces)\n            )\n            logger.info(\"Adding known %d faces to unknown cluster\" % len(known_faces))\n            for face in unknown_faces:\n                face.cluster = unknown_cluster\n                face.cluster_person = None\n                face.save()\n            for face in known_faces:\n                face.cluster = unknown_cluster\n                face.save()\n\n            return added_clusters\n\n        if len(known_faces) == 0:\n            new_cluster: Cluster\n            new_person: Person\n\n            new_person = get_or_create_person(\n                name=\"Unknown \" + labelStr, owner=user, kind=Person.KIND_CLUSTER\n            )\n            new_person.cluster_owner = user\n            new_person.save()\n            new_cluster = Cluster.get_or_create_cluster_by_id(user, cluster_id)\n            new_cluster.name = \"Cluster \" + str(cluster_id)\n\n            new_cluster.person = new_person\n            encoding_by_person[new_cluster.person.id] = []\n            new_cluster.save()\n            added_clusters.append(new_cluster)\n\n            for face in unknown_faces:\n                encoding_by_person[new_cluster.person.id].append(\n                    face.get_encoding_array()\n                )\n            Face.objects.filter(id__in=unknown_ids).update(\n                cluster=new_cluster,\n                cluster_person=new_person,\n            )\n        else:\n            clusters_by_person: dict[int, Cluster] = dict()\n            mean_encoding_by_cluster: dict[int, list[np.ndarray]] = dict()\n            idx: int = 0\n            for face in known_faces:\n                if face.person.id not in clusters_by_person.keys():\n                    idx = idx + 1\n                    new_cluster = Cluster.get_or_create_cluster_by_name(\n                        user, \"Cluster \" + str(cluster_id) + \"-\" + str(idx)\n                    )\n                    new_cluster.cluster_id = cluster_id\n                    new_cluster.person = face.person\n                    clusters_by_person[new_cluster.person.id] = new_cluster\n                    added_clusters.append(new_cluster)\n                    encoding_by_person[face.person.id] = []\n                    face_ids_by_cluster[new_cluster.id] = []\n                else:\n                    new_cluster = clusters_by_person[face.person.id]\n                encoding_by_person[face.person.id].append(face.get_encoding_array())\n                face_ids_by_cluster[new_cluster.id].append(face.id)\n            for new_cluster in added_clusters:\n                Face.objects.filter(id__in=face_ids_by_cluster[new_cluster.id]).update(\n                    cluster=new_cluster\n                )\n\n            # Set initial metadata on the split clusters based on known faces\n            for new_cluster in added_clusters:\n                new_cluster.set_metadata(encoding_by_person[new_cluster.person.id])\n                mean_encoding_by_cluster[new_cluster.id] = (\n                    new_cluster.get_mean_encoding_array()\n                )\n\n            # Clear the face IDs list to prepare for processing the unknown faces\n            for new_cluster in added_clusters:\n                face_ids_by_cluster[new_cluster.id] = []\n\n            for new_cluster in added_clusters:\n                Face.objects.filter(id__in=face_ids_by_cluster[new_cluster.id]).update(\n                    cluster=new_cluster,\n                    cluster_person=new_cluster.person,\n                )\n\n        # Update statistics again and save everything, since we've added more faces\n        for new_cluster in added_clusters:\n            new_cluster.set_metadata(encoding_by_person[new_cluster.person.id])\n            new_cluster.save()\n\n        return added_clusters\n"
  },
  {
    "path": "api/date_time_extractor.py",
    "content": "import json\nimport math\nimport os\nimport pathlib\nimport re\nfrom datetime import datetime\n\nimport pytz\n\nfrom api.metadata.tags import Tags\nfrom api.util import logger\n\n\ndef _regexp_group_range(a, b):\n    return \"(\" + \"|\".join(f\"{i:02}\" for i in range(a, b)) + \")\"\n\n\n_REGEXP_GROUP_YEAR = r\"((?:19|20|21)\\d\\d)\"\n_REGEXP_GROUP_MONTH = _regexp_group_range(1, 13)\n_REGEXP_GROUP_DAY = _regexp_group_range(1, 32)\n_REGEXP_GROUP_HOUR = _regexp_group_range(0, 24)\n_REGEXP_GROUP_MIN = r\"([0-5]\\d)\"\n_REGEXP_GROUP_SEC = r\"([0-5]\\d)\"\n_REGEXP_DELIM = r\"[-:_\\., ]*\"\n_NOT_A_NUMBER = r\"(?<!\\d)\"\n\nREGEXP_NO_TZ = re.compile(\n    _NOT_A_NUMBER\n    + _REGEXP_DELIM.join(\n        [\n            _REGEXP_GROUP_YEAR,\n            _REGEXP_GROUP_MONTH,\n            _REGEXP_GROUP_DAY,\n            _REGEXP_GROUP_HOUR,\n            _REGEXP_GROUP_MIN,\n            _REGEXP_GROUP_SEC,\n        ]\n    )\n)\n\n# WhatsApp style filename - like IMG-20220101-WA0007.jpg\n# Here we get year, month, day from the filename and use the number as microsecond so that\n# media is ordered by that number but all of these images are grouped together separated from\n# other media on that date.\nREGEXP_WHATSAPP = re.compile(r\"^(?:IMG|VID)[-_](\\d{4})(\\d{2})(\\d{2})(?:[-_]WA(\\d+))?\")\nREGEXP_WHATSAPP_GROUP_MAPPING = [\"year\", \"month\", \"day\", \"microsecond\"]\n\nPREDEFINED_REGEXPS = {\n    \"default\": (REGEXP_NO_TZ, None),\n    \"whatsapp\": (REGEXP_WHATSAPP, REGEXP_WHATSAPP_GROUP_MAPPING),\n}\n\nREGEXP_GROUP_MAPPINGS = {\n    \"year\": 0,\n    \"month\": 1,\n    \"day\": 2,\n    \"hour\": 3,\n    \"minute\": 4,\n    \"second\": 5,\n    \"microsecond\": 6,\n}\n\n\ndef _extract_no_tz_datetime_from_str(x, regexp=REGEXP_NO_TZ, group_mapping=None):\n    match = re.search(regexp, x)\n    if not match:\n        return None\n    g = match.groups()\n    if group_mapping is None:\n        datetime_args = list(map(int, g))\n    else:\n        if len(g) > len(group_mapping):\n            raise ValueError(\n                f\"Can't have more groups than group mapping values: {x}, regexp: {regexp}, mapping: {group_mapping}\"\n            )\n        datetime_args = [\n            None,\n            None,\n            None,\n            0,\n            0,\n            0,\n            0,\n        ]  # year, month, day, hour, minute, second, microsecond\n        for value, how_to_use in zip(g, group_mapping):\n            if how_to_use not in REGEXP_GROUP_MAPPINGS:\n                raise ValueError(\n                    f\"Group mapping {how_to_use} is unknown - must be one of {list(REGEXP_GROUP_MAPPINGS.keys())}\"\n                )\n            ind = REGEXP_GROUP_MAPPINGS[how_to_use]\n            # handle case when we have less groups than expected\n            if value is not None:\n                datetime_args[ind] = int(value)\n\n    try:\n        parsed_datetime = datetime(*datetime_args)\n        delta = parsed_datetime - datetime.now()\n        if delta.days > 30:\n            logger.error(\n                f\"Error while parsing datetime from '{x}': Parsed datetime is {delta.days} in the future.\"\n            )\n            return None\n\n        return parsed_datetime\n    except ValueError:\n        logger.error(\n            f\"Error while trying to create datetime using '{x}': datetime arguments {datetime_args}. Regexp used: '{regexp}'\"\n        )\n        return None\n\n\nclass RuleTypes:\n    EXIF = \"exif\"\n    PATH = \"path\"\n    FILESYSTEM = \"filesystem\"\n    USER_DEFINED = \"user_defined\"\n\n\nclass TimeExtractionRule:\n    \"\"\"The goal is to help extract local time, but for historical reason it is expected the returned\n    datetime will have timezone to be set to pytz.utc (so local time + timezone equal to UTC)..\n\n    Some sources of data might give us very rich information, e.g. timestamp + timezone,\n    but others only allow to get local time (without knowing real timestamp).\n\n    The logic for extracting local time is described as a list of rules that should be applied\n    one after another until one rule is able to extract date time (or until all rules are tried\n    without success).\n\n    Currently supported rule types:\n      - \"exif\" - local time is taken using exif tag params[\"exif_tag\"] as obtained with exiftool\n      - \"path\" - time is taken from the filename using a regular expression matching\n        - if params[\"path_part\"] is set to \"full_path\" then full path (as seen by backend container)\n          is used for regexp matching instead of just fileanme.\n        - if params[\"custom_regexp\"] is specified - that regexp is used instead of default one\n          (it is still expecting 6 groups to be matched: year, month, day, hour, minute, second).\n      - \"fs\" - time is taken from file property. Since these are unix timestamps without timezones\n        they are always translated to local time using UTC.\n        - params[\"file_property\"] must be specified and equal to one of the following:\n          - \"mtime\" - for file modifided time\n          - \"ctime\" - for file created time\n    If a rule can't fetch the time (e.g. the exif tag value is not present or path doesn't match\n    a regexp) then that rule is considered to be not applicable.\n\n    In some cases it is known that the local time the rule would obtain is not in the desired\n    timezone. E.g. video datetime tag QuickTime:CreateDate is by standard written in UTC rather\n    than local time. For such cases each rule can optionally have setting \"transform_tz\" set to \"1\"\n    - in that case this rule should also specify \"source_tz\" and \"report_tz\" settings where\n    \"source_tz\" is describing the timezone that the rule is getting and \"report_tz\" is describing\n    the timezone of the location where the photo/video was taken. Both \"source_tz\" and \"report_tz\"\n    should be one of the following:\n      - \"utc\" - UTC timezone\n      - \"gps_timezonefinder\" - the timezone of the GPS location associated with the photo/video\n      - \"server_local\" - the timezone of the librephotos server - not very useful since we run docker containers in UTC timezone.\n      - \"user_default\" - user default timezone\n      - \"name:<timezone_name>\" - the timezone with the name <timezone_name>\n    If either \"source_tz\" or \"report_tz\" could not be obtained the rule is considered to be not applicable.\n\n    Additionally each rule can have condition specifications that limits the rule application\n    to only the photos/videos that meet the condition's requirement. Supported conditions:\n      - \"condition_path\": \"<regexp>\" - rule only applied to files with full path (as seen by backend)\n                                       matching the regexp\n      - \"condition_filename\": \"<regexp>\" - rule only applied to files with filename matching the regexp\n      - \"condition_exif\": \"<tag_name>//<regexp>\" - first \"//\" is considered end of tag name and the rule is only\n                                          applied if value of tag <tag_name> exists and matches the regexp.\n\n    If multiple conditions are provided the rule is considered applicable only if all of them are met.\n\n    Examples of the rules:\n      - Take local time from exif tag \"EXIF:DateTimeOriginal\" if it is available:\n\n            {\n                \"rule_type\": \"exif\",\n                \"exif_tag\": \"EXIF:DateTimeOriginal\",\n            }\n\n      - Take UTC time using tag \"QuickTime:CreateDate\" and convert it from UTC\n        to timezone associated with the GPS coordinates (only applies if both\n        tag value and GPS coordinates are available):\n\n            {\n                \"rule_type\": \"exif\",\n                \"exif_tag\": \"QuickTime:CreateDate\",\n                \"transform_tz\": 1,\n                \"source_tz\": \"utc\",\n                \"report_tz\": \"gps_timezonefinder\",\n            }\n\n      - Look at the filename and try to extract time from it - treat it as local time\n        (it is known that some devices are not using local time here - e.g. some phones\n        might use UTC time for video filenames):\n\n            {\n                \"rule_type\": \"path\",\n            }\n\n      - Take UTC time time using tag \"QuickTime:CreateDate\" and convert it from UTC\n        to a fixed timezone \"Europe/Moscow\" to get local time. Only apply to files\n        which path contains \"Moscow_Visit\" or \"FromMoscow\":\n\n            {\n                \"rule_type\": \"exif\",\n                \"exif_tag\": \"QuickTime:CreateDate\",\n                \"transform_tz\": 1,\n                \"source_tz\": \"utc\",\n                \"report_tz\": \"name:Europe/Moscow\",\n                \"condition_path\": \"(Moscow_Visit|FromMoscow)\",\n            }\n\n      - Take modified time of the file and get local time using timezone associated\n        with the GPS location. Only apply to files with \"EXIF:Model\" exif tag\n        containing \"FooBar\":\n\n            {\n                \"rule_type\": \"filesystem\",\n                \"file_property\": \"mtime\",\n                \"transform_tz\": 1,\n                \"source_tz\": \"utc\",\n                \"report_tz\": \"gps_timezonefinder\",\n                \"condition_exif\": \"EXIF:Model//FooBar\"\n            }\n    \"\"\"\n\n    def __init__(self, params):\n        self.rule_type = params[\"rule_type\"]\n        self.params = params\n\n    def get_required_exif_tags(self):\n        condition_tag, pattern = self._get_condition_exif()\n        res = set()\n        if condition_tag is not None:\n            res.add(condition_tag)\n        if self.rule_type == RuleTypes.EXIF:\n            res.add(self.params[\"exif_tag\"])\n        return res\n\n    def _get_no_tz_dt_from_tag(self, tag_name, exif_tags):\n        tag_val = exif_tags.get(tag_name)\n        if not tag_val:\n            return None\n        dt = _extract_no_tz_datetime_from_str(tag_val)\n        return dt\n\n    def _check_condition_path(self, path):\n        if \"condition_path\" in self.params:\n            return re.search(self.params[\"condition_path\"], path) is not None\n        else:\n            return True\n\n    def _check_condition_filename(self, path):\n        if \"condition_filename\" in self.params:\n            return (\n                re.search(self.params[\"condition_filename\"], pathlib.Path(path).name)\n                is not None\n            )\n        else:\n            return True\n\n    def _get_condition_exif(self):\n        val = self.params.get(\"condition_exif\")\n        if val is None:\n            return None, None\n        tag_and_pattern = val.split(\"//\", maxsplit=1)\n        if len(tag_and_pattern) != 2:\n            raise ValueError(\n                f\"Value of condition_exif must contain '//' delimiter between tag name and pattern: '{val}'\"\n            )\n        tag, pattern = tag_and_pattern\n        return tag, pattern\n\n    def _check_condition_exif(self, exif_tags):\n        tag, pattern = self._get_condition_exif()\n        if tag:\n            tag_value = exif_tags.get(tag)\n            if not tag_value:\n                return False\n            return re.search(pattern, tag_value) is not None\n        else:\n            return True\n\n    def _check_conditions(self, path, exif_tags, gps_lat, gps_lon):\n        return (\n            self._check_condition_exif(exif_tags)\n            and self._check_condition_path(path)\n            and self._check_condition_filename(path)\n        )\n\n    def apply(\n        self, path, exif_tags, gps_lat, gps_lon, user_default_tz, user_defined_timestamp\n    ):\n        if not self._check_conditions(path, exif_tags, gps_lat, gps_lon):\n            return None\n        if self.rule_type == RuleTypes.EXIF:\n            return self._apply_exif(exif_tags, gps_lat, gps_lon, user_default_tz)\n        elif self.rule_type == RuleTypes.PATH:\n            return self._apply_path(path, gps_lat, gps_lon, user_default_tz)\n        elif self.rule_type == RuleTypes.FILESYSTEM:\n            return self._apply_filesystem(path, gps_lat, gps_lon, user_default_tz)\n        elif self.rule_type == RuleTypes.USER_DEFINED:\n            return user_defined_timestamp\n        else:\n            raise ValueError(f\"Unknown rule type {self.rule_type}\")\n\n    def _get_tz(self, description, gps_lat, gps_lon, user_default_tz):\n        \"\"\"None is a valid timezone returned here (meaning that we want to use server local time).\n        This is why this function returns a tuple with the first element specifying success of\n        determining the timezone, and the second element - the timezone itself.\n        \"\"\"\n        if description == \"gps_timezonefinder\":\n            if not _check_gps_ok(gps_lat, gps_lon):\n                return (False, None)\n            from timezonefinder import TimezoneFinder\n\n            tzfinder = TimezoneFinder()\n            tz_name = tzfinder.timezone_at(lng=gps_lon, lat=gps_lat)\n            return (True, pytz.timezone(tz_name)) if tz_name else (False, None)\n        elif description == \"user_default\":\n            return (True, pytz.timezone(user_default_tz))\n        elif description == \"server_local\":\n            return (True, None)\n        elif description.lower() == \"utc\":\n            return (True, pytz.utc)\n        elif description.startswith(\"name:\"):\n            return (True, pytz.timezone(description[5:]))\n        else:\n            raise ValueError(f\"Unknown tz description {description}\")\n\n    def _transform_tz(self, dt, gps_lat, gps_lon, user_default_tz):\n        if not dt:\n            return None\n        if self.params.get(\"transform_tz\"):\n            has_source_tz, source_tz = self._get_tz(\n                self.params[\"source_tz\"], gps_lat, gps_lon, user_default_tz\n            )\n            if not has_source_tz:\n                return None\n            has_report_tz, report_tz = self._get_tz(\n                self.params[\"report_tz\"], gps_lat, gps_lon, user_default_tz\n            )\n            if not has_report_tz:\n                return None\n            # Either of source_tz or report_tz might be None - meaning that we want to use\n            # server local timezone\n            dt = datetime.fromtimestamp(\n                dt.replace(tzinfo=source_tz).timestamp(), report_tz\n            )\n        return dt.replace(tzinfo=pytz.utc)\n\n    def _apply_exif(self, exif_tags, gps_lat, gps_lon, user_default_tz):\n        dt = self._get_no_tz_dt_from_tag(self.params[\"exif_tag\"], exif_tags)\n        return self._transform_tz(dt, gps_lat, gps_lon, user_default_tz)\n\n    def _apply_path(self, path, gps_lat, gps_lon, user_default_tz):\n        path_part = self.params.get(\"path_part\")\n        if path_part is None or path_part == \"filename\":\n            source = pathlib.Path(path).name\n        elif path_part == \"full_path\":\n            source = path\n        else:\n            raise ValueError(f\"Unknown path_part {path_part}\")\n\n        group_mapping = None\n        regexp = self.params.get(\"custom_regexp\")\n        if not regexp:\n            predefined_regexp_type = self.params.get(\"predefined_regexp\", \"default\")\n            if predefined_regexp_type not in PREDEFINED_REGEXPS:\n                raise ValueError(\n                    f\"Unknown predefined regexp type {predefined_regexp_type}\"\n                )\n            regexp, group_mapping = PREDEFINED_REGEXPS[predefined_regexp_type]\n        dt = _extract_no_tz_datetime_from_str(source, regexp, group_mapping)\n        return self._transform_tz(dt, gps_lat, gps_lon, user_default_tz)\n\n    def _apply_filesystem(self, path, gps_lat, gps_lon, user_default_tz):\n        file_property = self.params.get(\"file_property\")\n        if file_property == \"mtime\":\n            dt = datetime.fromtimestamp(os.path.getmtime(path), pytz.utc)\n        elif file_property == \"ctime\":\n            dt = datetime.fromtimestamp(os.path.getctime(path), pytz.utc)\n        else:\n            raise ValueError(f\"Unknown file_property {file_property}\")\n        return self._transform_tz(dt, gps_lat, gps_lon, user_default_tz)\n\n\ndef _check_gps_ok(lat, lon):\n    return (\n        lat is not None\n        and lon is not None\n        and math.isfinite(lat)\n        and math.isfinite(lon)\n        and (lat != 0.0 or lon != 0.0)\n    )\n\n\nALL_TIME_ZONES = pytz.all_timezones\n\nDEFAULT_RULES_PARAMS = [\n    {\n        \"id\": 14,\n        \"name\": \"Timestamp set by user\",\n        \"rule_type\": RuleTypes.USER_DEFINED,\n    },\n    {\n        \"id\": 15,\n        \"name\": f\"Local time from {Tags.DATE_TIME} exif tag\",\n        \"rule_type\": RuleTypes.EXIF,\n        \"exif_tag\": Tags.DATE_TIME,\n    },\n    {\n        \"id\": 1,\n        \"name\": f\"Local time from {Tags.DATE_TIME_ORIGINAL} exif tag\",\n        \"rule_type\": RuleTypes.EXIF,\n        \"exif_tag\": Tags.DATE_TIME_ORIGINAL,\n    },\n    {\n        \"id\": 2,\n        \"name\": \"Get Video creation tag in UTC + figure out timezone using GPS coordinates\",\n        \"rule_type\": RuleTypes.EXIF,\n        \"exif_tag\": Tags.QUICKTIME_CREATE_DATE,\n        \"transform_tz\": 1,\n        \"source_tz\": \"utc\",\n        \"report_tz\": \"gps_timezonefinder\",\n    },\n    {\n        \"id\": 11,\n        \"name\": f\"Use {Tags.GPS_DATE_TIME} tag + figure out timezone using GPS coordinates\",\n        \"rule_type\": RuleTypes.EXIF,\n        \"exif_tag\": Tags.GPS_DATE_TIME,\n        \"transform_tz\": 1,\n        \"source_tz\": \"utc\",\n        \"report_tz\": \"gps_timezonefinder\",\n    },\n    {\n        \"id\": 3,\n        \"name\": \"Using filename assuming time is local (most of filenames auto generated by smartphones etc)\",\n        \"rule_type\": RuleTypes.PATH,\n    },\n    {\n        \"id\": 4,\n        \"name\": \"Video creation datetime in user default timezone (can't find out actual timezone)\",\n        \"rule_type\": RuleTypes.EXIF,\n        \"exif_tag\": Tags.QUICKTIME_CREATE_DATE,\n        \"transform_tz\": 1,\n        \"source_tz\": \"utc\",\n        \"report_tz\": \"user_default\",\n    },\n    {\n        \"id\": 5,\n        \"name\": \"Extract date using WhatsApp file name\",\n        \"rule_type\": RuleTypes.PATH,\n        \"predefined_regexp\": \"whatsapp\",\n    },\n]\n\nOTHER_RULES_PARAMS = [\n    {\n        \"id\": 6,\n        \"name\": \"Video creation datetime in UTC timezone (can't find out actual timezone)\",\n        \"rule_type\": RuleTypes.EXIF,\n        \"exif_tag\": Tags.QUICKTIME_CREATE_DATE,\n    },\n    {\n        \"id\": 7,\n        \"name\": \"File modified time in user default timezone\",\n        \"rule_type\": RuleTypes.FILESYSTEM,\n        \"file_property\": \"mtime\",\n        \"transform_tz\": 1,\n        \"source_tz\": \"utc\",\n        \"report_tz\": \"user_default\",\n    },\n    {\n        \"id\": 8,\n        \"name\": \"File modified time in UTC timezone\",\n        \"rule_type\": RuleTypes.FILESYSTEM,\n        \"file_property\": \"mtime\",\n    },\n    {\n        \"id\": 9,\n        \"name\": \"File created time in user default timezone\",\n        \"rule_type\": RuleTypes.FILESYSTEM,\n        \"file_property\": \"ctime\",\n        \"transform_tz\": 1,\n        \"source_tz\": \"utc\",\n        \"report_tz\": \"user_default\",\n    },\n    {\n        \"id\": 10,\n        \"name\": \"File created time in UTC timezone\",\n        \"rule_type\": RuleTypes.FILESYSTEM,\n        \"file_property\": \"ctime\",\n    },\n    {\n        \"id\": 12,\n        \"name\": f\"Use {Tags.GPS_DATE_TIME} tag in user default timezone (can't find out actual timezone)\",\n        \"rule_type\": RuleTypes.EXIF,\n        \"exif_tag\": Tags.GPS_DATE_TIME,\n        \"transform_tz\": 1,\n        \"source_tz\": \"utc\",\n        \"report_tz\": \"user_default\",\n    },\n    {\n        \"id\": 13,\n        \"name\": f\"Use {Tags.GPS_DATE_TIME} tag in UTC timezone (can't find out actual timezone)\",\n        \"rule_type\": RuleTypes.EXIF,\n        \"exif_tag\": Tags.GPS_DATE_TIME,\n    },\n]\n\n\ndef set_as_default_rule(rule):\n    rule[\"is_default\"] = True\n    return rule\n\n\ndef set_as_other_rule(rule):\n    rule[\"is_default\"] = False\n    return rule\n\n\nPREDEFINED_RULES_PARAMS = list(map(set_as_default_rule, DEFAULT_RULES_PARAMS)) + list(\n    map(set_as_other_rule, OTHER_RULES_PARAMS)\n)\n\n\ndef _as_json(configs):\n    return json.dumps(configs, default=lambda x: x.__dict__)\n\n\nDEFAULT_RULES_JSON = _as_json(DEFAULT_RULES_PARAMS)\nPREDEFINED_RULES_JSON = _as_json(PREDEFINED_RULES_PARAMS)\nALL_TIME_ZONES_JSON = _as_json(ALL_TIME_ZONES)\n\n\ndef as_rules(configs):\n    return list(map(TimeExtractionRule, configs))\n\n\ndef extract_local_date_time(\n    path, rules, exif_getter, gps_lat, gps_lon, user_default_tz, user_defined_timestamp\n):\n    required_tags = set()\n    for rule in rules:\n        required_tags.update(rule.get_required_exif_tags())\n    required_tags = list(required_tags)\n    exif_values = exif_getter(required_tags)\n    exif_tags = {k: v for k, v in zip(required_tags, exif_values)}\n    for rule in rules:\n        res = rule.apply(\n            path, exif_tags, gps_lat, gps_lon, user_default_tz, user_defined_timestamp\n        )\n        if res:\n            return res\n    return None\n"
  },
  {
    "path": "api/directory_watcher/__init__.py",
    "content": "\"\"\"\nDirectory watcher module for scanning and processing photos.\n\nThis module implements a two-phase scan architecture to avoid race conditions\nwhen processing RAW+JPEG pairs concurrently:\n\nPhase 1: Collect all files and group by (directory, basename)\n         - IMG_001.jpg, IMG_001.CR2, IMG_001.xmp -> one group\n         - IMG_002.jpg -> separate group\n\nPhase 2: Process each group, creating one Photo per group\n         with all file variants attached.\n\"\"\"\n\n# Main scan functions\nfrom api.directory_watcher.scan_jobs import (\n    scan_photos,\n    scan_missing_photos,\n    photo_scanner,\n)\n\n# File handling\nfrom api.directory_watcher.file_handlers import (\n    create_new_image,\n    create_file_record,\n    group_files_into_photo,\n    handle_new_image,\n    handle_file_group,\n)\n\n# File grouping utilities\nfrom api.directory_watcher.file_grouping import (\n    JPEG_EXTENSIONS,\n    FILE_TYPE_PRIORITY,\n    get_file_grouping_key,\n    select_main_file,\n    find_matching_jpeg_photo,\n    find_matching_image_for_video,\n)\n\n# Processing jobs\nfrom api.directory_watcher.processing_jobs import (\n    generate_tags,\n    generate_tag_job,\n    add_geolocation,\n    geolocation_job,\n    scan_faces,\n    generate_face_embeddings,\n)\n\n# Repair jobs\nfrom api.directory_watcher.repair_jobs import (\n    repair_ungrouped_file_variants,\n)\n\n# Utilities\nfrom api.directory_watcher.utils import (\n    is_hidden,\n    should_skip,\n    walk_directory,\n    walk_files,\n    update_scan_counter,\n)\n\n# Re-export from api.models.file for backwards compatibility\nfrom api.models.file import is_valid_media\n\n__all__ = [\n    # Scan jobs\n    \"scan_photos\",\n    \"scan_missing_photos\",\n    \"photo_scanner\",\n    # File handling\n    \"create_new_image\",\n    \"create_file_record\",\n    \"group_files_into_photo\",\n    \"handle_new_image\",\n    \"handle_file_group\",\n    # File grouping\n    \"JPEG_EXTENSIONS\",\n    \"FILE_TYPE_PRIORITY\",\n    \"get_file_grouping_key\",\n    \"select_main_file\",\n    \"find_matching_jpeg_photo\",\n    \"find_matching_image_for_video\",\n    # Processing jobs\n    \"generate_tags\",\n    \"generate_tag_job\",\n    \"add_geolocation\",\n    \"geolocation_job\",\n    \"scan_faces\",\n    \"generate_face_embeddings\",\n    # Repair jobs\n    \"repair_ungrouped_file_variants\",\n    # Utilities\n    \"is_hidden\",\n    \"should_skip\",\n    \"walk_directory\",\n    \"walk_files\",\n    \"update_scan_counter\",\n    # Re-exported from api.models.file\n    \"is_valid_media\",\n]\n"
  },
  {
    "path": "api/directory_watcher/file_grouping.py",
    "content": "\"\"\"\nFile grouping utilities for the two-phase scan architecture.\n\nThis module provides functions for grouping related files (RAW+JPEG pairs,\nLive Photos, etc.) so they can be processed together as a single Photo.\n\"\"\"\n\nimport os\n\nfrom api.models import File, Photo\n\n\n# JPEG/image extensions that RAW files can be paired with\nJPEG_EXTENSIONS = {'.jpg', '.jpeg', '.heic', '.heif', '.png', '.tiff', '.tif'}\n\n# File type priority for main_file selection (lower number = higher priority)\n# JPEG/processed images should be main_file, RAW/video variants are secondary\nFILE_TYPE_PRIORITY = {\n    File.IMAGE: 1,      # JPEG, HEIC, PNG - highest priority\n    File.VIDEO: 2,      # Videos (standalone or Live Photo motion)\n    File.RAW_FILE: 3,   # RAW files are variants, not main\n    File.METADATA_FILE: 4,  # XMP sidecars - lowest priority\n    File.UNKNOWN: 5,\n}\n\n\ndef get_file_grouping_key(path: str) -> tuple[str, str]:\n    \"\"\"\n    Get the grouping key for a file path.\n    \n    Files with the same (directory, basename) should be grouped together\n    as variants of the same Photo (e.g., IMG_001.jpg + IMG_001.CR2 + IMG_001.xmp).\n    \n    Args:\n        path: File path to get grouping key for\n        \n    Returns:\n        Tuple of (directory, lowercase_basename_without_extension)\n    \"\"\"\n    directory = os.path.dirname(path)\n    basename = os.path.splitext(os.path.basename(path))[0].lower()\n    return (directory, basename)\n\n\ndef select_main_file(files: list[File]) -> File | None:\n    \"\"\"\n    Select the best file to be the main_file for a Photo.\n    \n    Priority: IMAGE > VIDEO > RAW > METADATA\n    Within same type, prefer the first one found (alphabetically by path).\n    \n    Args:\n        files: List of File objects to choose from\n        \n    Returns:\n        The File that should be main_file, or None if empty list\n    \"\"\"\n    if not files:\n        return None\n    \n    return min(files, key=lambda f: (FILE_TYPE_PRIORITY.get(f.type, 999), f.path))\n\n\ndef find_matching_jpeg_photo(raw_path: str, user) -> Photo | None:\n    \"\"\"\n    Find an existing Photo with a matching JPEG/image file for a RAW file.\n    \n    Matches based on same base filename (without extension) in the same directory.\n    This implements the PhotoPrism-like file variant model where RAW+JPEG are\n    one Photo with multiple file variants, not separate Photos.\n    \n    Args:\n        raw_path: Path to the RAW file\n        user: Owner of the photos\n        \n    Returns:\n        Matching Photo if found, None otherwise\n    \"\"\"\n    raw_dir = os.path.dirname(raw_path)\n    raw_basename = os.path.splitext(os.path.basename(raw_path))[0]\n    \n    # Look for matching JPEG/image file in same directory\n    for jpeg_ext in JPEG_EXTENSIONS:\n        # Try both lowercase and uppercase extensions\n        for ext in [jpeg_ext, jpeg_ext.upper()]:\n            jpeg_path = os.path.join(raw_dir, raw_basename + ext)\n            photo = Photo.objects.filter(\n                owner=user,\n                main_file__path=jpeg_path\n            ).first()\n            if photo:\n                return photo\n    \n    return None\n\n\ndef find_matching_image_for_video(video_path: str, user) -> Photo | None:\n    \"\"\"\n    Find an existing Photo with a matching image file for a Live Photo video.\n    \n    Apple Live Photos store the video as a separate .mov file with the same\n    base name as the image. This allows attaching the video as a file variant.\n    \n    Args:\n        video_path: Path to the video file\n        user: Owner of the photos\n        \n    Returns:\n        Matching Photo if found, None otherwise\n    \"\"\"\n    video_dir = os.path.dirname(video_path)\n    video_basename = os.path.splitext(os.path.basename(video_path))[0]\n    video_ext_lower = os.path.splitext(video_path)[1].lower()\n    \n    # Only match .mov files (Apple Live Photos) with same base name\n    if video_ext_lower not in ['.mov']:\n        return None\n    \n    # Look for matching image file with same base name\n    image_extensions = list(JPEG_EXTENSIONS) + ['.heic', '.HEIC']\n    for img_ext in image_extensions:\n        for ext in [img_ext, img_ext.upper()]:\n            image_path = os.path.join(video_dir, video_basename + ext)\n            photo = Photo.objects.filter(\n                owner=user,\n                main_file__path=image_path\n            ).first()\n            if photo:\n                return photo\n    \n    return None\n"
  },
  {
    "path": "api/directory_watcher/file_handlers.py",
    "content": "\"\"\"\nFile and Photo creation handlers.\n\nThis module contains functions for creating File records and grouping\nthem into Photo objects.\n\"\"\"\n\nimport datetime\nimport os\n\nimport pytz\nfrom django.conf import settings\nfrom django.db.models import Q\n\nfrom api import util\nfrom api.models import File, Photo, Thumbnail\nfrom api.models.file import calculate_hash, is_metadata, is_raw, is_valid_media, is_video\nfrom api.models.photo_search import PhotoSearch\nfrom api.perceptual_hash import calculate_hash_from_thumbnail\nfrom api.stacks.live_photo import has_embedded_motion_video, extract_embedded_motion_video\n\nfrom api.directory_watcher.file_grouping import (\n    FILE_TYPE_PRIORITY,\n    find_matching_jpeg_photo,\n    find_matching_image_for_video,\n    select_main_file,\n)\nfrom api.directory_watcher.utils import update_scan_counter\n\n\ndef create_file_record(user, path) -> File | None:\n    \"\"\"\n    Phase 1: Create a File record for a path without creating/grouping Photos.\n    \n    This is the first phase of the two-phase scan architecture:\n    - Phase 1: Create File records for all discovered files (this function)\n    - Phase 2: Group files into Photos by (directory, basename)\n    \n    This separation eliminates race conditions where concurrent processing\n    of RAW and JPEG files could create separate Photos instead of grouping them.\n    \n    Args:\n        user: The owner of the file\n        path: The file path\n        \n    Returns:\n        File object if created/found, None if invalid media\n    \"\"\"\n    if not is_valid_media(path=path, user=user):\n        return None\n    \n    hash_value = calculate_hash(user, path)\n    \n    # Skip if this is embedded media (already attached to another file)\n    if File.embedded_media.through.objects.filter(Q(to_file_id=hash_value)).exists():\n        util.logger.warning(f\"embedded content file found {path}\")\n        return None\n    \n    # Create the File record (File.create handles race conditions via unique path constraint)\n    file = File.create(path, user)\n    return file\n\n\ndef group_files_into_photo(user, files: list[File], job_id) -> Photo | None:\n    \"\"\"\n    Phase 2: Group a list of related files into a single Photo.\n    \n    Creates a new Photo with the given files as variants, selecting the\n    best file as main_file based on type priority (IMAGE > VIDEO > RAW > METADATA).\n    \n    This function should be called with all files that share the same\n    (directory, basename) - e.g., IMG_001.jpg, IMG_001.CR2, IMG_001.xmp.\n    \n    Args:\n        user: The owner of the photo\n        files: List of File objects to group (must not be empty)\n        job_id: Job ID for logging\n        \n    Returns:\n        The created Photo, or None if no valid files\n    \"\"\"\n    if not files:\n        return None\n    \n    # Filter out metadata files for main photo creation - they're sidecars\n    non_metadata_files = [f for f in files if f.type != File.METADATA_FILE]\n    \n    if not non_metadata_files:\n        # Only metadata files - no photo to create\n        util.logger.warning(f\"job {job_id}: Only metadata files in group, skipping\")\n        return None\n    \n    # Select main file based on priority\n    main_file = select_main_file(non_metadata_files)\n    if not main_file:\n        return None\n    \n    # Check if a Photo already exists with any of these files\n    existing_photo = Photo.objects.filter(\n        owner=user,\n        files__in=files\n    ).first()\n    \n    if existing_photo:\n        # Add any missing files to the existing photo\n        for f in files:\n            if not existing_photo.files.filter(hash=f.hash).exists():\n                existing_photo.files.add(f)\n                util.logger.info(f\"job {job_id}: Attached file {f.path} to existing Photo {existing_photo.image_hash}\")\n        \n        # Update main_file if current one has lower priority\n        if existing_photo.main_file:\n            current_priority = FILE_TYPE_PRIORITY.get(existing_photo.main_file.type, 999)\n            new_priority = FILE_TYPE_PRIORITY.get(main_file.type, 999)\n            if new_priority < current_priority:\n                existing_photo.main_file = main_file\n                existing_photo.save(update_fields=['main_file'])\n        \n        return existing_photo\n    \n    # Create new Photo\n    photo = Photo()\n    photo.image_hash = main_file.hash\n    photo.owner = user\n    photo.added_on = datetime.datetime.now().replace(tzinfo=pytz.utc)\n    photo.geolocation_json = {}\n    photo.video = (main_file.type == File.VIDEO)\n    photo.save()\n    \n    # Add all files to the photo\n    for f in files:\n        photo.files.add(f)\n    \n    photo.main_file = main_file\n    photo.save()\n    \n    # Handle embedded media (Google/Samsung Live Photos with embedded video)\n    if has_embedded_motion_video(main_file.path) and settings.FEATURE_PROCESS_EMBEDDED_MEDIA:\n        em_path = extract_embedded_motion_video(main_file.path, main_file.hash)\n        if em_path:\n            em_file = File.create(em_path, user)\n            main_file.embedded_media.add(em_file)\n            photo.files.add(em_file)\n            photo.save()\n    \n    util.logger.info(f\"job {job_id}: Created Photo {photo.image_hash} with {len(files)} file(s)\")\n    return photo\n\n\ndef create_new_image(user, path) -> Photo | None:\n    \"\"\"\n    Creates a new Photo object based on user input and file path.\n    \n    This is the legacy single-file creation function, kept for backwards\n    compatibility with upload handling. For scan operations, use the\n    two-phase approach (create_file_record + group_files_into_photo).\n\n    Args:\n        user: The owner of the photo.\n        path: The file path of the image.\n\n    Returns:\n        The created Photo object if successful, otherwise returns None.\n\n    Note:\n        This function implements file variant grouping (PhotoPrism-like):\n        - RAW files are attached to existing JPEG Photos as file variants\n        - Live Photo videos (.mov) are attached to existing image Photos as file variants\n        - Other files create new Photo entities\n    \"\"\"\n    if not is_valid_media(path=path, user=user):\n        return None\n    hash_value = calculate_hash(user, path)\n    if File.embedded_media.through.objects.filter(Q(to_file_id=hash_value)).exists():\n        util.logger.warning(f\"embedded content file found {path}\")\n        return None\n\n    # Handle metadata files (XMP sidecars)\n    if is_metadata(path):\n        photo_name = os.path.splitext(os.path.basename(path))[0]\n        photo_dir = os.path.dirname(path)\n        photo = Photo.objects.filter(\n            Q(files__path__contains=photo_dir)\n            & Q(files__path__contains=photo_name)\n            & ~Q(files__path__contains=os.path.basename(path))\n        ).first()\n\n        if photo:\n            file = File.create(path, user)\n            photo.files.add(file)\n            photo.save()\n        else:\n            util.logger.warning(f\"no photo to metadata file found {path}\")\n        return None\n\n    # === File Variant Handling (PhotoPrism-like model) ===\n    \n    # Handle RAW files: attach to existing JPEG Photo if found\n    if is_raw(path):\n        existing_photo = find_matching_jpeg_photo(path, user)\n        if existing_photo:\n            # Check if this RAW file is already attached\n            if not existing_photo.files.filter(path=path).exists():\n                raw_file = File.create(path, user)\n                existing_photo.files.add(raw_file)\n                existing_photo.save()\n                util.logger.info(f\"Attached RAW file {path} to existing Photo {existing_photo.image_hash}\")\n            return existing_photo\n    \n    # Handle Live Photo videos (.mov): attach to existing image Photo if found\n    if is_video(path):\n        existing_photo = find_matching_image_for_video(path, user)\n        if existing_photo:\n            # Check if this video is already attached\n            if not existing_photo.files.filter(path=path).exists():\n                video_file = File.create(path, user)\n                existing_photo.files.add(video_file)\n                existing_photo.video = False  # Keep photo as image (video is just a variant)\n                existing_photo.save()\n                util.logger.info(f\"Attached Live Photo video {path} to existing Photo {existing_photo.image_hash}\")\n            return existing_photo\n\n    # === Standard Photo Creation ===\n    photo = Photo()\n    photo.image_hash = hash_value\n    photo.owner = user\n    photo.added_on = datetime.datetime.now().replace(tzinfo=pytz.utc)\n    photo.geolocation_json = {}\n    photo.video = is_video(path)\n    photo.save()\n    file = File.create(path, user)\n    \n    # Live Photo detection - extracts embedded motion video if present (Google/Samsung)\n    if has_embedded_motion_video(file.path) and settings.FEATURE_PROCESS_EMBEDDED_MEDIA:\n        em_path = extract_embedded_motion_video(file.path, file.hash)\n        if em_path:\n            em_file = File.create(em_path, user)\n            file.embedded_media.add(em_file)\n            # Also add embedded video to Photo.files as a variant\n            photo.files.add(em_file)\n    \n    photo.files.add(file)\n    photo.main_file = file\n    photo.save()\n    return photo\n\n\ndef handle_new_image(user, path, job_id, photo=None):\n    \"\"\"\n    Handles the creation and all the processing of the photo needed for it to be displayed.\n\n    Args:\n        user: The owner of the photo.\n        path: The file path of the image.\n        job_id: The long-running job id, which gets updated when the task runs\n        photo: An optional parameter, where you can input a photo instead of creating a new one. Used for uploading.\n\n    Note:\n        This function is used when uploading a picture, because rescanning does not perform machine learning tasks.\n    \"\"\"\n    try:\n        start = datetime.datetime.now()\n        if photo is None:\n            photo = create_new_image(user, path)\n            elapsed = (datetime.datetime.now() - start).total_seconds()\n            util.logger.info(f\"job {job_id}: save image: {path}, elapsed: {elapsed}\")\n        if photo:\n            _process_photo(photo, path, job_id, start)\n\n    except Exception as e:\n        try:\n            util.logger.exception(\n                f\"job {job_id}: could not load image {path}. reason: {str(e)}\"\n            )\n        except Exception:\n            util.logger.exception(f\"job {job_id}: could not load image {path}\")\n    finally:\n        update_scan_counter(job_id)\n\n\ndef handle_file_group(user, file_paths: list[str], job_id):\n    \"\"\"\n    Phase 2 handler: Process a group of related files into a single Photo.\n    \n    This is called after Phase 1 has created File records for all paths.\n    Files are grouped by (directory, basename) so RAW+JPEG pairs are processed together.\n    \n    Args:\n        user: The owner of the files\n        file_paths: List of file paths that share the same (directory, basename)\n        job_id: Job ID for logging and progress tracking\n    \"\"\"\n    try:\n        start = datetime.datetime.now()\n        \n        # Get or create File records for all paths\n        files = []\n        for path in file_paths:\n            file = create_file_record(user, path)\n            if file:\n                files.append(file)\n        \n        if not files:\n            util.logger.warning(f\"job {job_id}: No valid files in group: {file_paths}\")\n            return\n        \n        # Group files into a Photo\n        photo = group_files_into_photo(user, files, job_id)\n        \n        if not photo:\n            util.logger.warning(f\"job {job_id}: Could not create photo for files: {file_paths}\")\n            return\n        \n        elapsed = (datetime.datetime.now() - start).total_seconds()\n        util.logger.info(f\"job {job_id}: created photo with {len(files)} files, elapsed: {elapsed}\")\n        \n        # Process the photo (thumbnails, EXIF, etc.) using main_file\n        if photo.main_file:\n            _process_photo(photo, photo.main_file.path, job_id, start)\n\n    except Exception as e:\n        try:\n            util.logger.exception(\n                f\"job {job_id}: could not process file group {file_paths}. reason: {str(e)}\"\n            )\n        except Exception:\n            util.logger.exception(f\"job {job_id}: could not process file group\")\n    finally:\n        update_scan_counter(job_id)\n\n\ndef _process_photo(photo: Photo, path: str, job_id, start: datetime.datetime):\n    \"\"\"\n    Process a photo: generate thumbnails, extract EXIF, calculate hashes, etc.\n    \n    This is the common processing logic shared between handle_new_image and handle_file_group.\n    \n    Args:\n        photo: The Photo object to process\n        path: The main file path (for logging)\n        job_id: Job ID for logging\n        start: Start time for elapsed time calculation\n    \"\"\"\n    util.logger.info(f\"job {job_id}: handling image {path}\")\n    \n    # Create or get thumbnail instance\n    thumbnail, _ = Thumbnail.objects.get_or_create(photo=photo)\n    thumbnail._generate_thumbnail()\n    elapsed = (datetime.datetime.now() - start).total_seconds()\n    util.logger.info(\n        f\"job {job_id}: generate thumbnails: {path}, elapsed: {elapsed}\"\n    )\n    \n    thumbnail._calculate_aspect_ratio()\n    elapsed = (datetime.datetime.now() - start).total_seconds()\n    util.logger.info(\n        f\"job {job_id}: calculate aspect ratio: {path}, elapsed: {elapsed}\"\n    )\n    \n    # Calculate perceptual hash for duplicate detection\n    if thumbnail.thumbnail_big and os.path.exists(thumbnail.thumbnail_big.path):\n        phash = calculate_hash_from_thumbnail(thumbnail.thumbnail_big.path)\n        if phash:\n            photo.perceptual_hash = phash\n            photo.save(update_fields=[\"perceptual_hash\"])\n            elapsed = (datetime.datetime.now() - start).total_seconds()\n            util.logger.info(\n                f\"job {job_id}: calculate perceptual hash: {path}, elapsed: {elapsed}\"\n            )\n    \n    from api.models.photo_metadata import PhotoMetadata\n    PhotoMetadata.extract_exif_data(photo, commit=True)\n    elapsed = (datetime.datetime.now() - start).total_seconds()\n    util.logger.info(\n        f\"job {job_id}: extract exif data: {path}, elapsed: {elapsed}\"\n    )\n\n    photo._extract_date_time_from_exif(True)\n    elapsed = (datetime.datetime.now() - start).total_seconds()\n    util.logger.info(\n        f\"job {job_id}: extract date time: {path}, elapsed: {elapsed}\"\n    )\n    \n    thumbnail._get_dominant_color()\n    elapsed = (datetime.datetime.now() - start).total_seconds()\n    util.logger.info(\n        f\"job {job_id}: get dominant color: {path}, elapsed: {elapsed}\"\n    )\n    \n    search_instance, created = PhotoSearch.objects.get_or_create(photo=photo)\n    search_instance.recreate_search_captions()\n    search_instance.save()\n    elapsed = (datetime.datetime.now() - start).total_seconds()\n    util.logger.info(\n        f\"job {job_id}: search caption recreated: {path}, elapsed: {elapsed}\"\n    )\n"
  },
  {
    "path": "api/directory_watcher/processing_jobs.py",
    "content": "\"\"\"\nPhoto processing jobs (tags, geolocation, faces).\n\nThese jobs run after the main scan to enrich photos with additional\nmetadata like location information, image tags, and face detection.\n\"\"\"\n\nimport traceback\nimport uuid\nfrom uuid import UUID\n\nfrom django import db\nfrom django.db.models import Q\nfrom django_q.tasks import AsyncTask\n\nfrom api import util\nfrom api.face_classify import cluster_all_faces\nfrom api.models import Face, LongRunningJob, Photo\nfrom api.models.photo_caption import PhotoCaption\nfrom api.directory_watcher.utils import update_scan_counter\n\n\ndef generate_face_embeddings(user, job_id: UUID):\n    \"\"\"\n    Generate face embeddings for faces that don't have them yet.\n    \n    Args:\n        user: The user whose faces to process\n        job_id: Job ID for tracking progress\n    \"\"\"\n    if Face.objects.filter(encoding=\"\").count() == 0:\n        return\n    \n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_GENERATE_FACE_EMBEDDINGS,\n        job_id=job_id,\n    )\n\n    try:\n        faces = Face.objects.filter(encoding=\"\")\n        lrj.update_progress(current=0, target=faces.count())\n        db.connections.close_all()\n\n        for face in faces:\n            failed = False\n            error = None\n            try:\n                face.generate_encoding()\n            except Exception as err:\n                util.logger.exception(\"An error occurred: \")\n                print(f\"[ERR]: {err}\")\n                failed = True\n                error_msg = f\"Face {face.id}: {str(err)}\\n{traceback.format_exc()}\"\n                error = error_msg\n            update_scan_counter(job_id, failed, error)\n\n        lrj.complete()\n\n    except Exception as err:\n        util.logger.exception(\"An error occurred: \")\n        print(f\"[ERR]: {err}\")\n        lrj.fail(error=err)\n\n\ndef generate_tags(user, job_id: UUID, full_scan=False):\n    \"\"\"\n    Generate image tags (Places365 captions) for photos.\n    \n    Args:\n        user: The user whose photos to process\n        job_id: Job ID for tracking progress\n        full_scan: If True, process all photos; otherwise only new ones\n    \"\"\"\n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_GENERATE_TAGS,\n        job_id=job_id,\n    )\n\n    try:\n        last_scan = (\n            LongRunningJob.objects.filter(finished=True)\n            .filter(job_type=LongRunningJob.JOB_GENERATE_TAGS)\n            .filter(started_by=user)\n            .order_by(\"-finished_at\")\n            .first()\n        )\n        from constance import config as site_config\n\n        tagging_model = site_config.TAGGING_MODEL\n\n        existing_photos = Photo.objects.filter(\n            Q(owner=user.id)\n            & (\n                Q(caption_instance__isnull=True)\n                | Q(caption_instance__captions_json__isnull=True)\n                | Q(**{f\"caption_instance__captions_json__{tagging_model}__isnull\": True})\n            )\n        )\n        if not full_scan and last_scan:\n            existing_photos = existing_photos.filter(added_on__gt=last_scan.started_at)\n\n        if existing_photos.count() == 0:\n            lrj.update_progress(current=0, target=0)\n            lrj.complete()\n            return\n        lrj.update_progress(current=0, target=existing_photos.count())\n        db.connections.close_all()\n\n        for photo in existing_photos:\n            AsyncTask(generate_tag_job, photo, job_id).run()\n\n    except Exception as err:\n        util.logger.exception(\"An error occurred: \")\n        print(f\"[ERR]: {err}\")\n        lrj.fail(error=err)\n\n\ndef generate_tag_job(photo: Photo, job_id: str):\n    \"\"\"\n    Worker task to generate tags for a single photo.\n    \n    Args:\n        photo: The photo to process\n        job_id: Job ID for tracking progress\n    \"\"\"\n    failed = False\n    error = None\n    try:\n        photo.refresh_from_db()\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n        caption_instance.generate_tag_captions(commit=True)\n    except Exception as err:\n        util.logger.exception(\"An error occurred: %s\", photo.image_hash)\n        print(f\"[ERR]: {err}\")\n        failed = True\n        error_msg = f\"Photo {photo.image_hash}: {str(err)}\\n{traceback.format_exc()}\"\n        error = error_msg\n    update_scan_counter(job_id, failed, error)\n\n\ndef add_geolocation(user, job_id: UUID, full_scan=False):\n    \"\"\"\n    Add geolocation data to photos based on GPS coordinates.\n    \n    Args:\n        user: The user whose photos to process\n        job_id: Job ID for tracking progress\n        full_scan: If True, process all photos; otherwise only new ones\n    \"\"\"\n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_ADD_GEOLOCATION,\n        job_id=job_id,\n    )\n\n    try:\n        last_scan = (\n            LongRunningJob.objects.filter(finished=True)\n            .filter(job_type=LongRunningJob.JOB_ADD_GEOLOCATION)\n            .filter(started_by=user)\n            .order_by(\"-finished_at\")\n            .first()\n        )\n        existing_photos = Photo.objects.filter(owner=user.id)\n        if not full_scan and last_scan:\n            existing_photos = existing_photos.filter(added_on__gt=last_scan.started_at)\n        if existing_photos.count() == 0:\n            lrj.update_progress(current=0, target=0)\n            lrj.complete()\n            return\n        lrj.update_progress(current=0, target=existing_photos.count())\n        db.connections.close_all()\n\n        for photo in existing_photos:\n            AsyncTask(geolocation_job, photo, job_id).run()\n\n    except Exception as err:\n        util.logger.exception(\"An error occurred: \")\n        print(f\"[ERR]: {err}\")\n        lrj.fail(error=err)\n\n\ndef geolocation_job(photo: Photo, job_id: UUID):\n    \"\"\"\n    Worker task to add geolocation for a single photo.\n    \n    Args:\n        photo: The photo to process\n        job_id: Job ID for tracking progress\n    \"\"\"\n    failed = False\n    error = None\n    try:\n        photo.refresh_from_db()\n        photo._geolocate()\n        photo._add_location_to_album_dates()\n    except Exception as err:\n        util.logger.exception(\"An error occurred: \")\n        failed = True\n        error_msg = f\"Photo {photo.image_hash}: {str(err)}\\n{traceback.format_exc()}\"\n        error = error_msg\n    update_scan_counter(job_id, failed, error)\n\n\ndef scan_faces(user, job_id: UUID, full_scan=False):\n    \"\"\"\n    Detect and extract faces from photos.\n    \n    Args:\n        user: The user whose photos to process\n        job_id: Job ID for tracking progress\n        full_scan: If True, process all photos; otherwise only new ones\n    \"\"\"\n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_SCAN_FACES,\n        job_id=job_id,\n    )\n\n    try:\n        last_scan = (\n            LongRunningJob.objects.filter(finished=True)\n            .filter(job_type=LongRunningJob.JOB_SCAN_FACES)\n            .filter(started_by=user)\n            .order_by(\"-finished_at\")\n            .first()\n        )\n        existing_photos = Photo.objects.filter(\n            Q(owner=user.id) & Q(thumbnail__thumbnail_big__isnull=False)\n        )\n        if not full_scan and last_scan:\n            existing_photos = existing_photos.filter(added_on__gt=last_scan.started_at)\n\n        if existing_photos.count() == 0:\n            lrj.update_progress(current=0, target=0)\n            lrj.complete()\n            return\n\n        lrj.update_progress(current=0, target=existing_photos.count())\n        db.connections.close_all()\n\n        for photo in existing_photos:\n            failed = False\n            error = None\n            try:\n                photo._extract_faces()\n            except Exception as err:\n                util.logger.exception(\"An error occurred: \")\n                print(f\"[ERR]: {err}\")\n                failed = True\n                error_msg = f\"Photo {photo.image_hash}: {str(err)}\\n{traceback.format_exc()}\"\n                error = error_msg\n            update_scan_counter(job_id, failed, error)\n    except Exception as err:\n        util.logger.exception(\"An error occurred: \")\n        print(f\"[ERR]: {err}\")\n        lrj.fail(error=err)\n\n    generate_face_embeddings(user, uuid.uuid4())\n    cluster_all_faces(user, uuid.uuid4())\n"
  },
  {
    "path": "api/directory_watcher/repair_jobs.py",
    "content": "\"\"\"\nRepair jobs for fixing ungrouped file variants.\n\nThis module contains jobs that repair data inconsistencies, such as\nRAW files that weren't properly grouped with their JPEG counterparts\ndue to race conditions in previous scans.\n\"\"\"\n\nfrom uuid import UUID\n\nfrom api import util\nfrom api.models import File, LongRunningJob, Photo\nfrom api.directory_watcher.file_grouping import find_matching_jpeg_photo\n\n\ndef repair_ungrouped_file_variants(user, job_id: UUID):\n    \"\"\"\n    Post-scan job to fix any ungrouped file variants.\n    \n    This handles:\n    1. Race conditions from previous scans where RAW+JPEG weren't grouped\n    2. Rescans where files were added incrementally\n    3. Any orphaned RAW/metadata files that should be attached to existing Photos\n    \n    Strategy: Find Photos with RAW-only main_file, look for matching JPEG Photos,\n    merge the RAW file into the JPEG Photo and delete the RAW-only Photo.\n    \n    Args:\n        user: The user whose photos to repair\n        job_id: Job ID for tracking progress\n    \"\"\"\n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_REPAIR_FILE_VARIANTS,\n        job_id=job_id,\n    )\n    \n    try:\n        # Find Photos where main_file is RAW (potential orphans)\n        raw_only_photos = Photo.objects.filter(\n            owner=user,\n            main_file__type=File.RAW_FILE\n        )\n        \n        lrj.update_progress(current=0, target=raw_only_photos.count())\n        \n        merged_count = 0\n        fixed_main_file_count = 0\n        \n        for raw_photo in raw_only_photos:\n            if not raw_photo.main_file:\n                continue\n                \n            # Check if this RAW photo has any IMAGE files (already grouped)\n            has_image = raw_photo.files.filter(type=File.IMAGE).exists()\n            if has_image:\n                # Already properly grouped, just fix main_file priority\n                image_file = raw_photo.files.filter(type=File.IMAGE).first()\n                if image_file:\n                    raw_photo.main_file = image_file\n                    raw_photo.video = False\n                    raw_photo.save(update_fields=['main_file', 'video'])\n                    fixed_main_file_count += 1\n                continue\n            \n            # Look for matching JPEG Photo\n            jpeg_photo = find_matching_jpeg_photo(raw_photo.main_file.path, user)\n            if jpeg_photo and jpeg_photo.id != raw_photo.id:\n                # Merge: move all files from RAW photo to JPEG photo\n                for f in raw_photo.files.all():\n                    if not jpeg_photo.files.filter(hash=f.hash).exists():\n                        jpeg_photo.files.add(f)\n                \n                jpeg_photo.save()\n                \n                # Delete the orphaned RAW-only photo\n                raw_photo.delete()\n                merged_count += 1\n                util.logger.info(\n                    f\"job {job_id}: Merged RAW photo into JPEG photo {jpeg_photo.image_hash}\"\n                )\n        \n        util.logger.info(\n            f\"job {job_id}: Repaired {merged_count} ungrouped file variants, \"\n            f\"fixed {fixed_main_file_count} main_file priorities\"\n        )\n        lrj.complete()\n        \n    except Exception as e:\n        util.logger.exception(f\"job {job_id}: Error repairing file variants: {e}\")\n        lrj.fail(error=e)\n"
  },
  {
    "path": "api/directory_watcher/scan_jobs.py",
    "content": "\"\"\"\nMain scan jobs for photo discovery and processing.\n\nThis module contains the core scan_photos function that implements the\ntwo-phase scan architecture to avoid race conditions with RAW+JPEG grouping.\n\"\"\"\n\nimport datetime\nimport os\nimport uuid\nfrom collections import defaultdict\nfrom uuid import UUID\n\nimport pytz\nfrom django import db\nfrom django.conf import settings\nfrom django.core.paginator import Paginator\nfrom django.db.models import F, Q\nfrom django.utils import timezone\nfrom django_q.tasks import AsyncTask, Chain\n\nfrom api import util\nfrom api.metadata.reader import get_sidecar_files_in_priority_order\nfrom api.batch_jobs import batch_calculate_clip_embedding\nfrom api.models import LongRunningJob, Photo, Thumbnail\nfrom api.models.file import is_metadata\n\nfrom api.directory_watcher.file_grouping import get_file_grouping_key\nfrom api.directory_watcher.file_handlers import handle_new_image, handle_file_group\nfrom api.directory_watcher.processing_jobs import (\n    generate_tags,\n    add_geolocation,\n    scan_faces,\n)\nfrom api.directory_watcher.repair_jobs import repair_ungrouped_file_variants\nfrom api.directory_watcher.utils import (\n    walk_directory,\n    walk_files,\n    update_scan_counter,\n)\n\n\ndef _file_was_modified_after(filepath, time):\n    \"\"\"Check if a file was modified after a given time.\"\"\"\n    try:\n        modified = os.path.getmtime(filepath)\n    except OSError:\n        return False\n    return datetime.datetime.fromtimestamp(modified).replace(tzinfo=pytz.utc) > time\n\n\ndef wait_for_group_and_process_metadata(\n    group_id: str,\n    metadata_paths: list[str],\n    user_id: int,\n    full_scan: bool,\n    job_id: UUID | str,\n    expected_count: int,\n    *,\n    attempt: int = 1,\n    max_attempts: int = 2,\n    **kwargs,  # Django-Q may pass additional arguments like 'schedule'\n):\n    \"\"\"\n    Sentinel task: waits until the expected number of image/video tasks in the group complete,\n    then processes metadata files. It runs inside a django-q worker (non-blocking for the caller).\n\n    Failure handling:\n    - If the group is not complete yet, it will re-enqueue itself up to `max_attempts`.\n    - After exhausting attempts, it proceeds with metadata processing anyway (best-effort).\n    \"\"\"\n    from django_q.tasks import count_group\n    from django.contrib.auth import get_user_model\n\n    util.logger.info(\n        f\"Sentinel attempt {attempt}/{max_attempts} for group {group_id} (expecting {expected_count} tasks)\"\n    )\n\n    # Check current completion count for the group\n    try:\n        completed = count_group(group_id)  # counts successes by default\n    except Exception as e:\n        util.logger.warning(\n            f\"Could not read group status for {group_id}: {e}. Treating as incomplete.\"\n        )\n        completed = 0\n\n    # Normalize to an int to avoid None-related type issues\n    completed_int = int(completed or 0)\n\n    if completed_int < expected_count and attempt < max_attempts:\n        util.logger.info(\n            f\"Group {group_id} not complete yet: {completed_int}/{expected_count}. Re-enqueue sentinel (attempt {attempt + 1}).\"\n        )\n        # Requeue the sentinel to check again later\n        AsyncTask(\n            wait_for_group_and_process_metadata,\n            group_id,\n            metadata_paths,\n            user_id,\n            full_scan,\n            job_id,\n            expected_count,\n            attempt=attempt + 1,\n            max_attempts=max_attempts,\n            schedule=datetime.timedelta(seconds=5),\n        ).run()\n        return\n\n    # Proceed with metadata processing (either completed or after exhausting attempts)\n    if completed_int < expected_count:\n        util.logger.warning(\n            f\"Proceeding with metadata despite incomplete image group {group_id}: {completed_int}/{expected_count}.\"\n        )\n    else:\n        util.logger.info(\n            f\"Image group {group_id} completed. Processing {len(metadata_paths)} metadata files\"\n        )\n\n    if not metadata_paths:\n        util.logger.info(\"No metadata files to process after images completion\")\n        return\n\n    User = get_user_model()\n    try:\n        user = User.objects.get(id=user_id)\n    except User.DoesNotExist:\n        util.logger.warning(\n            f\"User {user_id} not found when processing metadata for job {job_id}\"\n        )\n        return\n\n    last_scan = (\n        LongRunningJob.objects.filter(finished=True)\n        .filter(job_type=LongRunningJob.JOB_SCAN_PHOTOS)\n        .filter(started_by=user)\n        .order_by(\"-finished_at\")\n        .first()\n    )\n\n    for path in metadata_paths:\n        try:\n            photo_scanner(user, last_scan, full_scan, path, job_id)\n        except Exception as e:\n            util.logger.exception(\n                f\"Failed processing metadata {path} for job {job_id}: {e}\"\n            )\n\n\ndef photo_scanner(user, last_scan, full_scan, path, job_id):\n    \"\"\"\n    Check if a single file needs processing and queue it.\n\n    Used primarily for metadata files after the main scan.\n    \"\"\"\n    files_to_check = [path]\n    files_to_check.extend(get_sidecar_files_in_priority_order(path))\n    if (\n        not Photo.objects.filter(files__path=path).exists()\n        or full_scan\n        or not last_scan\n        or any(\n            [_file_was_modified_after(p, last_scan.finished_at) for p in files_to_check]\n        )\n    ):\n        # Queue processing for this file. Metadata is queued here without grouping on purpose,\n        # because grouping is managed at the higher-level scan phase to ensure images complete first.\n        AsyncTask(handle_new_image, user, path, job_id).run()\n    else:\n        update_scan_counter(job_id)\n\n\ndef scan_photos(user, full_scan, job_id, scan_directory=\"\", scan_files=[]):\n    \"\"\"\n    Two-phase scan to avoid race conditions with RAW+JPEG grouping.\n\n    Phase 1: Collect all files and group by (directory, basename)\n             - IMG_001.jpg, IMG_001.CR2, IMG_001.xmp -> one group\n             - IMG_002.jpg -> separate group\n\n    Phase 2: Process each group sequentially, creating one Photo per group\n             with all file variants attached.\n\n    This eliminates the race condition where concurrent processing of\n    RAW and JPEG files could create separate Photos.\n\n    Args:\n        user: The user performing the scan\n        full_scan: If True, rescan all files; otherwise only new/modified\n        job_id: Job ID for tracking progress\n        scan_directory: Directory to scan (defaults to user's scan_directory)\n        scan_files: Optional list of specific files to scan\n    \"\"\"\n    thumbnail_dirs = [\n        os.path.join(settings.MEDIA_ROOT, \"square_thumbnails_small\"),\n        os.path.join(settings.MEDIA_ROOT, \"square_thumbnails\"),\n        os.path.join(settings.MEDIA_ROOT, \"thumbnails_big\"),\n    ]\n    for directory in thumbnail_dirs:\n        os.makedirs(directory, exist_ok=True)\n\n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_SCAN_PHOTOS,\n        job_id=job_id,\n    )\n    photo_count_before = Photo.objects.count()\n\n    try:\n        if scan_directory == \"\":\n            scan_directory = user.scan_directory\n        photo_list = []\n        if scan_files:\n            walk_files(scan_files, photo_list)\n        else:\n            walk_directory(scan_directory, photo_list)\n        files_found = len(photo_list)\n        last_scan = (\n            LongRunningJob.objects.filter(finished=True)\n            .filter(job_type=1)\n            .filter(started_by=user)\n            .order_by(\"-finished_at\")\n            .first()\n        )\n\n        # === PHASE 1: Group files by (directory, basename) ===\n        # This ensures RAW+JPEG pairs are processed together, eliminating race conditions\n        file_groups: dict[tuple[str, str], list[str]] = defaultdict(list)\n        metadata_paths: list[str] = []\n\n        for path in photo_list:\n            if is_metadata(path):\n                # Metadata files are processed after their parent photos exist\n                metadata_paths.append(path)\n            else:\n                # Group by (directory, basename_lowercase)\n                group_key = get_file_grouping_key(path)\n                file_groups[group_key].append(path)\n\n        # Determine which groups need processing\n        groups_to_process: list[tuple[tuple[str, str], list[str]]] = []\n\n        for group_key, paths in file_groups.items():\n            # Check if any file in this group needs processing\n            needs_processing = False\n\n            for path in paths:\n                files_to_check = [path]\n                files_to_check.extend(get_sidecar_files_in_priority_order(path))\n\n                if (\n                    not Photo.objects.filter(files__path=path).exists()\n                    or full_scan\n                    or not last_scan\n                    or any(\n                        [\n                            _file_was_modified_after(p, last_scan.finished_at)\n                            for p in files_to_check\n                        ]\n                    )\n                ):\n                    needs_processing = True\n                    break\n\n            if needs_processing:\n                groups_to_process.append((group_key, paths))\n\n        # Progress target is number of groups (not individual files)\n        # Each group = one Photo with potentially multiple file variants\n        total_groups = len(groups_to_process) + len(metadata_paths)\n        lrj.update_progress(current=0, target=total_groups)\n        db.connections.close_all()\n\n        util.logger.info(\n            f\"Grouped {files_found} files into {len(file_groups)} groups, {len(groups_to_process)} need processing\"\n        )\n\n        # === PHASE 2: Process each file group ===\n        # Process groups sequentially to avoid race conditions\n        # Each group creates one Photo with all file variants\n        image_group_id = str(uuid.uuid4())\n\n        for group_key, paths in groups_to_process:\n            AsyncTask(\n                handle_file_group,\n                user,\n                paths,\n                job_id,\n                group=image_group_id,\n            ).run()\n\n        # If there are only metadata files (no image groups queued), process metadata now\n        if not groups_to_process and metadata_paths:\n            util.logger.info(\n                f\"No images to process, processing {len(metadata_paths)} metadata files directly\"\n            )\n            for path in metadata_paths:\n                photo_scanner(user, last_scan, full_scan, path, job_id)\n\n        # If there are images and metadata, enqueue a sentinel task that waits for the image group\n        if groups_to_process and metadata_paths:\n            util.logger.info(\n                f\"Scheduling sentinel to process {len(metadata_paths)} metadata files after {len(groups_to_process)} image groups\"\n            )\n            AsyncTask(\n                wait_for_group_and_process_metadata,\n                image_group_id,\n                metadata_paths,\n                user.id,\n                full_scan,\n                job_id,\n                len(groups_to_process),\n                attempt=1,\n                max_attempts=2,\n            ).run()\n\n        util.logger.info(f\"Scanned {files_found} files in : {scan_directory}\")\n\n        # If no files were queued for processing (empty directory or all files already processed),\n        # mark the job as finished immediately since progress_current will equal progress_target (both 0)\n        LongRunningJob.objects.filter(\n            job_id=job_id, progress_current=F(\"progress_target\")\n        ).update(finished=True, finished_at=timezone.now())\n\n        util.logger.info(\"Finished updating album things\")\n\n        # Check for photos with missing aspect ratios but existing thumbnails\n        photos_with_missing_aspect_ratio = Photo.objects.filter(\n            Q(owner=user.id)\n            & Q(thumbnail__isnull=False)\n            & Q(thumbnail__thumbnail_big__isnull=False)\n            & Q(thumbnail__aspect_ratio__isnull=True)\n        )\n        if photos_with_missing_aspect_ratio.exists():\n            util.logger.info(\n                f\"Found {photos_with_missing_aspect_ratio.count()} photos with missing aspect ratios\"\n            )\n            for photo in photos_with_missing_aspect_ratio:\n                try:\n                    thumbnail = getattr(photo, \"thumbnail\", None)\n                    if thumbnail and isinstance(thumbnail, Thumbnail):\n                        thumbnail._calculate_aspect_ratio()\n                except Exception as e:\n                    util.logger.exception(\n                        f\"Could not calculate aspect ratio for photo {photo.image_hash}: {str(e)}\"\n                    )\n\n        # if the scan type is not the default user scan directory, or if it is specified as only scanning\n        # specific files, there is no need to rescan fully for missing photos.\n        if full_scan or (scan_directory == user.scan_directory and not scan_files):\n            AsyncTask(scan_missing_photos, user, uuid.uuid4()).run()\n\n        # Run repair job to fix any previously ungrouped file variants\n        # This handles race conditions from previous scans and incremental adds\n        AsyncTask(repair_ungrouped_file_variants, user, uuid.uuid4()).run()\n\n        AsyncTask(generate_tags, user, uuid.uuid4(), full_scan).run()\n        AsyncTask(add_geolocation, user, uuid.uuid4(), full_scan).run()\n\n        # The scan faces job will have issues if the embeddings haven't been generated before it runs\n        chain = Chain()\n        chain.append(batch_calculate_clip_embedding, user)\n        chain.append(scan_faces, user, uuid.uuid4(), full_scan)\n        chain.run()\n\n    except Exception as e:\n        util.logger.exception(\"An error occurred: \")\n        lrj.fail(error=e)\n\n    added_photo_count = Photo.objects.count() - photo_count_before\n    util.logger.info(f\"Added {added_photo_count} photos\")\n\n\ndef scan_missing_photos(user, job_id: UUID):\n    \"\"\"\n    Scan for photos whose files no longer exist on disk.\n\n    Args:\n        user: The user whose photos to check\n        job_id: Job ID for tracking progress\n    \"\"\"\n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_SCAN_MISSING_PHOTOS,\n        job_id=job_id,\n    )\n    try:\n        existing_photos = Photo.objects.filter(owner=user.id).order_by(\"image_hash\")\n\n        paginator = Paginator(existing_photos, 5000)\n        lrj.update_progress(current=0, target=paginator.num_pages)\n        for page in range(1, paginator.num_pages + 1):\n            for existing_photo in paginator.page(page).object_list:\n                existing_photo._check_files()\n\n            update_scan_counter(job_id)\n\n        util.logger.info(\"Finished checking paths for missing photos\")\n    except Exception as e:\n        util.logger.exception(\"An error occurred: \")\n        lrj.fail(error=e)\n"
  },
  {
    "path": "api/directory_watcher/utils.py",
    "content": "\"\"\"\nUtility functions for directory scanning and job management.\n\"\"\"\n\nimport os\nimport stat\n\nfrom constance import config as site_config\nfrom django.db.models import F\nfrom django.utils import timezone\n\nfrom api.models import LongRunningJob\n\n\ndef should_skip(path):\n    \"\"\"Check if a path should be skipped based on configured patterns.\"\"\"\n    if not site_config.SKIP_PATTERNS:\n        return False\n\n    skip_patterns = site_config.SKIP_PATTERNS\n    skip_list = skip_patterns.split(\",\")\n    skip_list = map(str.strip, skip_list)\n\n    res = [ele for ele in skip_list if (ele in path)]\n    return bool(res)\n\n\nif os.name == \"Windows\":\n\n    def is_hidden(path):\n        \"\"\"Check if a file is hidden (Windows version).\"\"\"\n        name = os.path.basename(os.path.abspath(path))\n        return name.startswith(\".\") or _has_hidden_attribute(path)\n\n    def _has_hidden_attribute(path):\n        \"\"\"Check if file has Windows hidden attribute.\"\"\"\n        try:\n            return bool(os.stat(path).st_file_attributes & stat.FILE_ATTRIBUTE_HIDDEN)\n        except Exception:\n            return False\n\nelse:\n\n    def is_hidden(path):\n        \"\"\"Check if a file is hidden (Unix version - starts with dot).\"\"\"\n        return os.path.basename(path).startswith(\".\")\n\n\ndef walk_directory(directory, callback):\n    \"\"\"\n    Recursively walk a directory and collect file paths.\n    \n    Args:\n        directory: Directory to scan\n        callback: List to append file paths to\n    \"\"\"\n    for file in os.scandir(directory):\n        fpath = os.path.join(directory, file)\n        if not is_hidden(fpath) and not should_skip(fpath):\n            if os.path.isdir(fpath):\n                walk_directory(fpath, callback)\n            else:\n                callback.append(fpath)\n\n\ndef walk_files(scan_files, callback):\n    \"\"\"\n    Walk a list of specific files.\n    \n    Args:\n        scan_files: List of file paths to check\n        callback: List to append valid file paths to\n    \"\"\"\n    for fpath in scan_files:\n        if os.path.isfile(fpath):\n            callback.append(fpath)\n\n\ndef update_scan_counter(job_id, failed=False, error=None):\n    \"\"\"\n    Update the progress counter for a long-running job.\n    \n    Increments progress_current and marks job as finished when complete.\n    Also tracks errors for failed items.\n    \n    Args:\n        job_id: The job ID to update\n        failed: Whether this item failed processing\n        error: Error message if failed\n    \"\"\"\n    # Increment the current progress and get the updated job\n    LongRunningJob.objects.filter(job_id=job_id).update(\n        progress_current=F(\"progress_current\") + 1\n    )\n    \n    # Refetch the job to get the updated progress_current value\n    job = LongRunningJob.objects.filter(job_id=job_id).first()\n    if not job:\n        return\n\n    # Mark the job as finished if the current progress equals the target\n    if job.progress_current >= job.progress_target:\n        # Job is finishing, update result with errors if any\n        result = job.result or {}\n        if failed or error:\n            result[\"status\"] = \"failed\"\n            if \"errors\" not in result:\n                result[\"errors\"] = []\n            if error:\n                error_str = str(error)\n                # Avoid duplicate errors\n                if error_str not in result[\"errors\"]:\n                    result[\"errors\"].append(error_str)\n            # Set main error field for backward compatibility\n            if \"error\" not in result and error:\n                result[\"error\"] = str(error)\n            elif \"error\" not in result and result.get(\"errors\"):\n                result[\"error\"] = result[\"errors\"][0]  # Use first error as main error\n        job.finished = True\n        job.finished_at = timezone.now()\n        if failed:\n            job.failed = True\n        job.result = result\n        job.save(update_fields=[\"finished\", \"finished_at\", \"failed\", \"result\"])\n    else:\n        # Job is still running, accumulate errors in result\n        if failed or error:\n            job = LongRunningJob.objects.filter(job_id=job_id).first()\n            if job:\n                result = job.result or {}\n                result[\"status\"] = \"partial_failure\" if not job.finished else \"failed\"\n                if \"errors\" not in result:\n                    result[\"errors\"] = []\n                if error:\n                    error_str = str(error)\n                    # Avoid duplicate errors (limit to last 100 to prevent unbounded growth)\n                    if error_str not in result[\"errors\"]:\n                        result[\"errors\"].append(error_str)\n                        if len(result[\"errors\"]) > 100:\n                            result[\"errors\"] = result[\"errors\"][-100:]  # Keep last 100 errors\n                # Set main error field for backward compatibility\n                if \"error\" not in result and error:\n                    result[\"error\"] = str(error)\n                elif \"error\" not in result and result.get(\"errors\"):\n                    result[\"error\"] = result[\"errors\"][0]  # Use first error as main error\n                job.result = result\n                job.failed = failed or job.failed\n                job.save(update_fields=[\"failed\", \"result\"])\n"
  },
  {
    "path": "api/drf_optimize.py",
    "content": "from django.db import ProgrammingError, models\nfrom django.db.models.constants import LOOKUP_SEP\nfrom django.db.models.query import normalize_prefetch_lookups\nfrom rest_framework import serializers\nfrom rest_framework.utils import model_meta\n\n\nclass OptimizeRelatedModelViewSetMetaclass(type):\n    \"\"\"This metaclass optimizes the queryset using `prefetch_related` and `select_related`.\n\n    Any attribute of `_base_forward_rel` as attributes on either the class or on\n    any of its superclasses will be include in the `base_forward_rel`\n    they must be ForeignKey fields and only those will be included.\n\n    If the `serializer_class` attribute is an instance of `serializers.ModelSerializer`\n    included as an attribute on the class the `serializers.ModelSerializer.Meta.fields`\n    is also added calling prefetch_related on Many-To-One and Many-To-Many related objects.\n    \"\"\"\n\n    @classmethod\n    def get_many_to_many_rel(cls, info, meta_fields):\n        many_to_many_fields = [\n            field_name\n            for field_name, relation_info in info.relations.items()\n            if relation_info.to_many\n        ]\n        many_to_many_lookups = []\n        for lookup_name, lookup in cls.get_lookups(meta_fields):\n            if lookup_name in many_to_many_fields:\n                many_to_many_lookups.append(lookup)\n\n        return many_to_many_lookups\n\n    @classmethod\n    def get_lookups(cls, fields, strict=False):\n        field_lookups = [(lookup.split(LOOKUP_SEP, 1)[0], lookup) for lookup in fields]\n        if strict:\n            field_lookups = [f for f in field_lookups if LOOKUP_SEP in f[1]]\n        return field_lookups\n\n    @classmethod\n    def get_many_to_one_rel(cls, info, meta_fields):\n        try:\n            fields = [\n                field_name\n                for field_name, relation_info in info.forward_relations.items()\n                if issubclass(type(relation_info[0]), models.ForeignKey)\n            ]\n        except IndexError:\n            pass\n        else:\n            if fields:\n                forward_many_to_many_rel = []\n                for lookup_name, lookup in cls.get_lookups(meta_fields, strict=True):\n                    if lookup_name in fields:\n                        forward_many_to_many_rel.append(lookup)\n                return forward_many_to_many_rel\n        return []\n\n    @classmethod\n    def get_forward_rel(cls, info, meta_fields):\n        return [\n            field_name\n            for field_name, relation_info in info.forward_relations.items()\n            if field_name in meta_fields and not relation_info.to_many\n        ]\n\n    def __new__(cls, name, bases, attrs):\n        serializer_class = attrs.get(\"serializer_class\", None)\n        many_to_many_fields = many_to_one_fields = related_fields = []\n\n        info = None\n        base_forward_rel = list(attrs.pop(\"_base_forward_rel\", ()))\n\n        for base in reversed(bases):\n            if hasattr(base, \"_base_forward_rel\"):\n                base_forward_rel.extend(list(base._base_forward_rel))\n        if serializer_class and issubclass(\n            serializer_class, serializers.ModelSerializer\n        ):\n            base_forward_rel.extend(\n                list(getattr(serializer_class, \"_related_fields\", [])),\n            )\n            many_to_many_fields.extend(\n                list(getattr(serializer_class, \"_many_to_many_fields\", [])),\n            )\n            many_to_one_fields.extend(\n                list(getattr(serializer_class, \"_many_to_one_fields\", [])),\n            )\n            if hasattr(serializer_class.Meta, \"model\"):\n                info = model_meta.get_field_info(serializer_class.Meta.model)\n                meta_fields = list(serializer_class.Meta.fields)\n                many_to_many_fields.extend(meta_fields)\n                many_to_one_fields.extend(meta_fields)\n                base_forward_rel.extend(meta_fields)\n\n        if info is not None:\n            many_to_many_fields = cls.get_many_to_many_rel(\n                info, set(many_to_many_fields)\n            )\n            many_to_one_fields = cls.get_many_to_one_rel(info, set(many_to_one_fields))\n            related_fields = cls.get_forward_rel(info, set(base_forward_rel))\n\n        queryset = attrs.get(\"queryset\", None)\n        try:\n            if queryset:\n                if many_to_many_fields:\n                    queryset = queryset.prefetch_related(\n                        *normalize_prefetch_lookups(\n                            set(many_to_many_fields + many_to_one_fields)\n                        ),\n                    )\n                if related_fields:\n                    queryset = queryset.select_related(*related_fields)\n                attrs[\"queryset\"] = queryset.all()\n        except ProgrammingError:\n            pass\n        return super(OptimizeRelatedModelViewSetMetaclass, cls).__new__(\n            cls, name, bases, attrs\n        )\n"
  },
  {
    "path": "api/duplicate_detection.py",
    "content": "\"\"\"\nDuplicate detection module for finding duplicate photos.\n\nHandles two types of duplicates:\n- EXACT_COPY: Files with identical MD5 hash (byte-for-byte copies)\n- VISUAL_DUPLICATE: Photos with similar perceptual hash\n\nThis is separate from stack detection (RAW+JPEG pairs, bursts, etc.)\nbecause duplicates are about storage cleanup, not photo organization.\n\nOptimized with BK-Tree for efficient visual duplicate detection.\n\nMemory Optimizations (v2):\n- detect_exact_copies: Uses database aggregation (GROUP BY) instead of loading\n  all photos into memory. Only photo IDs are loaded, not full objects.\n- detect_visual_duplicates: Processes photos in configurable batches (default 10k).\n  Uses two-pass algorithm: within-batch BK-Tree search, then cross-batch linear scan.\n  Memory usage: O(batch_size) instead of O(total_photos).\n\nWith 300k photos:\n- Old: ~10GB+ RAM (all photos + files + large BK-Tree)\n- New: ~100-200MB RAM (batch + hash list only)\n\"\"\"\n\nfrom collections import defaultdict\n\nfrom django.db.models import Q\n\nfrom api.models import Photo\nfrom api.models.duplicate import Duplicate\nfrom api.models.file import File\nfrom api.models.long_running_job import LongRunningJob\nfrom api.perceptual_hash import DEFAULT_HAMMING_THRESHOLD, hamming_distance\nfrom api.util import logger\n\n\nclass BKTree:\n    \"\"\"\n    Burkhard-Keller Tree for efficient Hamming distance queries.\n\n    Achieves O(log n) average case by pruning branches using triangle inequality.\n    \"\"\"\n\n    def __init__(self, distance_func):\n        self.distance = distance_func\n        self.root = None\n        self.size = 0\n\n    def add(self, item_id, item_hash):\n        \"\"\"Add an item (id, hash) to the tree.\"\"\"\n        self.size += 1\n        if self.root is None:\n            self.root = {\"id\": item_id, \"hash\": item_hash, \"children\": {}}\n            return\n\n        node = self.root\n        while True:\n            dist = self.distance(item_hash, node[\"hash\"])\n            if dist in node[\"children\"]:\n                node = node[\"children\"][dist]\n            else:\n                node[\"children\"][dist] = {\n                    \"id\": item_id,\n                    \"hash\": item_hash,\n                    \"children\": {},\n                }\n                break\n\n    def search(self, query_hash, threshold):\n        \"\"\"Find all items within threshold Hamming distance of query.\"\"\"\n        if self.root is None:\n            return []\n\n        results = []\n        candidates = [self.root]\n\n        while candidates:\n            node = candidates.pop()\n            dist = self.distance(query_hash, node[\"hash\"])\n\n            if dist <= threshold:\n                results.append((node[\"id\"], dist))\n\n            min_dist = max(0, dist - threshold)\n            max_dist = dist + threshold\n\n            for d, child in node[\"children\"].items():\n                if min_dist <= d <= max_dist:\n                    candidates.append(child)\n\n        return results\n\n\nclass UnionFind:\n    \"\"\"Union-Find with path compression and union by rank.\"\"\"\n\n    def __init__(self):\n        self.parent = {}\n        self.rank = {}\n\n    def find(self, x):\n        if x not in self.parent:\n            self.parent[x] = x\n            self.rank[x] = 0\n            return x\n        if self.parent[x] != x:\n            self.parent[x] = self.find(self.parent[x])\n        return self.parent[x]\n\n    def union(self, x, y):\n        px, py = self.find(x), self.find(y)\n        if px == py:\n            return\n        if self.rank[px] < self.rank[py]:\n            px, py = py, px\n        self.parent[py] = px\n        if self.rank[px] == self.rank[py]:\n            self.rank[px] += 1\n\n    def get_groups(self):\n        groups = defaultdict(list)\n        for item in self.parent:\n            groups[self.find(item)].append(item)\n        return [group for group in groups.values() if len(group) > 1]\n\n\ndef detect_exact_copies(user, progress_callback=None):\n    \"\"\"\n    Detect exact file copies for a user.\n\n    Groups photos that have the same content hash. Uses database aggregation\n    to efficiently find duplicate groups without loading all photos into memory.\n\n    Memory optimized: Uses database GROUP BY instead of Python dictionaries.\n\n    Args:\n        user: The user whose photos to analyze\n        progress_callback: Optional callback(current, total, found) for progress\n\n    Returns:\n        Number of duplicate groups created\n    \"\"\"\n    from django.db.models import Count\n\n    # Method 1: Find duplicate groups by Photo.image_hash using database aggregation\n    # This is memory efficient as we only load photo IDs grouped by hash\n    image_hash_groups = (\n        Photo.objects.filter(\n            Q(owner=user)\n            & Q(hidden=False)\n            & Q(in_trashcan=False)\n            & Q(removed=False)\n            & Q(image_hash__isnull=False)\n        )\n        .values(\"image_hash\")\n        .annotate(count=Count(\"id\"))\n        .filter(count__gt=1)\n        .values_list(\"image_hash\", flat=True)\n    )\n\n    # Method 2: Find duplicate groups by File content hash (MD5 part)\n    # We need to use a SUBSTRING operation to extract the MD5 part\n    # This is done via raw SQL for efficiency\n    from django.db import connection\n\n    file_hash_duplicates = []\n    with connection.cursor() as cursor:\n        # Extract first 32 chars (MD5) from File.hash and find duplicates\n        # Only consider non-metadata files\n        cursor.execute(\n            \"\"\"\n            SELECT SUBSTRING(f.hash, 1, 32) as content_hash\n            FROM api_file f\n            INNER JOIN api_photo_files pf ON pf.file_id = f.hash\n            INNER JOIN api_photo p ON p.id = pf.photo_id\n            WHERE p.owner_id = %s \n                AND p.hidden = FALSE \n                AND p.in_trashcan = FALSE\n                AND p.removed = FALSE\n                AND f.type != %s\n            GROUP BY SUBSTRING(f.hash, 1, 32)\n            HAVING COUNT(DISTINCT p.id) > 1\n        \"\"\",\n            [user.id, File.METADATA_FILE],\n        )\n\n        file_hash_duplicates = [row[0] for row in cursor.fetchall()]\n\n    # Use Union-Find to merge overlapping groups\n    uf = UnionFind()\n\n    # Process image_hash groups\n    # Note: We need to iterate through the queryset, which will load the hashes into memory\n    # But this is much better than loading all photos with files\n    image_hash_list = list(image_hash_groups)  # Load just the hashes\n    total_image_groups = len(image_hash_list)\n\n    for i, image_hash in enumerate(image_hash_list):\n        # Only load photo IDs, not full Photo objects\n        photo_ids = list(\n            Photo.objects.filter(\n                owner=user,\n                image_hash=image_hash,\n                hidden=False,\n                in_trashcan=False,\n                removed=False,\n            ).values_list(\"id\", flat=True)\n        )\n\n        if len(photo_ids) >= 2:\n            first = photo_ids[0]\n            for pid in photo_ids[1:]:\n                uf.union(first, pid)\n\n        if progress_callback and i % 100 == 0:\n            progress_callback(i, total_image_groups * 2, 0)\n\n    # Process file_hash groups\n    for i, content_hash in enumerate(file_hash_duplicates):\n        # Find photos with files matching this content hash\n        photo_ids = list(\n            Photo.objects.filter(\n                owner=user,\n                hidden=False,\n                in_trashcan=False,\n                removed=False,\n                files__hash__startswith=content_hash,\n            )\n            .exclude(files__type=File.METADATA_FILE)\n            .distinct()\n            .values_list(\"id\", flat=True)\n        )\n\n        if len(photo_ids) >= 2:\n            first = photo_ids[0]\n            for pid in photo_ids[1:]:\n                uf.union(first, pid)\n\n        if progress_callback and i % 100 == 0:\n            progress_callback(total_image_groups + i, total_image_groups * 2, 0)\n\n    # Get merged groups from Union-Find\n    merged_groups = uf.get_groups()\n\n    duplicates_created = 0\n    total = len(merged_groups)\n\n    for i, photo_id_group in enumerate(merged_groups):\n        if len(photo_id_group) < 2:\n            continue\n\n        # Get Photo objects for this group\n        group_photos = Photo.objects.filter(id__in=photo_id_group)\n\n        # Create or merge duplicate group using the helper method\n        duplicate = Duplicate.create_or_merge(\n            owner=user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=group_photos,\n        )\n\n        if duplicate:\n            duplicates_created += 1\n\n        if progress_callback and i % 100 == 0:\n            progress_callback(i, total, duplicates_created)\n\n    logger.info(\n        f\"Exact copy detection for {user.username}: found {duplicates_created} duplicate groups\"\n    )\n    return duplicates_created\n\n\ndef detect_visual_duplicates(\n    user, threshold=DEFAULT_HAMMING_THRESHOLD, progress_callback=None, batch_size=10000\n):\n    \"\"\"\n    Detect visually similar photos using perceptual hash.\n\n    Memory optimized: Processes photos in batches to avoid loading all data into memory.\n    Uses a two-pass approach for complete duplicate detection with bounded memory.\n\n    Algorithm:\n    1. First pass: Build BKTree in batches, find within-batch duplicates\n    2. Second pass: Compare each batch against all previous batches using linear scan\n\n    The linear scan in pass 2 is acceptable because:\n    - We only store (id, hash) tuples, not full Photo objects\n    - Hamming distance is very fast to compute\n    - With 300k photos, we have ~300k comparisons per batch, which is fast\n\n    Args:\n        user: The user whose photos to analyze\n        threshold: Hamming distance threshold (default: 10)\n        progress_callback: Optional callback(current, total, found) for progress\n        batch_size: Number of photos to process per batch (default: 10000)\n\n    Returns:\n        Number of duplicate groups created\n    \"\"\"\n    # Get photos with perceptual hash that aren't already in visual duplicate groups\n    # Exclude removed photos to avoid including merged/deleted duplicates\n    photos_queryset = (\n        Photo.objects.filter(\n            Q(owner=user)\n            & Q(hidden=False)\n            & Q(in_trashcan=False)\n            & Q(removed=False)\n            & Q(perceptual_hash__isnull=False)\n        )\n        .exclude(duplicates__duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE)\n        .only(\"id\", \"perceptual_hash\")\n    )\n\n    total = photos_queryset.count()\n    if total < 2:\n        return 0\n\n    logger.info(\n        f\"Processing {total} photos in batches of {batch_size} (user: {user.username})\"\n    )\n\n    # Union-Find for grouping across all batches\n    uf = UnionFind()\n    pairs_found = 0\n    processed = 0\n\n    # Store all photo hashes as (id, hash) tuples for cross-batch comparison\n    # Memory efficient: 300k photos × ~28 bytes = ~8.4MB theoretical\n    # In practice, Python overhead means ~25-40MB for list + objects\n    all_photo_hashes = []\n\n    # Calculate number of batches\n    num_batches = (total + batch_size - 1) // batch_size\n\n    # Pass 1: Process each batch internally and build the complete hash list\n    for batch_idx in range(num_batches):\n        offset = batch_idx * batch_size\n\n        # Get current batch using slicing (memory efficient)\n        batch_photos = list(\n            photos_queryset[offset : offset + batch_size].values(\n                \"id\", \"perceptual_hash\"\n            )\n        )\n\n        if not batch_photos:\n            break\n\n        logger.info(\n            f\"Pass 1: Processing batch {batch_idx + 1}/{num_batches} ({len(batch_photos)} photos)\"\n        )\n\n        # Build temporary BK-Tree for current batch (for efficient within-batch search)\n        batch_tree = BKTree(hamming_distance)\n        batch_hashes = []\n\n        for photo in batch_photos:\n            photo_id = photo[\"id\"]\n            phash = photo[\"perceptual_hash\"]\n\n            if phash:\n                batch_tree.add(photo_id, phash)\n                batch_hashes.append((photo_id, phash))\n\n        # Find duplicates within current batch using BK-Tree\n        for photo_id, phash in batch_hashes:\n            similar = batch_tree.search(phash, threshold)\n\n            for similar_id, distance in similar:\n                if similar_id != photo_id:\n                    uf.union(photo_id, similar_id)\n                    pairs_found += 1\n\n        # Add batch to the complete list for cross-batch comparison\n        all_photo_hashes.extend(batch_hashes)\n\n        processed += len(batch_photos)\n\n        if progress_callback:\n            # Report progress for pass 1 (first 50% of total work)\n            progress_callback(processed // 2, total, pairs_found)\n\n    logger.info(\n        f\"Pass 1 complete. Found {pairs_found} within-batch pairs. Starting cross-batch comparison.\"\n    )\n\n    # Pass 2: Compare each batch against all previous photos (linear scan)\n    # This ensures we don't miss duplicates between distant batches\n    processed = 0\n\n    for batch_idx in range(num_batches):\n        start_idx = batch_idx * batch_size\n        end_idx = min(start_idx + batch_size, len(all_photo_hashes))\n\n        if start_idx >= end_idx:\n            break\n\n        batch_hashes = all_photo_hashes[start_idx:end_idx]\n\n        logger.info(\n            f\"Pass 2: Comparing batch {batch_idx + 1}/{num_batches} against previous photos\"\n        )\n\n        # Compare current batch against all previous photos\n        # Store the previous photos slice once to avoid repeated slicing\n        previous_hashes = all_photo_hashes[:start_idx] if start_idx > 0 else []\n\n        for photo_id, phash in batch_hashes:\n            # Only compare against photos in previous batches (avoid duplicate comparisons)\n            for prev_id, prev_hash in previous_hashes:\n                distance = hamming_distance(phash, prev_hash)\n                if distance <= threshold:\n                    uf.union(photo_id, prev_id)\n                    pairs_found += 1\n\n        processed += len(batch_hashes)\n\n        if progress_callback:\n            # Report progress for pass 2 (second 50% of total work)\n            progress_callback(total // 2 + processed // 2, total, pairs_found)\n\n    logger.info(f\"Pass 2 complete. Total pairs found: {pairs_found}\")\n\n    # Create duplicate groups from Union-Find groups\n    groups = uf.get_groups()\n    duplicates_created = 0\n\n    for group in groups:\n        if len(group) < 2:\n            continue\n\n        # Get Photo objects for this group\n        group_photos = Photo.objects.filter(id__in=group)\n\n        # Create or merge duplicate group\n        duplicate = Duplicate.create_or_merge(\n            owner=user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n            photos=group_photos,\n        )\n\n        if duplicate:\n            duplicates_created += 1\n\n    logger.info(\n        f\"Visual duplicate detection for {user.username}: found {duplicates_created} groups from {pairs_found} pairs\"\n    )\n    return duplicates_created\n\n\ndef batch_detect_duplicates(user, options=None):\n    \"\"\"\n    Run batch duplicate detection for a user.\n\n    Args:\n        user: The user whose photos to analyze\n        options: Dict with detection options:\n            - detect_exact_copies: bool (default: True)\n            - detect_visual_duplicates: bool (default: True)\n            - visual_threshold: int (default: 10)\n            - clear_pending: bool (default: False)\n            - batch_size: int (default: 10000) - photos per batch for visual detection\n    \"\"\"\n    if options is None:\n        options = {}\n\n    detect_exact = options.get(\"detect_exact_copies\", True)\n    detect_visual = options.get(\"detect_visual_duplicates\", True)\n    visual_threshold = options.get(\"visual_threshold\", DEFAULT_HAMMING_THRESHOLD)\n    clear_pending = options.get(\"clear_pending\", False)\n    batch_size = options.get(\"batch_size\", 10000)\n\n    # Create long-running job for progress tracking\n    job = LongRunningJob.create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_DETECT_DUPLICATES,\n        start_now=True,\n    )\n\n    try:\n        # Clear pending duplicates if requested\n        if clear_pending:\n            cleared = Duplicate.objects.filter(\n                owner=user, review_status=Duplicate.ReviewStatus.PENDING\n            ).delete()[0]\n            logger.info(f\"Cleared {cleared} pending duplicates for {user.username}\")\n\n        total_found = 0\n\n        # Detect exact copies\n        if detect_exact:\n\n            def progress_exact(current, total, found):\n                job.set_result(\n                    {\n                        \"stage\": \"exact_copies\",\n                        \"current\": current,\n                        \"total\": total,\n                        \"found\": found,\n                    }\n                )\n\n            exact_count = detect_exact_copies(user, progress_exact)\n            total_found += exact_count\n\n        # Detect visual duplicates\n        if detect_visual:\n\n            def progress_visual(current, total, found):\n                job.set_result(\n                    {\n                        \"stage\": \"visual_duplicates\",\n                        \"current\": current,\n                        \"total\": total,\n                        \"found\": found,\n                    }\n                )\n\n            visual_count = detect_visual_duplicates(\n                user, visual_threshold, progress_visual, batch_size\n            )\n            total_found += visual_count\n\n        job.complete(result={\"status\": \"completed\", \"duplicates_found\": total_found})\n\n        logger.info(\n            f\"Duplicate detection completed for {user.username}: {total_found} groups found\"\n        )\n\n    except Exception as e:\n        logger.error(f\"Duplicate detection failed for {user.username}: {e}\")\n        job.fail(error=e)\n        raise\n"
  },
  {
    "path": "api/face_classify.py",
    "content": "import datetime\nimport uuid\n\nimport numpy as np\nimport seaborn as sns\nfrom bulk_update.helper import bulk_update\nfrom django.core.paginator import Paginator\nfrom django.db.models import Q\nfrom django_q.tasks import AsyncTask\nfrom hdbscan import HDBSCAN\nfrom sklearn.decomposition import PCA\nfrom sklearn.neural_network import MLPClassifier\n\nfrom api.cluster_manager import ClusterManager\nfrom api.models import Face, LongRunningJob, Person\nfrom api.models.cluster import UNKNOWN_CLUSTER_ID, Cluster, get_unknown_cluster\nfrom api.models.user import User, get_deleted_user\nfrom api.util import logger\n\nFACE_CLASSIFY_COLUMNS = [\n    \"person\",\n    \"classification_person\",\n    \"classification_probability\",\n    \"cluster_person\",\n    \"cluster_probability\",\n    \"id\",\n    \"cluster\",\n]\n\n\ndef cluster_faces(user, inferred=True):\n    # Fetch distinct persons associated with the user's faces\n    persons = [p.id for p in Person.objects.filter(faces__photo__owner=user).distinct()]\n\n    # Create a color mapping for each person\n    p2c = dict(zip(persons, sns.color_palette(n_colors=len(persons)).as_hex()))\n\n    face_encoding = []\n    faces_with_encoding = []\n    # Fetch faces that belong to the user and are not deleted\n    faces = Face.objects.filter(Q(photo__owner=user) & Q(deleted=False))\n    paginator = Paginator(faces, 5000)\n\n    for page in range(1, paginator.num_pages + 1):\n        for face in paginator.page(page).object_list:\n            if ((not face.person) or inferred) and face.encoding:\n                face_encoding.append(face.get_encoding_array())\n                faces_with_encoding.append(face)\n\n    # Return empty result if no faces with encodings\n    if len(face_encoding) == 0:\n        return {\"status\": True, \"data\": []}\n\n    # Perform PCA for dimensionality reduction\n    pca = PCA(n_components=3)\n    vis_all = pca.fit_transform(face_encoding)\n\n    res = []\n    for face, vis in zip(faces_with_encoding, vis_all):\n        person_id = face.person.id if face.person else UNKNOWN_CLUSTER_ID\n        person_name = face.person.name if face.person else \"unknown\"\n\n        # Ensure UNKNOWN_CLUSTER_ID is in p2c\n        if person_id not in p2c:\n            # Assign a default color if not found\n            color = \"#000000\"  # Default to black or any fallback color\n        else:\n            color = p2c[person_id]\n\n        res.append(\n            {\n                \"person_id\": person_id,\n                \"person_name\": person_name,\n                \"person_label_is_inferred\": not face.person,\n                \"color\": color,\n                \"face_url\": face.image.url,\n                \"value\": {\"x\": vis[0], \"y\": vis[1], \"size\": vis[2]},\n            }\n        )\n    return {\"status\": True, \"data\": res}\n\n\ndef cluster_all_faces(user, job_id) -> bool:\n    \"\"\"Groups all faces into clusters for ease of labeling. It first deletes all\n    existing clusters, then regenerates them all. It will split clusters that have\n    more than one kind of labeled face.\n    :param user: the current user running the training\n    :param job_id: the background job ID\n    \"\"\"\n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_CLUSTER_ALL_FACES,\n        job_id=job_id,\n    )\n    lrj.update_progress(current=0, target=1)\n\n    try:\n        delete_clustered_people(user)\n        delete_clusters(user)\n        delete_persons_without_faces()\n        target_count: int = create_all_clusters(user, lrj)\n\n        lrj.update_progress(current=target_count, target=target_count)\n        lrj.complete()\n\n        train_job_id = uuid.uuid4()\n        AsyncTask(train_faces, user, train_job_id).run()\n        return True\n\n    except BaseException as err:\n        logger.exception(\"An error occurred\")\n        print(f\"[ERR] {err}\")\n        lrj.fail(error=err)\n        return False\n\n\ndef create_all_clusters(user: User, lrj: LongRunningJob = None) -> int:\n    \"\"\"Generate Cluster records for each different clustering of people\n    :param user: the current user\n    :param lrj: LongRunningJob to update, if needed\n    \"\"\"\n    all_clusters: list[Cluster] = []\n    face: Face\n    logger.info(\"Creating clusters\")\n\n    data = {\n        \"all\": {\"encoding\": [], \"id\": [], \"person_id\": [], \"person_labeled\": []},\n    }\n    for face in Face.objects.filter(photo__owner=user).prefetch_related(\"person\"):\n        data[\"all\"][\"encoding\"].append(face.get_encoding_array())\n        data[\"all\"][\"id\"].append(face.id)\n\n    target_count = len(data[\"all\"][\"id\"])\n    if target_count == 0:\n        return target_count\n\n    min_cluster_size = 2\n    # double cluster size for every 10x increase in target counts, if user has not set a valid min_cluster_size\n    if (\n        user.min_cluster_size == 0\n        or user.min_cluster_size is None\n        or user.min_cluster_size == 1\n    ):\n        if target_count > 1000:\n            min_cluster_size = 4\n        if target_count > 10000:\n            min_cluster_size = 8\n        if target_count > 100000:\n            min_cluster_size = 16\n    else:\n        min_cluster_size = user.min_cluster_size\n\n    min_samples = 1\n    if user.min_samples > 0:\n        min_samples = user.min_samples\n\n    # creating HDBSCAN object for clustering the encodings with the metric \"euclidean\"\n    clt = HDBSCAN(\n        min_cluster_size=min_cluster_size,\n        min_samples=min_samples,\n        cluster_selection_epsilon=user.cluster_selection_epsilon,\n        metric=\"euclidean\",\n    )\n    logger.info(\"Before finding clusters\")\n    # clustering the encodings\n    clt.fit(np.array(data[\"all\"][\"encoding\"]))\n    logger.info(\"After finding clusters\")\n\n    labelIDs = np.unique(clt.labels_)\n    labelID: np.intp\n    commit_time = datetime.datetime.now() + datetime.timedelta(seconds=5)\n    count: int = 0\n    maxLen: int = len(str(np.size(labelIDs)))\n    sortedIndexes: dict[int, np.ndarray] = dict()\n    clusterCount: int = 0\n    clusterId: int\n\n    for labelID in labelIDs:\n        idxs = np.where(clt.labels_ == labelID)[0]\n        sortedIndexes[labelID] = idxs\n\n    logger.info(f\"Found {len(sortedIndexes)} clusters\")\n    for labelID in sorted(\n        sortedIndexes, key=lambda key: np.size(sortedIndexes[key]), reverse=True\n    ):\n        if labelID != UNKNOWN_CLUSTER_ID:\n            clusterCount = clusterCount + 1\n            clusterId = clusterCount\n        else:\n            clusterId = labelID\n        face_array: list[Face] = []\n        face_id_list: list[int] = []\n        for i in sortedIndexes[labelID]:\n            count = count + 1\n            face_id = data[\"all\"][\"id\"][i]\n            face_id_list.append(face_id)\n        face_array = Face.objects.filter(\n            Q(pk__in=face_id_list) & Q(encoding__isnull=False) & Q(deleted=False)\n        )\n        new_clusters: list[Cluster] = ClusterManager.try_add_cluster(\n            user, clusterId, face_array, maxLen\n        )\n\n        if commit_time < datetime.datetime.now() and lrj is not None:\n            lrj.progress_current = count\n            lrj.progress_target = target_count\n            lrj.save()\n            commit_time = datetime.datetime.now() + datetime.timedelta(seconds=5)\n\n        all_clusters.extend(new_clusters)\n\n    print(f\"[INFO] Created {len(all_clusters)} clusters\")\n    return target_count\n\n\ndef delete_persons_without_faces():\n    \"\"\"Delete all existing Person records that have no associated Face records\"\"\"\n    print(\"[INFO] Deleting all people without faces\")\n    Person.objects.filter(faces=None, kind=Person.KIND_USER).delete()\n\n\ndef delete_clusters(user: User):\n    \"\"\"Delete all existing Cluster records\"\"\"\n    print(\"[INFO] Deleting all clusters\")\n    Cluster.objects.filter(Q(owner=user)).delete()\n    Cluster.objects.filter(Q(owner=None)).delete()\n    Cluster.objects.filter(Q(owner=get_deleted_user())).delete()\n\n\ndef delete_clustered_people(user: User):\n    \"\"\"Delete all existing Person records of type CLUSTER\"\"\"\n    print(\"[INFO] Deleting all clustered people\")\n    Person.objects.filter(kind=Person.KIND_CLUSTER, cluster_owner=user).delete()\n    Person.objects.filter(kind=Person.KIND_UNKNOWN, cluster_owner=user).delete()\n    Person.objects.filter(cluster_owner=None).delete()\n    Person.objects.filter(cluster_owner=get_deleted_user()).delete()\n\n\n# Function to filter data based on a desired shape\ndef filter_data(encodings, ids):\n    valid_encodings = []\n    valid_ids = []\n    expected_shape = (\n        len(encodings[0]) if encodings else 0\n    )  # Set expected shape from first entry\n\n    for i, (encoding, id_) in enumerate(zip(encodings, ids)):\n        if len(encoding) == expected_shape:  # Check if shape is consistent\n            valid_encodings.append(encoding)\n            valid_ids.append(id_)\n        else:\n            logger.error(\n                f\"Discarding entry {i}: ID={id_}, encoding shape={len(encoding)} (expected {expected_shape})\"\n            )\n\n    return np.array(valid_encodings), np.array(valid_ids)\n\n\ndef train_faces(user: User, job_id) -> bool:\n    \"\"\"Given existing Cluster records for all faces, determines the probability\n    that unknown faces belong to those Clusters. It takes any known, labeled faces\n    and adds the centroids of \"unknown\" clusters, assuming that those clusters\n    correspond to *some* face. It then trains a classifier on that data to use\n    in calculating the probabilities for unknown faces.\n    :param user: the current user running the training\n    :param job_id: the background job ID\n    \"\"\"\n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_TRAIN_FACES,\n        job_id=job_id,\n    )\n    lrj.update_progress(current=1, target=2)\n    try:\n        # Use two array, so that the first one gets thrown out, if it is no longer used.\n        data_known = {\"encoding\": [], \"id\": []}\n        data_unknown = {\"encoding\": [], \"id\": []}\n        # First, sort all faces into known and unknown ones\n        face: Face\n        for face in Face.objects.filter(\n            Q(photo__owner=user) & Q(encoding__isnull=False) & Q(deleted=False)\n        ).prefetch_related(\"person\"):\n            if not face.person:\n                data_unknown[\"encoding\"].append(face.get_encoding_array())\n                data_unknown[\"id\"].append(face.id)\n            else:\n                data_known[\"encoding\"].append(face.get_encoding_array())\n                data_known[\"id\"].append(face.person.id)\n\n        if len(data_known[\"id\"]) == 0:\n            classifier = None\n        else:\n            logger.info(\"Before fitting\")\n            classifier = MLPClassifier(\n                solver=\"adam\", alpha=1e-5, random_state=1, max_iter=1000\n            ).fit(np.array(data_known[\"encoding\"]), np.array(data_known[\"id\"]))\n            logger.info(\"After fitting\")\n\n        # Next, pretend all unknown face clusters are known and add their mean encoding. This allows us\n        # to predict the likelihood of other unknown faces belonging to those simulated clusters. For\n        # the \"Unknown - Other\"-type cluster, we can still try to predict the probability that the face\n        # can't be classified into another group, i.e. that it should be classified that way\n        cluster: Cluster\n        for cluster in Cluster.objects.filter(owner=user):\n            if cluster.person and cluster.person.kind == Person.KIND_CLUSTER:\n                print(cluster.person)\n                data_known[\"encoding\"].append(cluster.get_mean_encoding_array())\n                data_known[\"id\"].append(cluster.person.id)\n\n        filtered_encodings, filtered_ids = filter_data(\n            data_known[\"encoding\"], data_known[\"id\"]\n        )\n\n        # Fit the classifier based on the \"known\" faces, including the simulated clusters\n        logger.info(\"Before cluster fitting\")\n        cluster_classifier = MLPClassifier(\n            solver=\"adam\", alpha=1e-5, random_state=1, max_iter=1000\n        ).fit(filtered_encodings, filtered_ids)\n        logger.info(\"After cluster fitting\")\n\n        # Collect the probabilities for each unknown face. The probabilities returned\n        # are arrays in the same order as the people IDs in the original training set\n        target_count = len(data_unknown[\"id\"])\n        if target_count == 0:\n            logger.info(\"No clusters found\")\n            lrj.update_progress(current=2, target=2)\n            lrj.complete()\n            return True\n        logger.info(f\"Number of Cluster: {target_count}\")\n\n        # Hacky way to split arrays into smaller arrays\n        pages_encoding = [\n            data_unknown[\"encoding\"][i : i + 100]\n            for i in range(0, len(data_unknown[\"encoding\"]), 100)\n        ]\n        pages_id = [\n            data_unknown[\"id\"][i : i + 100]\n            for i in range(0, len(data_unknown[\"encoding\"]), 100)\n        ]\n        for idx, page in enumerate(pages_encoding):\n            page_id = pages_id[idx]\n            pages_of_faces = Face.objects.filter(id__in=page_id).all()\n            # sort pages of faces by id by page_id\n            pages_of_faces = sorted(pages_of_faces, key=lambda x: page_id.index(x.id))\n            face_encodings_unknown_np = np.array(page)\n            cluster_probs = cluster_classifier.predict_proba(face_encodings_unknown_np)\n            if classifier:\n                classification_probs = classifier.predict_proba(\n                    face_encodings_unknown_np\n                )\n            else:\n                classification_probs = []\n                while len(classification_probs) < len(cluster_probs):\n                    classification_probs.append(\n                        [0.0] * len(cluster_classifier.classes_)\n                    )\n\n            commit_time = datetime.datetime.now() + datetime.timedelta(seconds=5)\n            face_stack = []\n            unknown_cluster: Cluster = get_unknown_cluster(user=user)\n\n            for idx, (\n                face,\n                cluster_probabilities,\n                classification_probabilties,\n            ) in enumerate(zip(pages_of_faces, cluster_probs, classification_probs)):\n                face.cluster_probability = 0.0  # Cluster probability\n                face.classification_probability = 0.0  # Classification probability\n                classification_person = None\n                classification_probability = 0.0\n\n                highest_classification_probability = max(classification_probabilties)\n                highest_classification_person = 0\n\n                # Find the person with the highest probability for classification\n                if classifier:\n                    for i, target in enumerate(classifier.classes_):\n                        if (\n                            highest_classification_probability\n                            == classification_probabilties[i]\n                        ):\n                            highest_classification_person = target\n\n                    classification_person = highest_classification_person\n                    classification_probability = highest_classification_probability\n\n                # Find the probability in the probability array corresponding to the person\n                # that we currently believe the face is, even a simulated \"unknown\" person\n                highest_probability = max(cluster_probabilities)\n                highest_probability_person = 0\n                for i, target in enumerate(cluster_classifier.classes_):\n                    if highest_probability == cluster_probabilities[i]:\n                        highest_probability_person = target\n\n                if face.cluster != unknown_cluster:\n                    face.cluster_person = Person.objects.get(\n                        id=highest_probability_person\n                    )\n                    face.cluster_probability = highest_probability\n\n                if classification_person:\n                    face.classification_person = Person.objects.get(\n                        id=classification_person\n                    )\n                    face.classification_probability = classification_probability\n\n                face_stack.append(face)\n                if commit_time < datetime.datetime.now():\n                    lrj.update_progress(current=idx + 1, target=target_count)\n                    commit_time = datetime.datetime.now() + datetime.timedelta(\n                        seconds=5\n                    )\n                if len(face_stack) > 200:\n                    bulk_update(face_stack, update_fields=FACE_CLASSIFY_COLUMNS)\n                    face_stack = []\n\n            bulk_update(face_stack, update_fields=FACE_CLASSIFY_COLUMNS)\n\n        lrj.update_progress(current=target_count, target=target_count)\n        lrj.complete()\n        return True\n\n    except BaseException as err:\n        logger.exception(\"An error occurred\")\n        print(f\"[ERR] {err}\")\n        lrj.fail(error=err)\n        return False\n"
  },
  {
    "path": "api/face_extractor.py",
    "content": "import numpy as np\nimport PIL\n\nfrom api.face_recognition import get_face_locations\nfrom api.metadata.reader import get_metadata\nfrom api.metadata.tags import Tags\nfrom api.util import is_number, logger\n\n\nclass RuleTypes:\n    EXIF = \"exif\"\n    DLIB = \"dlib\"\n\n\ndef extract_from_exif(image_path, big_thumbnail_image_path):\n    (region_info, orientation) = get_metadata(\n        image_path,\n        tags=[Tags.REGION_INFO, Tags.ORIENTATION],\n        try_sidecar=True,\n        struct=True,\n    )\n    if not region_info:\n        return\n    logger.debug(f\"Extracted region_info for {image_path}\")\n    logger.debug(f\"region_info: {region_info}\")\n    face_locations = []\n    for region in region_info[\"RegionList\"]:\n        if region.get(\"Type\") != \"Face\":\n            continue\n        person_name = region.get(\"Name\")\n\n        area = region.get(\"Area\")\n        applied_to_dimensions = region.get(\"AppliedToDimensions\")\n        big_thumbnail_image = np.array(PIL.Image.open(big_thumbnail_image_path))\n        if (area and area.get(\"Unit\") == \"normalized\") or (\n            applied_to_dimensions and applied_to_dimensions.get(\"Unit\") == \"pixel\"\n        ):\n            image_width = big_thumbnail_image.shape[1]\n            image_height = big_thumbnail_image.shape[0]\n            if (\n                not is_number(area.get(\"X\"))\n                or not is_number(area.get(\"Y\"))\n                or not is_number(area.get(\"W\"))\n                or not is_number(area.get(\"H\"))\n            ):\n                logger.info(\n                    f\"Broken face area exif data! No numerical positional data. region_info: {region_info}\"\n                )\n                continue\n\n            correct_w = float(area.get(\"W\"))\n            correct_h = float(area.get(\"H\"))\n            correct_x = float(area.get(\"X\"))\n            correct_y = float(area.get(\"Y\"))\n            if orientation == \"Rotate 90 CW\":\n                temp_x = correct_x\n                correct_x = 1 - correct_y\n                correct_y = temp_x\n                correct_w, correct_h = correct_h, correct_w\n            elif orientation == \"Mirror horizontal\":\n                correct_x = 1 - correct_x\n            elif orientation == \"Rotate 180\":\n                correct_x = 1 - correct_x\n                correct_y = 1 - correct_y\n            elif orientation == \"Mirror vertical\":\n                correct_y = 1 - correct_y\n            elif orientation == \"Mirror horizontal and rotate 270 CW\":\n                temp_x = correct_x\n                correct_x = 1 - correct_y\n                correct_y = temp_x\n                correct_w, correct_h = correct_h, correct_w\n            elif orientation == \"Mirror horizontal and rotate 90 CW\":\n                temp_x = correct_x\n                correct_x = correct_y\n                correct_y = 1 - temp_x\n                correct_w, correct_h = correct_h, correct_w\n            elif orientation == \"Rotate 270 CW\":\n                temp_x = correct_x\n                correct_x = correct_y\n                correct_y = 1 - temp_x\n                correct_w, correct_h = correct_h, correct_w\n\n            # Calculate the half-width and half-height of the box\n            half_width = (correct_w * image_width) / 2\n            half_height = (correct_h * image_height) / 2\n\n            # Calculate the top, right, bottom, and left coordinates\n            top = int((correct_y * image_height) - half_height)\n            right = int((correct_x * image_width) + half_width)\n            bottom = int((correct_y * image_height) + half_height)\n            left = int((correct_x * image_width) - half_width)\n\n            face_locations.append((top, right, bottom, left, person_name))\n    return face_locations\n\n\ndef extract_from_dlib(image_path, big_thumbnail_path, owner):\n    try:\n        face_locations = get_face_locations(\n            big_thumbnail_path,\n            model=owner.face_recognition_model.lower(),\n        )\n    except Exception as e:\n        logger.info(f\"Can't extract face information on photo: {image_path}\")\n        logger.info(e)\n        face_locations = []\n\n    for i, face_location in enumerate(face_locations):\n        face_locations[i] = (*face_location, None)\n    return face_locations\n\n\ndef extract(image_path, big_thumbnail_path, owner):\n    exif = extract_from_exif(image_path, big_thumbnail_path)\n    if not exif:\n        return extract_from_dlib(image_path, big_thumbnail_path, owner)\n    return exif\n"
  },
  {
    "path": "api/face_recognition.py",
    "content": "import numpy as np\nimport requests\n\n\ndef get_face_encodings(image_path, known_face_locations):\n    json = {\n        \"source\": image_path,\n        \"face_locations\": known_face_locations,\n    }\n    face_encoding = requests.post(\n        \"http://localhost:8005/face-encodings\", json=json\n    ).json()\n\n    face_encodings_list = face_encoding[\"encodings\"]\n    face_encodings = [np.array(enc) for enc in face_encodings_list]\n\n    return face_encodings\n\n\ndef get_face_locations(image_path, model=\"hog\"):\n    json = {\"source\": image_path, \"model\": model}\n    face_locations = requests.post(\n        \"http://localhost:8005/face-locations\", json=json\n    ).json()\n    return face_locations[\"face_locations\"]\n"
  },
  {
    "path": "api/feature/__init__.py",
    "content": ""
  },
  {
    "path": "api/feature/embedded_media.py",
    "content": "import os\nfrom mmap import ACCESS_READ, mmap\n\nimport magic\nfrom django.conf import settings\n\nJPEG_EOI_MARKER = b\"\\xff\\xd9\"\nGOOGLE_PIXEL_MOTION_PHOTO_MP4_SIGNATURES = [b\"ftypmp42\", b\"ftypisom\", b\"ftypiso2\"]\n\n# in reality Samsung motion photo marker will look something like this\n# ........Image_UTC_Data1458170015363SEFHe...........#...#.......SEFT..0.....MotionPhoto_Data\n# but we are interested only in the content of the video which is right after MotionPhoto_Data\nSAMSUNG_MOTION_PHOTO_MARKER = b\"MotionPhoto_Data\"\n\n\ndef _locate_embedded_video_google(data):\n    signatures = GOOGLE_PIXEL_MOTION_PHOTO_MP4_SIGNATURES\n    for signature in signatures:\n        position = data.find(signature)\n        if position != -1:\n            return position - 4\n    return -1\n\n\ndef _locate_embedded_video_samsung(data):\n    position = data.find(SAMSUNG_MOTION_PHOTO_MARKER)\n    if position != -1:\n        return position + len(SAMSUNG_MOTION_PHOTO_MARKER)\n    return -1\n\n\ndef has_embedded_media(path: str) -> bool:\n    mime = magic.Magic(mime=True)\n    mime_type = mime.from_file(path)\n    if mime_type != \"image/jpeg\":\n        return False\n    with open(path, \"rb\") as image:\n        with mmap(image.fileno(), 0, access=ACCESS_READ) as mm:\n            return (\n                _locate_embedded_video_samsung(mm) != -1\n                or _locate_embedded_video_google(mm) != -1\n            )\n\n\ndef extract_embedded_media(path: str, hash: str) -> str | None:\n    with open(str(path), \"rb\") as image:\n        with mmap(image.fileno(), 0, access=ACCESS_READ) as mm:\n            position = _locate_embedded_video_google(\n                mm\n            ) or _locate_embedded_video_google(mm)\n            if position == -1:\n                return None\n            output_dir = f\"{settings.MEDIA_ROOT}/embedded_media\"\n            if not os.path.exists(output_dir):\n                os.makedirs(output_dir)\n            output_path = f\"{output_dir}/{hash}_1.mp4\"\n            with open(output_path, \"wb+\") as video:\n                mm.seek(position)\n                data = mm.read(mm.size())\n                video.write(data)\n            return output_path\n"
  },
  {
    "path": "api/feature/tests/__init__.py",
    "content": ""
  },
  {
    "path": "api/feature/tests/test_embedded_media.py",
    "content": "from django.conf import settings\nfrom django.test import override_settings\nfrom rest_framework.test import APIClient, APITestCase\n\nfrom api.feature.embedded_media import (\n    GOOGLE_PIXEL_MOTION_PHOTO_MP4_SIGNATURES,\n    JPEG_EOI_MARKER,\n    SAMSUNG_MOTION_PHOTO_MARKER,\n    extract_embedded_media,\n    has_embedded_media,\n)\nfrom api.models import User\nfrom api.models.file import File\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\ndef create_test_file(path: str, user: User, content: bytes):\n    with open(path, \"wb+\") as f:\n        f.write(content)\n    return File.create(path, user)\n\n\nJPEG_MAGIC_NUMBER = b\"\\xff\\xd8\\xff\"\nJPEG = JPEG_MAGIC_NUMBER + b\"\\xde\\xad\\xfa\\xce\" + JPEG_EOI_MARKER\nMP4_DATA = b\"\\xca\\xfe\\xfe\\xed\"\nMP4_PREFIX = b\"\\x00\\x00\\x00\\x18\"\nMP4 = MP4_PREFIX + b\"ftypmp42\" + MP4_DATA\nRANDOM_BYTES = b\"\\x13\\x37\\xc0\\xde\"\n\n\n@override_settings(MEDIA_ROOT=\"/tmp\")\nclass EmbeddedMediaTest(APITestCase):\n    def setUp(self):\n        self.test_image_path = \"/tmp/test_file.jpeg\"\n        self.test_video_path = \"/tmp/test_file.mp4\"\n        self.user = create_test_user()\n        self.client = APIClient()\n\n    def test_should_not_process_non_jpeg_files(self):\n        file = create_test_file(self.test_video_path, self.user, MP4)\n        actual = has_embedded_media(file.path)\n        self.assertFalse(actual)\n\n    def test_google_pixel_motion_photo_signatures(self):\n        for signature in GOOGLE_PIXEL_MOTION_PHOTO_MP4_SIGNATURES:\n            content = JPEG + MP4_PREFIX + signature + MP4_DATA\n            file = create_test_file(self.test_image_path, self.user, content)\n            actual = has_embedded_media(file.path)\n            self.assertTrue(actual)\n\n    def test_samsung_motion_photo_signature(self):\n        content = JPEG + SAMSUNG_MOTION_PHOTO_MARKER + MP4_DATA\n        file = create_test_file(self.test_image_path, self.user, content)\n        actual = has_embedded_media(file.path)\n        self.assertTrue(actual)\n\n    def test_other_content_should_not_report_as_having_embedded_media(self):\n        file = create_test_file(self.test_image_path, self.user, RANDOM_BYTES)\n        actual = has_embedded_media(file.path)\n        self.assertFalse(actual)\n\n    def test_extract_embedded_media_from_google_motion_photo(self):\n        for signature in GOOGLE_PIXEL_MOTION_PHOTO_MP4_SIGNATURES:\n            content = JPEG + MP4_PREFIX + signature + MP4_DATA\n            file = create_test_file(self.test_image_path, self.user, content)\n            path = extract_embedded_media(file.path, file.hash)\n            expected = f\"{settings.MEDIA_ROOT}/embedded_media/{file.hash}_1.mp4\"\n            self.assertEqual(path, expected)\n            with open(path, \"rb\") as f:\n                contents = f.read()\n                self.assertEqual(MP4_PREFIX + signature + MP4_DATA, contents)\n\n    def test_extract_embedded_media_from_samsung_motion_photo(self):\n        content = JPEG + SAMSUNG_MOTION_PHOTO_MARKER + MP4\n        file = create_test_file(self.test_image_path, self.user, content)\n        path = extract_embedded_media(file.path, file.hash)\n        expected = f\"{settings.MEDIA_ROOT}/embedded_media/{file.hash}_1.mp4\"\n        self.assertEqual(expected, path)\n        with open(path, \"rb+\") as f:\n            contents = f.read()\n            self.assertEqual(MP4, contents)\n\n    def test_fetch_embedded_media_as_owner(self):\n        self.client.force_authenticate(user=self.user)\n        embedded_media = create_test_file(self.test_video_path, self.user, MP4)\n        photo = create_test_photo(owner=self.user)\n        photo.main_file.embedded_media.add(embedded_media)\n        response = self.client.get(f\"/media/embedded_media/{photo.pk}\")\n        self.assertEqual(response.status_code, 200)\n\n    def test_fetch_embedded_media_as_anonymous_when_photo_is_public(self):\n        self.client.force_authenticate(user=None)\n        embedded_media = create_test_file(self.test_video_path, self.user, MP4)\n        photo = create_test_photo(owner=self.user, public=True)\n        photo.main_file.embedded_media.add(embedded_media)\n\n        response = self.client.get(f\"/media/embedded_media/{photo.pk}\")\n        self.assertEqual(response.status_code, 200)\n\n    def test_fetch_embedded_media_as_anonymous_when_photo_is_private(self):\n        self.client.force_authenticate(user=None)\n        embedded_media = create_test_file(self.test_video_path, self.user, MP4)\n        photo = create_test_photo(owner=self.user, public=False)\n        photo.main_file.embedded_media.add(embedded_media)\n\n        response = self.client.get(f\"/media/embedded_media/{photo.pk}\")\n        self.assertEqual(response.status_code, 404)\n\n    def test_fetch_embedded_media_when_photo_does_not_have_embedded_media(self):\n        self.client.force_authenticate(user=self.user)\n        photo = create_test_photo(owner=self.user)\n\n        response = self.client.get(f\"/media/embedded_media/{photo.pk}\")\n        self.assertEqual(response.status_code, 404)\n"
  },
  {
    "path": "api/filters.py",
    "content": "import datetime\nimport operator\nfrom functools import reduce\n\nfrom django.db.models import Q\nfrom rest_framework import filters\n\nfrom api import util\nfrom api.image_similarity import search_similar_embedding\nfrom api.semantic_search import calculate_query_embeddings\n\n\nclass SemanticSearchFilter(filters.SearchFilter):\n    def filter_queryset(self, request, queryset, view):\n        search_fields = self.get_search_fields(view, request)\n        search_terms = self.get_search_terms(request)\n\n        if not search_fields or not search_terms:\n            return queryset\n\n        orm_lookups = [\n            self.construct_search(str(search_field), queryset=queryset)\n            for search_field in search_fields\n        ]\n\n        if request.user.semantic_search_topk > 0:\n            query = request.query_params.get(\"search\")\n            start = datetime.datetime.now()\n            emb, magnitude = calculate_query_embeddings(query)\n            elapsed = (datetime.datetime.now() - start).total_seconds()\n            util.logger.info(\n                \"finished calculating query embedding - took %.2f seconds\" % (elapsed)\n            )\n            start = datetime.datetime.now()\n            image_hashes = search_similar_embedding(\n                request.user.id, emb, request.user.semantic_search_topk, threshold=27\n            )\n            elapsed = (datetime.datetime.now() - start).total_seconds()\n            util.logger.info(\"search similar embedding - took %.2f seconds\" % (elapsed))\n        conditions = []\n        for search_term in search_terms:\n            queries = [Q(**{orm_lookup: search_term}) for orm_lookup in orm_lookups]\n\n            if request.user.semantic_search_topk > 0:\n                queries += [Q(image_hash__in=image_hashes)]\n\n            conditions.append(reduce(operator.or_, queries))\n        queryset = queryset.filter(reduce(operator.and_, conditions))\n\n        if self.must_call_distinct(queryset, search_fields):\n            # Filtering against a many-to-many field requires us to\n            # call queryset.distinct() in order to avoid duplicate items\n            # in the resulting queryset.\n            # We try to avoid this if possible, for performance reasons.\n            queryset = queryset.distinct()\n        return queryset\n"
  },
  {
    "path": "api/geocode/__init__.py",
    "content": "GEOCODE_VERSION = \"1\"\n"
  },
  {
    "path": "api/geocode/config.py",
    "content": "from constance import config as settings\n\nfrom .parsers.mapbox import parse as parse_mapbox\nfrom .parsers.nominatim import parse as parse_nominatim\nfrom .parsers.opencage import parse as parse_opencage\nfrom .parsers.tomtom import parse as parse_tomtom\n\n\ndef _get_config():\n    return {\n        \"mapbox\": {\n            \"geocode_args\": {\"api_key\": settings.MAP_API_KEY},\n            \"parser\": parse_mapbox,\n        },\n        \"maptiler\": {\n            \"geocode_args\": {\"api_key\": settings.MAP_API_KEY},\n            \"parser\": parse_mapbox,\n        },\n        \"tomtom\": {\n            \"geocode_args\": {\"api_key\": settings.MAP_API_KEY},\n            \"parser\": parse_tomtom,\n        },\n        \"nominatim\": {\n            \"geocode_args\": {\"user_agent\": \"librephotos\"},\n            \"parser\": parse_nominatim,\n        },\n        \"opencage\": {\n            \"geocode_args\": {\n                \"api_key\": settings.MAP_API_KEY,\n            },\n            \"parser\": parse_opencage,\n        },\n    }\n\n\ndef get_provider_config(provider) -> dict:\n    config = _get_config()\n    if provider not in config:\n        raise Exception(f\"Map provider not found: {provider}.\")\n    return config[provider][\"geocode_args\"]\n\n\ndef get_provider_parser(provider) -> callable:\n    config = _get_config()\n    if provider not in config:\n        raise Exception(f\"Map provider not found: {provider}.\")\n    return config[provider][\"parser\"]\n"
  },
  {
    "path": "api/geocode/geocode.py",
    "content": "from typing import List\n\nimport geopy\nfrom constance import config as site_config\n\nfrom api import util\n\nfrom .config import get_provider_config, get_provider_parser\n\n\nclass Geocode:\n    def __init__(self, provider):\n        self._provider_config = get_provider_config(provider)\n        self._parser = get_provider_parser(provider)\n        self._geocoder = geopy.get_geocoder_for_service(provider)(\n            **self._provider_config\n        )\n\n    def reverse(self, lat: float, lon: float) -> dict:\n        if (\n            \"geocode_args\" in self._provider_config\n            and \"api_key\" in self._provider_config[\"geocode_args\"]\n            and self._provider_config[\"geocode_args\"][\"api_key\"] is None\n        ):\n            util.logger.warning(\n                \"No API key found for map provider. Please set MAP_API_KEY in the admin panel or switch map provider.\"\n            )\n            return {}\n        location = self._geocoder.reverse(f\"{lat},{lon}\")\n        return self._parser(location)\n\n    def search(self, query: str, limit: int = 5) -> List[dict]:\n        \"\"\"Forward geocoding: search for locations by name/address.\"\"\"\n        if (\n            \"api_key\" in self._provider_config\n            and self._provider_config[\"api_key\"] is None\n        ):\n            util.logger.warning(\n                \"No API key found for map provider. Please set MAP_API_KEY in the admin panel or switch map provider.\"\n            )\n            return []\n        locations = self._geocoder.geocode(query, exactly_one=False, limit=limit)\n        if not locations:\n            return []\n        return [\n            {\n                \"display_name\": loc.address,\n                \"lat\": loc.latitude,\n                \"lon\": loc.longitude,\n            }\n            for loc in locations\n        ]\n\n\ndef reverse_geocode(lat: float, lon: float) -> dict:\n    try:\n        return Geocode(site_config.MAP_API_PROVIDER).reverse(lat, lon)\n    except Exception as e:\n        util.logger.warning(f\"Error while reverse geocoding: {e}\")\n        return {}\n\n\ndef search_location(query: str, limit: int = 5) -> List[dict]:\n    \"\"\"Search for locations by name/address using the configured map provider.\"\"\"\n    try:\n        return Geocode(site_config.MAP_API_PROVIDER).search(query, limit)\n    except Exception as e:\n        util.logger.warning(f\"Error while searching location: {e}\")\n        return []\n"
  },
  {
    "path": "api/geocode/parsers/__init__.py",
    "content": ""
  },
  {
    "path": "api/geocode/parsers/mapbox.py",
    "content": "from api.geocode import GEOCODE_VERSION\n\n\ndef parse(location):\n    context = location.raw[\"context\"]\n    center = [location.raw[\"center\"][1], location.raw[\"center\"][0]]\n    local_name = location.raw[\"text\"]\n    places = [local_name] + [\n        i[\"text\"] for i in context if not i[\"id\"].startswith(\"post\")\n    ]\n    return {\n        \"features\": [{\"text\": place, \"center\": center} for place in places],\n        \"places\": places,\n        \"address\": location.address,\n        \"center\": center,\n        \"_v\": GEOCODE_VERSION,\n    }\n"
  },
  {
    "path": "api/geocode/parsers/nominatim.py",
    "content": "from api.geocode import GEOCODE_VERSION\n\n\ndef parse(location):\n    data = location.raw[\"address\"]\n    props = [\n        \"road\",\n        \"town\",\n        \"neighbourhood\",\n        \"suburb\",\n        \"hamlet\",\n        \"borough\",\n        \"city\",\n        \"county\",\n        \"state\",\n        \"country\",\n    ]\n    places = [data[prop] for prop in props if prop in data]\n    center = [float(location.raw[\"lat\"]), float(location.raw[\"lon\"])]\n    return {\n        \"features\": [{\"text\": place, \"center\": center} for place in places],\n        \"places\": places,\n        \"address\": location.address,\n        \"center\": center,\n        \"_v\": GEOCODE_VERSION,\n    }\n"
  },
  {
    "path": "api/geocode/parsers/opencage.py",
    "content": "from api.geocode import GEOCODE_VERSION\n\n\ndef parse(location):\n    data = location.raw[\"components\"]\n    center = [location.raw[\"geometry\"][\"lat\"], location.raw[\"geometry\"][\"lng\"]]\n    props = [\n        data[\"_type\"],\n        \"road\",\n        \"suburb\",\n        \"municipality\",\n        \"hamlet\",\n        \"towncity\",\n        \"borough\",\n        \"state\",\n        \"county\",\n        \"country\",\n    ]\n    places = [data[prop] for prop in props if prop in data]\n    return {\n        \"features\": [{\"text\": place, \"center\": center} for place in places],\n        \"places\": places,\n        \"address\": location.address,\n        \"center\": center,\n        \"_v\": GEOCODE_VERSION,\n    }\n"
  },
  {
    "path": "api/geocode/parsers/tomtom.py",
    "content": "from functools import reduce\n\nfrom api.geocode import GEOCODE_VERSION\n\n\ndef _dedup(iterable):\n    unique_items = set()\n\n    def reducer(acc, item):\n        if item not in unique_items:\n            unique_items.add(item)\n            acc.append(item)\n        return acc\n\n    return reduce(reducer, iterable, [])\n\n\ndef parse(location):\n    data = location.raw[\"address\"]\n    address = location.address\n    center = list(map(lambda x: float(x), location.raw[\"position\"].split(\",\")))\n    props = [\n        \"street\",\n        \"streetName\",\n        \"municipalitySubdivision\",\n        \"countrySubdivision\",\n        \"countrySecondarySubdivision\",\n        \"municipality\",\n        \"municipalitySubdivision\",\n        \"country\",\n    ]\n    places = _dedup(\n        [data[prop] for prop in props if prop in data and len(data[prop]) > 2]\n    )\n    return {\n        \"features\": [{\"text\": place, \"center\": center} for place in places],\n        \"places\": places,\n        \"address\": address,\n        \"center\": center,\n        \"_v\": GEOCODE_VERSION,\n    }\n"
  },
  {
    "path": "api/image_captioning.py",
    "content": "import requests\nfrom constance import config as site_config\n\n\ndef generate_caption(image_path, blip=False, prompt=None):\n    # Check if Moondream is selected as captioning model\n    if site_config.CAPTIONING_MODEL == \"moondream\":\n        # Use custom prompt if provided, otherwise use default caption prompt\n        if prompt is None:\n            prompt = \"Describe this image in a short, concise caption.\"\n\n        json_data = {\n            \"image_path\": image_path,\n            \"prompt\": prompt,\n            \"max_tokens\": 256,\n        }\n        try:\n            response = requests.post(\"http://localhost:8008/generate\", json=json_data)\n\n            if response.status_code != 201:\n                print(\n                    f\"Error with Moondream captioning service: HTTP {response.status_code} - {response.text}\"\n                )\n                return \"Error generating caption with Moondream: Service unavailable\"\n\n            response_data = response.json()\n            return response_data[\"response\"]\n        except requests.exceptions.ConnectionError:\n            print(\n                \"Error with Moondream captioning service: Cannot connect to LLM service on port 8008\"\n            )\n            return \"Error generating caption with Moondream: Service unavailable\"\n        except requests.exceptions.Timeout:\n            print(\"Error with Moondream captioning service: Request timeout\")\n            return \"Error generating caption with Moondream: Request timeout\"\n        except Exception as e:\n            print(f\"Error with Moondream captioning service: {e}\")\n            return \"Error generating caption with Moondream\"\n\n    # Original implementation for other models\n    json_data = {\n        \"image_path\": image_path,\n        \"onnx\": False,\n        \"blip\": blip,\n    }\n    caption_response = requests.post(\n        \"http://localhost:8007/generate-caption\", json=json_data\n    ).json()\n\n    return caption_response[\"caption\"]\n\n\ndef unload_model():\n    requests.get(\"http://localhost:8007/unload-model\")\n"
  },
  {
    "path": "api/image_similarity.py",
    "content": "from datetime import datetime\n\nimport numpy as np\nimport requests\nfrom django.conf import settings\nfrom django.core.paginator import Paginator\nfrom django.db.models import Q\n\nfrom api.models import Photo\nfrom api.util import logger\n\n\ndef search_similar_embedding(user, emb, result_count=100, threshold=27):\n    if isinstance(user, int):\n        user_id = user\n    else:\n        user_id = user.id\n\n    image_embedding = np.array(emb, dtype=np.float32)\n\n    post_data = {\n        \"user_id\": user_id,\n        \"image_embedding\": image_embedding.tolist(),\n        \"n\": result_count,\n        \"threshold\": threshold,\n    }\n    res = requests.post(settings.IMAGE_SIMILARITY_SERVER + \"/search/\", json=post_data)\n    if res.status_code == 200:\n        return res.json()[\"result\"]\n    else:\n        logger.error(f\"error retrieving similar embeddings for user {user_id}\")\n        return []\n\n\ndef search_similar_image(user, photo, threshold=27):\n    if isinstance(user, int):\n        user_id = user\n    else:\n        user_id = user.id\n\n    clip_embeddings = photo.get_clip_embeddings()\n    if clip_embeddings is None:\n        return []\n\n    image_embedding = np.array(clip_embeddings, dtype=np.float32)\n\n    post_data = {\n        \"user_id\": user_id,\n        \"image_embedding\": image_embedding.tolist(),\n        \"threshold\": threshold,\n    }\n    res = requests.post(settings.IMAGE_SIMILARITY_SERVER + \"/search/\", json=post_data)\n    if res.status_code == 200:\n        return res.json()\n    else:\n        logger.error(\n            f\"error retrieving similar photos to {photo.image_hash} belonging to user {user.username}\"\n        )\n        return []\n\n\ndef build_image_similarity_index(user):\n    logger.info(f\"building similarity index for user {user.username}\")\n    requests.delete(\n        settings.IMAGE_SIMILARITY_SERVER + \"/build/\",\n        json={\"user_id\": user.id},\n    )\n    start = datetime.now()\n    photos = (\n        Photo.objects.filter(Q(hidden=False) & Q(owner=user))\n        .exclude(clip_embeddings=None)\n        .only(\"clip_embeddings\", \"image_hash\")\n        .order_by(\"image_hash\")\n        .all()\n    )\n    paginator = Paginator(photos, 5000)\n\n    for page in range(1, paginator.num_pages + 1):\n        image_hashes = []\n        image_embeddings = []\n        for photo in paginator.page(page).object_list:\n            clip_embeddings = photo.get_clip_embeddings()\n            if clip_embeddings is not None:\n                image_hashes.append(photo.image_hash)\n                image_embedding = np.array(clip_embeddings, dtype=np.float32)\n                image_embeddings.append(image_embedding.tolist())\n\n        post_data = {\n            \"user_id\": user.id,\n            \"image_hashes\": image_hashes,\n            \"image_embeddings\": image_embeddings,\n        }\n        requests.post(settings.IMAGE_SIMILARITY_SERVER + \"/build/\", json=post_data)\n    elapsed = (datetime.now() - start).total_seconds()\n    logger.info(\"building similarity index took %.2f seconds\" % elapsed)\n"
  },
  {
    "path": "api/llm.py",
    "content": "import requests\nimport base64\nimport io\nfrom PIL import Image\nfrom constance import config as site_config\n\n\ndef image_to_base64_data_uri(image_path):\n    \"\"\"Convert image file to base64 data URI, converting to JPEG for compatibility\"\"\"\n    try:\n        # Open image with PIL and convert to RGB (handles WebP, PNG with transparency, etc.)\n        with Image.open(image_path) as img:\n            # Convert to RGB mode (removes alpha channel if present)\n            if img.mode != \"RGB\":\n                img = img.convert(\"RGB\")\n\n            # Save as JPEG to memory buffer\n            buffer = io.BytesIO()\n            img.save(buffer, format=\"JPEG\", quality=95)\n            buffer.seek(0)\n\n            # Encode to base64\n            image_data = base64.b64encode(buffer.getvalue()).decode(\"utf-8\")\n\n        return f\"data:image/jpeg;base64,{image_data}\"\n    except Exception as e:\n        print(f\"Error converting image to data URI: {str(e)}\")\n        raise\n\n\ndef generate_prompt(prompt, image_path=None):\n    if site_config.LLM_MODEL == \"none\":\n        return None\n\n    # Use the unified LLM service for all models including Moondream\n    if site_config.LLM_MODEL == \"moondream\":\n        model_path = \"/protected_media/data_models/moondream2-text-model-f16.gguf\"\n    elif site_config.LLM_MODEL == \"mistral-7b-instruct-v0.2.Q5_K_M\":\n        model_path = \"/protected_media/data_models/mistral-7b-instruct-v0.2.Q5_K_M.gguf\"\n    else:\n        return None\n\n    json_data = {\n        \"model_path\": model_path,\n        \"max_tokens\": 64,\n        \"prompt\": prompt,\n    }\n\n    # Convert image to base64 data URI if image path is provided\n    if image_path:\n        try:\n            image_data = image_to_base64_data_uri(image_path)\n            json_data[\"image_data\"] = image_data\n            json_data[\"multimodal\"] = True\n        except Exception as e:\n            print(f\"Error converting image: {e}\")\n            return None\n\n    try:\n        response = requests.post(\"http://localhost:8008/generate\", json=json_data)\n\n        if response.status_code != 201:\n            print(\n                f\"Error with LLM service: HTTP {response.status_code} - {response.text}\"\n            )\n            return None\n\n        response_data = response.json()\n        return response_data.get(\"response\", \"\")\n    except requests.exceptions.ConnectionError:\n        print(\"Error with LLM service: Cannot connect to service on port 8008\")\n        return None\n    except requests.exceptions.Timeout:\n        print(\"Error with LLM service: Request timeout\")\n        return None\n    except Exception as e:\n        print(f\"Error with LLM service: {e}\")\n        return None\n"
  },
  {
    "path": "api/management/__init__.py",
    "content": ""
  },
  {
    "path": "api/management/commands/build_similarity_index.py",
    "content": "from django.core.management.base import BaseCommand\nfrom django_q.tasks import AsyncTask\n\nfrom api.image_similarity import build_image_similarity_index\nfrom api.models import User\n\n\nclass Command(BaseCommand):\n    help = \"Build image similarity index for all users\"\n\n    def handle(self, *args, **kwargs):\n        for user in User.objects.all():\n            AsyncTask(build_image_similarity_index, user).run()\n"
  },
  {
    "path": "api/management/commands/clear_cache.py",
    "content": "from django.conf import settings\nfrom django.core.cache import cache\nfrom django.core.management.base import BaseCommand, CommandError\n\n\nclass Command(BaseCommand):\n    \"\"\"A simple management command which clears the site-wide cache.\"\"\"\n\n    help = \"Fully clear your site-wide cache.\"\n\n    def handle(self, *args, **kwargs):\n        try:\n            assert settings.CACHES\n            cache.clear()\n            self.stdout.write(\"Your cache has been cleared!\\n\")\n        except AttributeError:\n            raise CommandError(\"You have no cache configured!\\n\")\n"
  },
  {
    "path": "api/management/commands/createadmin.py",
    "content": "import os\nimport sys\n\nfrom django.core.management.base import BaseCommand, CommandError\nfrom django.core.validators import ValidationError, validate_email\n\nfrom api.models import User\n\n\nclass Command(BaseCommand):\n    help = \"Create a LibrePhotos user with administrative permissions\"\n\n    def add_arguments(self, parser):\n        parser.add_argument(\"admin_username\", help=\"Username to create\")\n        parser.add_argument(\"admin_email\", help=\"Email address of the new user\")\n        parser.add_argument(\n            \"-u\",\n            \"--update\",\n            help=(\n                \"Update an existing superuser's password (ignoring the\"\n                \"provided email) instead of reporting an error\"\n            ),\n            action=\"store_true\",\n        )\n        # Done this way because command lines are visible to the whole system by\n        #  default on Linux, so a password in the arguments would leak\n        parser.epilog = (\n            \"The password is read from the ADMIN_PASSWORD\"\n            \"environment variable or interactively if\"\n            \"ADMIN_PASSWORD is not set\"\n        )\n\n    def handle(self, *args, **options):\n        try:\n            validate_email(options[\"admin_email\"])\n        except ValidationError as err:\n            raise CommandError(err.message)\n\n        if \"ADMIN_PASSWORD\" in os.environ:\n            options[\"admin_password\"] = os.environ[\"ADMIN_PASSWORD\"]\n        else:\n            options[\"admin_password\"] = User.objects.make_random_password()\n\n        if not options[\"admin_password\"]:\n            raise CommandError(\"Admin password cannot be empty\")\n\n        if not User.objects.filter(username=options[\"admin_username\"].lower()).exists():\n            User.objects.create_superuser(\n                options[\"admin_username\"].lower(),\n                options[\"admin_email\"],\n                options[\"admin_password\"],\n            )\n        elif options[\"update\"]:\n            print(\n                \"Warning: ignoring provided email \" + options[\"admin_email\"],\n                file=sys.stderr,\n            )\n            admin_user = User.objects.get(username=options[\"admin_username\"].lower())\n            admin_user.set_password(options[\"admin_password\"])\n            admin_user.save()\n        else:\n            raise CommandError(\"Specified user already exists\")\n"
  },
  {
    "path": "api/management/commands/createuser.py",
    "content": "import os\nimport sys\n\nfrom django.core.management.base import BaseCommand, CommandError\nfrom django.core.validators import ValidationError, validate_email\n\nfrom api.models import User\n\n\nclass Command(BaseCommand):\n    help = \"Create a LibrePhotos user\"\n\n    def add_arguments(self, parser):\n        parser.add_argument(\"username\", help=\"Username to create\")\n        parser.add_argument(\"email\", help=\"Email address of the new user\")\n        parser.add_argument(\n            \"--password\",\n            help=\"Password to create/update for user. (autogenerate if omitted)\",\n        )\n        parser.add_argument(\n            \"--update\",\n            help=(\n                \"Update an existing user's password (ignoring the provided email) \"\n                \"instead of reporting an error\"\n            ),\n            action=\"store_true\",\n        )\n        parser.add_argument(\n            \"--admin\",\n            help=\"Create user with administrative privileges\",\n            action=\"store_true\",\n        )\n        # Done this way because command lines are visible to the whole system by\n        #  default on Linux, so a password in the arguments would leak\n        parser.epilog = (\n            \"When creating user with administrative privileges,\"\n            \"the password is read from the ADMIN_PASSWORD\"\n            \"environment variable or interactively if\"\n            \"ADMIN_PASSWORD is not set\"\n        )\n\n    def handle(self, *args, **options):\n        try:\n            validate_email(options[\"email\"])\n        except ValidationError as err:\n            raise CommandError(err.message)\n\n        if options[\"admin\"] and \"ADMIN_PASSWORD\" in os.environ:\n            options[\"password\"] = os.environ[\"ADMIN_PASSWORD\"]\n\n        if not options[\"password\"]:\n            options[\"password\"] = User.objects.make_random_password()\n\n        if not User.objects.filter(username=options[\"username\"].lower()).exists():\n            if options[\"admin\"]:\n                User.objects.create_superuser(\n                    options[\"username\"].lower(),\n                    options[\"email\"],\n                    options[\"password\"],\n                )\n            else:\n                User.objects.create_user(\n                    options[\"username\"].lower(),\n                    options[\"email\"],\n                    options[\"password\"],\n                )\n\n        elif options[\"update\"]:\n            print(\n                \"Warning: ignoring provided email \" + options[\"email\"],\n                file=sys.stderr,\n            )\n            user = User.objects.get(username=options[\"username\"].lower())\n            user.set_password(options[\"password\"])\n            user.save()\n        else:\n            raise CommandError(\"Specified user already exists\")\n"
  },
  {
    "path": "api/management/commands/save_metadata.py",
    "content": "from django.core.management.base import BaseCommand\n\nfrom api.models import Photo, User\nfrom api.models.person import Person\n\n\nclass Command(BaseCommand):\n    help = \"Save metadata to image files (or XMP sidecar files)\"\n\n    def add_arguments(self, parser):\n        parser.add_argument(\n            \"--types\",\n            nargs=\"+\",\n            choices=[\"ratings\", \"face_tags\"],\n            default=[\"ratings\"],\n            help=\"Which metadata types to write (default: ratings)\",\n        )\n        parser.add_argument(\n            \"--user\",\n            type=str,\n            help=\"Only process photos owned by this username\",\n        )\n        parser.add_argument(\n            \"--sidecar\",\n            action=\"store_true\",\n            default=True,\n            help=\"Write to XMP sidecar files (default)\",\n        )\n        parser.add_argument(\n            \"--media-file\",\n            action=\"store_true\",\n            help=\"Write directly to media files instead of sidecars\",\n        )\n        parser.add_argument(\n            \"--dry-run\",\n            action=\"store_true\",\n            help=\"Only show what would be written, don't actually write\",\n        )\n\n    def handle(self, *args, **options):\n        metadata_types = options[\"types\"]\n        use_sidecar = not options[\"media_file\"]\n\n        photos = Photo.objects.all()\n\n        if options[\"user\"]:\n            try:\n                user = User.objects.get(username=options[\"user\"])\n                photos = photos.filter(owner=user)\n            except User.DoesNotExist:\n                self.stderr.write(f\"User '{options['user']}' not found\")\n                return\n\n        # When only writing face tags, filter to photos with any (non-deleted) faces\n        if metadata_types == [\"face_tags\"]:\n            photos = photos.filter(\n                faces__deleted=False,\n            ).distinct()\n\n        total = photos.count()\n        self.stdout.write(f\"Found {total} photos to process (types: {metadata_types})\")\n\n        if options[\"dry_run\"]:\n            self.stdout.write(\"Dry run — no files will be modified\")\n            return\n\n        written = 0\n        errors = 0\n        for i, photo in enumerate(photos.iterator(), 1):\n            try:\n                photo._save_metadata(\n                    use_sidecar=use_sidecar, metadata_types=metadata_types\n                )\n                written += 1\n            except Exception as e:\n                errors += 1\n                self.stderr.write(f\"Error writing {photo.image_hash}: {e}\")\n\n            if i % 100 == 0:\n                self.stdout.write(\n                    f\"Progress: {i}/{total} ({written} written, {errors} errors)\"\n                )\n\n        self.stdout.write(\n            self.style.SUCCESS(\n                f\"Done. {written} written, {errors} errors out of {total} photos.\"\n            )\n        )\n"
  },
  {
    "path": "api/management/commands/scan.py",
    "content": "import traceback\nimport uuid\n\nfrom django.core.management.base import BaseCommand\n\nfrom api.directory_watcher import scan_photos\nfrom api.models import User\nfrom api.models.user import get_deleted_user\nfrom nextcloud.directory_watcher import scan_photos as scan_photos_nextcloud\n\n\nclass Command(BaseCommand):\n    help = \"scan directory for all users\"\n\n    def add_arguments(self, parser):\n        parser_group = parser.add_mutually_exclusive_group()\n        parser_group.add_argument(\n            \"-f\", \"--full-scan\", help=(\"Run full directory scan\"), action=\"store_true\"\n        )\n        parser_group.add_argument(\n            \"-s\", \"--scan-files\", help=(\"Scan a list of files\"), nargs=\"+\", default=[]\n        )\n        parser_group.add_argument(\n            \"-n\",\n            \"--nextcloud\",\n            help=(\"Run nextcloud scan instead of directory scan\"),\n            action=\"store_true\",\n        )\n\n    def handle(self, *args, **options):\n        # Nextcloud scan\n        if options[\"nextcloud\"]:\n            self.nextcloud_scan()\n            return\n\n        # Add a single file.\n        if options[\"scan_files\"]:\n            scan_files = options[\"scan_files\"]\n            deleted_user: User = get_deleted_user()\n            for user in User.objects.all():\n                user_files = []\n                if user == deleted_user:\n                    continue\n                for scan_file in scan_files:\n                    if scan_file.startswith(user.scan_directory):\n                        user_files.append(scan_file)\n                if user_files:\n                    scan_photos(user, False, uuid.uuid4(), scan_files=user_files)\n            return\n\n        # Directory scan\n        deleted_user: User = get_deleted_user()\n        for user in User.objects.all():\n            if user != deleted_user:\n                scan_photos(\n                    user, options[\"full_scan\"], uuid.uuid4(), user.scan_directory\n                )\n\n    def nextcloud_scan(self):\n        for user in User.objects.all():\n            if not user.nextcloud_scan_directory:\n                print(\n                    f\"Skipping nextcloud scan for user {user.username}. No scan directory configured.\"\n                )\n                continue\n            print(f\"Starting nextcloud scan for user {user.username}.\")\n            try:\n                scan_photos_nextcloud(user, uuid.uuid4())\n            except Exception:\n                print(f\"Nextcloud scan for user {user.username} failed:\")\n                print(traceback.format_exc())\n"
  },
  {
    "path": "api/management/commands/start_cleaning_service.py",
    "content": "from django.core.management.base import BaseCommand\nfrom django_q.models import Schedule\nfrom django_q.tasks import schedule\n\nfrom api.util import logger\n\n\nclass Command(BaseCommand):\n    help = \"Start the cleanup service.\"\n\n    def handle(self, *args, **kwargs):\n        if not Schedule.objects.filter(\n            func=\"api.services.cleanup_deleted_photos\"\n        ).exists():\n            schedule(\n                \"api.services.cleanup_deleted_photos\",\n                schedule_type=Schedule.DAILY,\n            )\n        logger.info(\"Cleanup service started\")\n"
  },
  {
    "path": "api/management/commands/start_job_cleanup_service.py",
    "content": "from django.core.management.base import BaseCommand\nfrom django_q.models import Schedule\nfrom django_q.tasks import schedule\n\nfrom api.util import logger\n\n\nclass Command(BaseCommand):\n    help = \"Start the job cleanup service to mark stuck jobs as failed and remove old completed jobs.\"\n\n    def handle(self, *args, **kwargs):\n        # Schedule hourly cleanup of stuck jobs (running for more than 24 hours)\n        if not Schedule.objects.filter(\n            func=\"api.models.long_running_job.LongRunningJob.cleanup_stuck_jobs\"\n        ).exists():\n            schedule(\n                \"api.models.long_running_job.LongRunningJob.cleanup_stuck_jobs\",\n                schedule_type=Schedule.HOURLY,\n            )\n            logger.info(\"Scheduled hourly stuck job cleanup\")\n\n        # Schedule daily cleanup of old completed jobs (older than 30 days)\n        if not Schedule.objects.filter(\n            func=\"api.models.long_running_job.LongRunningJob.cleanup_old_jobs\"\n        ).exists():\n            schedule(\n                \"api.models.long_running_job.LongRunningJob.cleanup_old_jobs\",\n                schedule_type=Schedule.DAILY,\n            )\n            logger.info(\"Scheduled daily old job cleanup\")\n\n        logger.info(\"Job cleanup service started\")\n"
  },
  {
    "path": "api/management/commands/start_service.py",
    "content": "from django.core.management.base import BaseCommand\nfrom django_q.models import Schedule\nfrom django_q.tasks import schedule\n\nfrom api.services import SERVICES, start_service\n\n\nclass Command(BaseCommand):\n    help = \"Start one of the services.\"\n\n    # Define all the services that can be started\n    def add_arguments(self, parser):\n        parser.add_argument(\n            \"service\",\n            type=str,\n            help=\"The service to start\",\n            choices=[\n                SERVICES.keys(),\n                \"all\",\n            ],\n        )\n\n    def handle(self, *args, **kwargs):\n        service = kwargs[\"service\"]\n        if service == \"all\":\n            for svc in SERVICES.keys():\n                start_service(svc)\n            if not Schedule.objects.filter(func=\"api.services.check_services\").exists():\n                schedule(\n                    \"api.services.check_services\",\n                    schedule_type=Schedule.MINUTES,\n                    minutes=1,\n                )\n        else:\n            start_service(service)\n"
  },
  {
    "path": "api/metadata/__init__.py",
    "content": "# api/metadata — organized metadata reading, writing, and tag constants.\n#\n# Submodules:\n#   api.metadata.tags          — Tag name constants (Tags class)\n#   api.metadata.reader        — get_metadata(), sidecar file helpers\n#   api.metadata.writer        — write_metadata()\n#   api.metadata.face_regions  — face region coordinate conversion & tag building\n#\n# Import directly from submodules to avoid circular import issues:\n#   from api.metadata.tags import Tags\n#   from api.metadata.reader import get_metadata\n#   from api.metadata.writer import write_metadata\n#   from api.metadata.face_regions import get_face_region_tags\n"
  },
  {
    "path": "api/metadata/face_regions.py",
    "content": "import PIL\n\nfrom api.metadata.reader import get_metadata\nfrom api.metadata.tags import Tags\nfrom api.models.face import Face\nfrom api.models.person import Person\nfrom api.util import logger\n\n\ndef thumbnail_coords_to_normalized(top, right, bottom, left, thumb_width, thumb_height):\n    \"\"\"Convert Face model pixel coords (in big thumbnail space) to MWG-RS\n    normalized center-based coords.\"\"\"\n    center_x = (left + right) / 2.0 / thumb_width\n    center_y = (top + bottom) / 2.0 / thumb_height\n    w = (right - left) / thumb_width\n    h = (bottom - top) / thumb_height\n    return center_x, center_y, w, h\n\n\ndef reverse_orientation_transform(x, y, w, h, orientation):\n    \"\"\"Invert the orientation transforms from face_extractor.py lines 54-80.\n\n    The read path applies a forward transform from XMP coords to display coords.\n    This function reverses that so we can go from display coords back to XMP coords.\n    \"\"\"\n    if orientation == \"Rotate 90 CW\":\n        # Forward: x' = 1 - y, y' = x, swap w/h\n        # Reverse: y_orig = 1 - x', x_orig = y'\n        new_x = y\n        new_y = 1 - x\n        w, h = h, w\n        return new_x, new_y, w, h\n    elif orientation == \"Mirror horizontal\":\n        # Forward: x' = 1 - x\n        # Reverse: x = 1 - x'\n        return 1 - x, y, w, h\n    elif orientation == \"Rotate 180\":\n        # Forward: x' = 1 - x, y' = 1 - y\n        # Reverse: same\n        return 1 - x, 1 - y, w, h\n    elif orientation == \"Mirror vertical\":\n        # Forward: y' = 1 - y\n        # Reverse: y = 1 - y'\n        return x, 1 - y, w, h\n    elif orientation == \"Mirror horizontal and rotate 270 CW\":\n        # Forward: x' = 1 - y, y' = x, swap w/h\n        # Same as Rotate 90 CW (the mirror cancels differently)\n        new_x = y\n        new_y = 1 - x\n        w, h = h, w\n        return new_x, new_y, w, h\n    elif orientation == \"Mirror horizontal and rotate 90 CW\":\n        # Forward: x' = y, y' = 1 - x, swap w/h\n        # Reverse: x_orig = 1 - y', y_orig = x'\n        new_x = 1 - y\n        new_y = x\n        w, h = h, w\n        return new_x, new_y, w, h\n    elif orientation == \"Rotate 270 CW\":\n        # Forward: x' = y, y' = 1 - x, swap w/h\n        # Reverse: x_orig = 1 - y', y_orig = x'\n        new_x = 1 - y\n        new_y = x\n        w, h = h, w\n        return new_x, new_y, w, h\n    # Normal orientation or unknown — no transform\n    return x, y, w, h\n\n\ndef _escape_exiftool_value(value):\n    \"\"\"Escape special characters in a string value for exiftool structured data.\n\n    ExifTool uses commas, equals, braces in its structured value syntax,\n    so person names containing these characters need escaping.\n    \"\"\"\n    # ExifTool expects special chars to be escaped with backslash\n    for ch in (\"\\\\\", \"{\", \"}\", \"=\", \",\"):\n        value = value.replace(ch, f\"\\\\{ch}\")\n    return value\n\n\ndef build_face_region_exiftool_args(face_regions, image_width=None, image_height=None):\n    \"\"\"Build exiftool args dict for writing XMP-mwg-rs:RegionInfo and XMP:Subject.\n\n    Args:\n        face_regions: list of dicts with keys: name, x, y, w, h\n        image_width: original image width in pixels (for AppliedToDimensions)\n        image_height: original image height in pixels (for AppliedToDimensions)\n\n    Returns:\n        dict of tag -> value suitable for write_metadata()\n    \"\"\"\n    region_parts = []\n    person_names = []\n    for region in face_regions:\n        name = _escape_exiftool_value(region[\"name\"])\n        x = f\"{region['x']:.6f}\"\n        y = f\"{region['y']:.6f}\"\n        w = f\"{region['w']:.6f}\"\n        h = f\"{region['h']:.6f}\"\n        region_parts.append(\n            f\"{{Area={{X={x},Y={y},W={w},H={h},Unit=normalized}}\"\n            f\",Name={name},Type=Face}}\"\n        )\n        if region[\"name\"]:\n            person_names.append(region[\"name\"])\n\n    region_list = \",\".join(region_parts)\n\n    # Include AppliedToDimensions if image dimensions are available\n    if image_width and image_height:\n        applied_to = f\"AppliedToDimensions={{W={image_width},H={image_height},Unit=pixel}},\"\n    else:\n        applied_to = \"\"\n\n    value = f\"{{{applied_to}RegionList=[{region_list}]}}\"\n\n    tags = {Tags.REGION_INFO_WRITE: value}\n\n    # Add person names as XMP:Subject keywords for Lightroom compatibility\n    if person_names:\n        tags[Tags.SUBJECT] = person_names\n\n    return tags\n\n\ndef get_face_region_tags(photo):\n    \"\"\"Build face region exiftool tags dict for a photo.\n\n    Returns a dict of tags suitable for merging into _save_metadata()'s tags_to_write,\n    or an empty dict if no faces exist.\n\n    Args:\n        photo: Photo model instance\n\n    Returns:\n        dict: e.g. {\"XMP-mwg-rs:RegionInfo\": \"{RegionList=[...]}\"}  or {}\n    \"\"\"\n    faces = Face.objects.filter(\n        photo=photo,\n        deleted=False,\n    ).select_related(\"person\")\n\n    if not faces.exists():\n        return {}\n\n    # Get thumbnail dimensions\n    try:\n        thumb_path = photo.thumbnail.thumbnail_big.path\n        thumb_image = PIL.Image.open(thumb_path)\n        thumb_width, thumb_height = thumb_image.size\n        thumb_image.close()\n    except Exception:\n        logger.error(\n            f\"Cannot open thumbnail for photo {photo.image_hash}, skipping face tags\"\n        )\n        return {}\n\n    # Get EXIF orientation and original image dimensions\n    (orientation, image_width, image_height) = get_metadata(\n        photo.main_file.path,\n        tags=[Tags.ORIENTATION, Tags.IMAGE_WIDTH, Tags.IMAGE_HEIGHT],\n        try_sidecar=True,\n    )\n\n    # Convert each face's coordinates\n    face_regions = []\n    for face in faces:\n        x, y, w, h = thumbnail_coords_to_normalized(\n            face.location_top,\n            face.location_right,\n            face.location_bottom,\n            face.location_left,\n            thumb_width,\n            thumb_height,\n        )\n        x, y, w, h = reverse_orientation_transform(x, y, w, h, orientation)\n        # Only write person name for user-labeled faces\n        if face.person and face.person.kind == Person.KIND_USER:\n            name = face.person.name\n        else:\n            name = \"\"\n        face_regions.append(\n            {\n                \"name\": name,\n                \"x\": x,\n                \"y\": y,\n                \"w\": w,\n                \"h\": h,\n            }\n        )\n\n    return build_face_region_exiftool_args(face_regions, image_width, image_height)\n"
  },
  {
    "path": "api/metadata/reader.py",
    "content": "import os\nimport os.path\n\nimport requests\n\n\ndef get_sidecar_files_in_priority_order(media_file):\n    \"\"\"Returns a list of possible XMP sidecar files for *media_file*, ordered\n    by priority.\n\n    \"\"\"\n    image_basename = os.path.splitext(media_file)[0]\n    return [\n        image_basename + \".xmp\",\n        image_basename + \".XMP\",\n        media_file + \".xmp\",\n        media_file + \".XMP\",\n    ]\n\n\ndef _get_existing_metadata_files_reversed(media_file, include_sidecar_files):\n    if include_sidecar_files:\n        files = [\n            file\n            for file in get_sidecar_files_in_priority_order(media_file)\n            if os.path.exists(file)\n        ]\n        files.append(media_file)\n        return list(reversed(files))\n    return [media_file]\n\n\ndef get_metadata(media_file, tags, try_sidecar=True, struct=False):\n    \"\"\"Get values for each metadata tag in *tags* from *media_file*.\n    If *try_sidecar* is `True`, use the value set in any XMP sidecar file\n    stored alongside *media_file*.\n    If *struct* is `True`, use the exiftool instance which returns structured data\n\n    Returns a list with the value of each tag in *tags* or `None` if the\n    tag was not found.\n\n    \"\"\"\n    files_by_reverse_priority = _get_existing_metadata_files_reversed(\n        media_file, try_sidecar\n    )\n\n    json = {\n        \"tags\": tags,\n        \"files_by_reverse_priority\": files_by_reverse_priority,\n        \"struct\": struct,\n    }\n    response = requests.post(\"http://localhost:8010/get-tags\", json=json).json()\n    return response[\"values\"]\n"
  },
  {
    "path": "api/metadata/tags.py",
    "content": "class Tags:\n    RATING = \"Rating\"\n    IMAGE_HEIGHT = \"ImageHeight\"\n    IMAGE_WIDTH = \"ImageWidth\"\n    DATE_TIME_ORIGINAL = \"EXIF:DateTimeOriginal\"\n    DATE_TIME = \"EXIF:DateTime\"\n    QUICKTIME_CREATE_DATE = \"QuickTime:CreateDate\"\n    QUICKTIME_DURATION = \"QuickTime:Duration\"\n    LATITUDE = \"Composite:GPSLatitude\"\n    LONGITUDE = \"Composite:GPSLongitude\"\n    GPS_DATE_TIME = \"Composite:GPSDateTime\"\n    FILE_SIZE = \"File:FileSize\"\n    FSTOP = \"EXIF:FNumber\"\n    EXPOSURE_TIME = \"EXIF:ExposureTime\"\n    ISO = \"EXIF:ISOSpeedRatings\"\n    FOCAL_LENGTH = \"EXIF:FocalLength\"\n    FOCAL_LENGTH_35MM = \"EXIF:FocalLengthIn35mmFilm\"\n    SHUTTER_SPEED = \"EXIF:ShutterSpeedValue\"\n    CAMERA = \"EXIF:Model\"\n    LENS = \"EXIF:LensModel\"\n    SUBJECT_DISTANCE = \"EXIF:SubjectDistance\"\n    DIGITAL_ZOOM_RATIO = \"EXIF:DigitalZoomRatio\"\n    REGION_INFO = \"XMP:RegionInfo\"\n    REGION_INFO_WRITE = \"XMP-mwg-rs:RegionInfo\"\n    SUBJECT = \"XMP:Subject\"\n    ROTATION = \"QuickTime:Rotation\"\n    ORIENTATION = \"EXIF:Orientation\"\n\n    # Burst/sequence detection tags\n    SUBSEC_TIME_ORIGINAL = \"EXIF:SubSecTimeOriginal\"\n    SUBSEC_TIME = \"EXIF:SubSecTime\"\n    IMAGE_NUMBER = \"EXIF:ImageNumber\"\n    IMAGE_UNIQUE_ID = \"EXIF:ImageUniqueID\"\n    BURST_MODE = \"MakerNotes:BurstMode\"\n    CONTINUOUS_DRIVE = \"MakerNotes:ContinuousDrive\"\n    SEQUENCE_NUMBER = \"MakerNotes:SequenceNumber\"\n    # Camera serial number (useful for grouping shots from same camera)\n    SERIAL_NUMBER = \"EXIF:SerialNumber\"\n    CAMERA_SERIAL = \"MakerNotes:SerialNumber\"\n"
  },
  {
    "path": "api/metadata/writer.py",
    "content": "import os\n\nimport exiftool\n\nfrom api.metadata.reader import get_sidecar_files_in_priority_order\nfrom api.util import logger\n\n\ndef write_metadata(media_file, tags, use_sidecar=True):\n    et = exiftool.ExifTool()\n    terminate_et = False\n    if not et.running:\n        et.start()\n        terminate_et = True\n    # To-Do: Replace with new File Structure\n    if use_sidecar:\n        file_path = get_sidecar_files_in_priority_order(media_file)[0]\n    else:\n        file_path = media_file\n\n    try:\n        logger.info(f\"Writing {tags} to {file_path}\")\n        params = []\n        for tag, value in tags.items():\n            if isinstance(value, list):\n                for item in value:\n                    params.append(os.fsencode(f\"-{tag}={item}\"))\n            else:\n                params.append(os.fsencode(f\"-{tag}={value}\"))\n        params.append(b\"-overwrite_original\")\n        params.append(os.fsencode(file_path))\n        et.execute(*params)\n    finally:\n        if terminate_et:\n            et.terminate()\n"
  },
  {
    "path": "api/middleware.py",
    "content": "class FingerPrintMiddleware:\n    def __init__(self, get_response):\n        self.get_response = get_response\n        # One-time configuration and initializatio\n\n    def __call__(self, request):\n        response = self.get_response(request)\n        import hashlib\n\n        fingerprint_raw = \"\".join(\n            (\n                request.META.get(\"HTTP_USER_AGENT\", \"\"),\n                request.META.get(\"HTTP_ACCEPT_ENCODING\", \"\"),\n            )\n        )\n        # print(fingerprint_raw)\n        fingerprint = hashlib.md5(fingerprint_raw.encode(\"utf-8\")).hexdigest()\n        request.fingerprint = fingerprint\n        # print(fingerprint)\n        return response\n"
  },
  {
    "path": "api/migrations/0001_initial.py",
    "content": "# Generated by Django 2.1.2 on 2020-11-15 18:49\n\nimport datetime\n\nimport django.contrib.auth.models\nimport django.contrib.auth.validators\nimport django.contrib.postgres.fields.jsonb\nimport django.utils.timezone\nimport django_cryptography.fields\nfrom django.conf import settings\nfrom django.db import migrations, models\n\nimport api.models\n\n\nclass Migration(migrations.Migration):\n    initial = True\n\n    dependencies = [\n        (\"auth\", \"0009_alter_user_last_name_max_length\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"User\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"password\", models.CharField(max_length=128, verbose_name=\"password\")),\n                (\n                    \"last_login\",\n                    models.DateTimeField(\n                        blank=True, null=True, verbose_name=\"last login\"\n                    ),\n                ),\n                (\n                    \"is_superuser\",\n                    models.BooleanField(\n                        default=False,\n                        help_text=\"Designates that this user has all permissions without explicitly assigning them.\",\n                        verbose_name=\"superuser status\",\n                    ),\n                ),\n                (\n                    \"username\",\n                    models.CharField(\n                        error_messages={\n                            \"unique\": \"A user with that username already exists.\"\n                        },\n                        help_text=\"Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.\",\n                        max_length=150,\n                        unique=True,\n                        validators=[\n                            django.contrib.auth.validators.UnicodeUsernameValidator()\n                        ],\n                        verbose_name=\"username\",\n                    ),\n                ),\n                (\n                    \"first_name\",\n                    models.CharField(\n                        blank=True, max_length=30, verbose_name=\"first name\"\n                    ),\n                ),\n                (\n                    \"last_name\",\n                    models.CharField(\n                        blank=True, max_length=150, verbose_name=\"last name\"\n                    ),\n                ),\n                (\n                    \"email\",\n                    models.EmailField(\n                        blank=True, max_length=254, verbose_name=\"email address\"\n                    ),\n                ),\n                (\n                    \"is_staff\",\n                    models.BooleanField(\n                        default=False,\n                        help_text=\"Designates whether the user can log into this admin site.\",\n                        verbose_name=\"staff status\",\n                    ),\n                ),\n                (\n                    \"is_active\",\n                    models.BooleanField(\n                        default=True,\n                        help_text=\"Designates whether this user should be treated as active. Unselect this instead of deleting accounts.\",\n                        verbose_name=\"active\",\n                    ),\n                ),\n                (\n                    \"date_joined\",\n                    models.DateTimeField(\n                        default=django.utils.timezone.now, verbose_name=\"date joined\"\n                    ),\n                ),\n                (\"scan_directory\", models.CharField(db_index=True, max_length=512)),\n                (\"avatar\", models.ImageField(null=True, upload_to=\"avatars\")),\n                (\n                    \"nextcloud_server_address\",\n                    models.CharField(default=None, max_length=200, null=True),\n                ),\n                (\n                    \"nextcloud_username\",\n                    models.CharField(default=None, max_length=64, null=True),\n                ),\n                (\n                    \"nextcloud_app_password\",\n                    django_cryptography.fields.encrypt(\n                        models.CharField(default=None, max_length=64, null=True)\n                    ),\n                ),\n                (\n                    \"nextcloud_scan_directory\",\n                    models.CharField(db_index=True, max_length=512, null=True),\n                ),\n                (\n                    \"groups\",\n                    models.ManyToManyField(\n                        blank=True,\n                        help_text=\"The groups this user belongs to. A user will get all permissions granted to each of their groups.\",\n                        related_name=\"user_set\",\n                        related_query_name=\"user\",\n                        to=\"auth.Group\",\n                        verbose_name=\"groups\",\n                    ),\n                ),\n                (\n                    \"user_permissions\",\n                    models.ManyToManyField(\n                        blank=True,\n                        help_text=\"Specific permissions for this user.\",\n                        related_name=\"user_set\",\n                        related_query_name=\"user\",\n                        to=\"auth.Permission\",\n                        verbose_name=\"user permissions\",\n                    ),\n                ),\n            ],\n            options={\n                \"verbose_name\": \"user\",\n                \"verbose_name_plural\": \"users\",\n                \"abstract\": False,\n            },\n            managers=[\n                (\"objects\", django.contrib.auth.models.UserManager()),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"AlbumAuto\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"title\", models.CharField(blank=True, max_length=512, null=True)),\n                (\"timestamp\", models.DateTimeField(db_index=True)),\n                (\"created_on\", models.DateTimeField(db_index=True)),\n                (\"gps_lat\", models.FloatField(blank=True, null=True)),\n                (\"gps_lon\", models.FloatField(blank=True, null=True)),\n                (\"favorited\", models.BooleanField(db_index=True, default=False)),\n                (\n                    \"owner\",\n                    models.ForeignKey(\n                        default=None,\n                        on_delete=models.SET(api.models.user.get_deleted_user),\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"AlbumDate\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\n                    \"title\",\n                    models.CharField(\n                        blank=True, db_index=True, max_length=512, null=True\n                    ),\n                ),\n                (\"date\", models.DateField(db_index=True, null=True)),\n                (\"favorited\", models.BooleanField(db_index=True, default=False)),\n                (\n                    \"location\",\n                    django.contrib.postgres.fields.jsonb.JSONField(\n                        blank=True, db_index=True, null=True\n                    ),\n                ),\n                (\n                    \"owner\",\n                    models.ForeignKey(\n                        default=None,\n                        on_delete=models.SET(api.models.user.get_deleted_user),\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"AlbumPlace\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"title\", models.CharField(db_index=True, max_length=512)),\n                (\"geolocation_level\", models.IntegerField(db_index=True, null=True)),\n                (\"favorited\", models.BooleanField(db_index=True, default=False)),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"AlbumThing\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"title\", models.CharField(db_index=True, max_length=512)),\n                (\n                    \"thing_type\",\n                    models.CharField(db_index=True, max_length=512, null=True),\n                ),\n                (\"favorited\", models.BooleanField(db_index=True, default=False)),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"AlbumUser\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"title\", models.CharField(max_length=512)),\n                (\"created_on\", models.DateTimeField(auto_now=True, db_index=True)),\n                (\"favorited\", models.BooleanField(db_index=True, default=False)),\n                (\"public\", models.BooleanField(db_index=True, default=False)),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"Face\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"image\", models.ImageField(upload_to=\"faces\")),\n                (\"image_path\", models.FilePathField()),\n                (\"person_label_is_inferred\", models.NullBooleanField(db_index=True)),\n                (\n                    \"person_label_probability\",\n                    models.FloatField(db_index=True, default=0.0),\n                ),\n                (\"location_top\", models.IntegerField()),\n                (\"location_bottom\", models.IntegerField()),\n                (\"location_left\", models.IntegerField()),\n                (\"location_right\", models.IntegerField()),\n                (\"encoding\", models.TextField()),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"LongRunningJob\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\n                    \"job_type\",\n                    models.PositiveIntegerField(\n                        choices=[\n                            (1, \"Scan Photos\"),\n                            (2, \"Generate Event Albums\"),\n                            (3, \"Regenerate Event Titles\"),\n                            (4, \"Train Faces\"),\n                        ]\n                    ),\n                ),\n                (\"finished\", models.BooleanField(default=False)),\n                (\"failed\", models.BooleanField(default=False)),\n                (\"job_id\", models.CharField(db_index=True, max_length=36, unique=True)),\n                (\"queued_at\", models.DateTimeField(default=datetime.datetime.now)),\n                (\"started_at\", models.DateTimeField(null=True)),\n                (\"finished_at\", models.DateTimeField(null=True)),\n                (\n                    \"result\",\n                    django.contrib.postgres.fields.jsonb.JSONField(\n                        default={\"progress\": {\"target\": 0, \"current\": 0}}\n                    ),\n                ),\n                (\n                    \"started_by\",\n                    models.ForeignKey(\n                        default=None,\n                        on_delete=models.SET(api.models.user.get_deleted_user),\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"Person\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"name\", models.CharField(max_length=128)),\n                (\n                    \"kind\",\n                    models.CharField(\n                        choices=[\n                            (\"USER\", \"User Labelled\"),\n                            (\"CLUSTER\", \"Cluster ID\"),\n                            (\"UNKNOWN\", \"Unknown Person\"),\n                        ],\n                        max_length=10,\n                    ),\n                ),\n                (\"mean_face_encoding\", models.TextField()),\n                (\"cluster_id\", models.IntegerField(null=True)),\n                (\n                    \"account\",\n                    models.OneToOneField(\n                        default=None,\n                        null=True,\n                        on_delete=models.SET(api.models.user.get_deleted_user),\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"Photo\",\n            fields=[\n                (\"image_path\", models.CharField(db_index=True, max_length=512)),\n                (\n                    \"image_hash\",\n                    models.CharField(max_length=64, primary_key=True, serialize=False),\n                ),\n                (\"thumbnail\", models.ImageField(upload_to=\"thumbnails\")),\n                (\"thumbnail_tiny\", models.ImageField(upload_to=\"thumbnails_tiny\")),\n                (\"thumbnail_small\", models.ImageField(upload_to=\"thumbnails_small\")),\n                (\"thumbnail_big\", models.ImageField(upload_to=\"thumbnails_big\")),\n                (\"square_thumbnail\", models.ImageField(upload_to=\"square_thumbnails\")),\n                (\n                    \"square_thumbnail_tiny\",\n                    models.ImageField(upload_to=\"square_thumbnails_tiny\"),\n                ),\n                (\n                    \"square_thumbnail_small\",\n                    models.ImageField(upload_to=\"square_thumbnails_small\"),\n                ),\n                (\n                    \"square_thumbnail_big\",\n                    models.ImageField(upload_to=\"square_thumbnails_big\"),\n                ),\n                (\"image\", models.ImageField(upload_to=\"photos\")),\n                (\"added_on\", models.DateTimeField(db_index=True)),\n                (\"exif_gps_lat\", models.FloatField(blank=True, null=True)),\n                (\"exif_gps_lon\", models.FloatField(blank=True, null=True)),\n                (\n                    \"exif_timestamp\",\n                    models.DateTimeField(blank=True, db_index=True, null=True),\n                ),\n                (\n                    \"exif_json\",\n                    django.contrib.postgres.fields.jsonb.JSONField(\n                        blank=True, null=True\n                    ),\n                ),\n                (\n                    \"geolocation_json\",\n                    django.contrib.postgres.fields.jsonb.JSONField(\n                        blank=True, db_index=True, null=True\n                    ),\n                ),\n                (\n                    \"captions_json\",\n                    django.contrib.postgres.fields.jsonb.JSONField(\n                        blank=True, db_index=True, null=True\n                    ),\n                ),\n                (\n                    \"search_captions\",\n                    models.TextField(blank=True, db_index=True, null=True),\n                ),\n                (\n                    \"search_location\",\n                    models.TextField(blank=True, db_index=True, null=True),\n                ),\n                (\"favorited\", models.BooleanField(db_index=True, default=False)),\n                (\"hidden\", models.BooleanField(db_index=True, default=False)),\n                (\"public\", models.BooleanField(db_index=True, default=False)),\n                (\"encoding\", models.TextField(default=None, null=True)),\n                (\n                    \"owner\",\n                    models.ForeignKey(\n                        default=None,\n                        on_delete=models.SET(api.models.user.get_deleted_user),\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n                (\n                    \"shared_to\",\n                    models.ManyToManyField(\n                        related_name=\"photo_shared_to\", to=settings.AUTH_USER_MODEL\n                    ),\n                ),\n            ],\n        ),\n        migrations.AddField(\n            model_name=\"face\",\n            name=\"person\",\n            field=models.ForeignKey(\n                on_delete=models.SET(api.models.person.get_unknown_person),\n                related_name=\"faces\",\n                to=\"api.Person\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"face\",\n            name=\"photo\",\n            field=models.ForeignKey(\n                null=True,\n                on_delete=models.SET(api.models.person.get_unknown_person),\n                related_name=\"faces\",\n                to=\"api.Photo\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"albumuser\",\n            name=\"cover_photos\",\n            field=models.ManyToManyField(\n                related_name=\"album_user_cover_photos\", to=\"api.Photo\"\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"albumuser\",\n            name=\"owner\",\n            field=models.ForeignKey(\n                default=None,\n                on_delete=models.SET(api.models.user.get_deleted_user),\n                to=settings.AUTH_USER_MODEL,\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"albumuser\",\n            name=\"photos\",\n            field=models.ManyToManyField(to=\"api.Photo\"),\n        ),\n        migrations.AddField(\n            model_name=\"albumuser\",\n            name=\"shared_to\",\n            field=models.ManyToManyField(\n                related_name=\"album_user_shared_to\", to=settings.AUTH_USER_MODEL\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"albumthing\",\n            name=\"cover_photos\",\n            field=models.ManyToManyField(\n                related_name=\"album_thing_cover_photos\", to=\"api.Photo\"\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"albumthing\",\n            name=\"owner\",\n            field=models.ForeignKey(\n                default=None,\n                on_delete=models.SET(api.models.user.get_deleted_user),\n                to=settings.AUTH_USER_MODEL,\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"albumthing\",\n            name=\"photos\",\n            field=models.ManyToManyField(to=\"api.Photo\"),\n        ),\n        migrations.AddField(\n            model_name=\"albumthing\",\n            name=\"shared_to\",\n            field=models.ManyToManyField(\n                related_name=\"album_thing_shared_to\", to=settings.AUTH_USER_MODEL\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"albumplace\",\n            name=\"cover_photos\",\n            field=models.ManyToManyField(\n                related_name=\"album_place_cover_photos\", to=\"api.Photo\"\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"albumplace\",\n            name=\"owner\",\n            field=models.ForeignKey(\n                default=None,\n                on_delete=models.SET(api.models.user.get_deleted_user),\n                to=settings.AUTH_USER_MODEL,\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"albumplace\",\n            name=\"photos\",\n            field=models.ManyToManyField(to=\"api.Photo\"),\n        ),\n        migrations.AddField(\n            model_name=\"albumplace\",\n            name=\"shared_to\",\n            field=models.ManyToManyField(\n                related_name=\"album_place_shared_to\", to=settings.AUTH_USER_MODEL\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"albumdate\",\n            name=\"photos\",\n            field=models.ManyToManyField(to=\"api.Photo\"),\n        ),\n        migrations.AddField(\n            model_name=\"albumdate\",\n            name=\"shared_to\",\n            field=models.ManyToManyField(\n                related_name=\"album_date_shared_to\", to=settings.AUTH_USER_MODEL\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"albumauto\",\n            name=\"photos\",\n            field=models.ManyToManyField(to=\"api.Photo\"),\n        ),\n        migrations.AddField(\n            model_name=\"albumauto\",\n            name=\"shared_to\",\n            field=models.ManyToManyField(\n                related_name=\"album_auto_shared_to\", to=settings.AUTH_USER_MODEL\n            ),\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"albumuser\",\n            unique_together={(\"title\", \"owner\")},\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"albumthing\",\n            unique_together={(\"title\", \"owner\")},\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"albumplace\",\n            unique_together={(\"title\", \"owner\")},\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"albumdate\",\n            unique_together={(\"date\", \"owner\")},\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"albumauto\",\n            unique_together={(\"timestamp\", \"owner\")},\n        ),\n        migrations.RemoveField(model_name=\"albumplace\", name=\"cover_photos\"),\n        migrations.RemoveField(model_name=\"albumthing\", name=\"cover_photos\"),\n        migrations.RemoveField(model_name=\"albumuser\", name=\"cover_photos\"),\n    ]\n"
  },
  {
    "path": "api/migrations/0002_add_confidence.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0001_initial\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"User\",\n            name=\"confidence\",\n            field=models.FloatField(default=0.1, db_index=True),\n        )\n    ]\n"
  },
  {
    "path": "api/migrations/0003_remove_unused_thumbs.py",
    "content": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0002_add_confidence\"),\n    ]\n\n    operations = [\n        migrations.RemoveField(model_name=\"Photo\", name=\"thumbnail_tiny\"),\n        migrations.RemoveField(model_name=\"Photo\", name=\"thumbnail_small\"),\n        migrations.RemoveField(model_name=\"Photo\", name=\"thumbnail\"),\n        migrations.RemoveField(model_name=\"Photo\", name=\"square_thumbnail_tiny\"),\n        migrations.RemoveField(model_name=\"Photo\", name=\"square_thumbnail_big\"),\n    ]\n"
  },
  {
    "path": "api/migrations/0004_fix_album_thing_constraint.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0003_remove_unused_thumbs\"),\n    ]\n\n    operations = [\n        migrations.AlterUniqueTogether(\n            name=\"albumthing\",\n            unique_together=set([]),\n        ),\n        migrations.AddConstraint(\n            model_name=\"albumthing\",\n            constraint=models.UniqueConstraint(\n                fields=[\"title\", \"thing_type\", \"owner\"],\n                name=\"unique AlbumThing\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0005_add_video_to_photo.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0004_fix_album_thing_constraint\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"Photo\", name=\"video\", field=models.BooleanField(default=False)\n        )\n    ]\n"
  },
  {
    "path": "api/migrations/0006_migrate_to_boolean_field.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0005_add_video_to_photo\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"Face\",\n            name=\"person_label_is_inferred\",\n            field=models.BooleanField(null=True, db_index=True),\n        )\n    ]\n"
  },
  {
    "path": "api/migrations/0007_migrate_to_json_field.py",
    "content": "import json\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0006_migrate_to_boolean_field\"),\n    ]\n\n    def forwards_func(apps, schema):\n        Photo = apps.get_model(\"api\", \"Photo\")\n        for obj in Photo.objects.all():\n            try:\n                obj.image_paths.append(obj.image_path)\n                obj.save()\n            except json.decoder.JSONDecodeError:\n                print(\"Cannot convert {} object\".format(obj.image_path))\n\n    operations = [\n        migrations.AddField(\n            model_name=\"Photo\",\n            name=\"image_paths\",\n            field=models.JSONField(db_index=True, default=list),\n        ),\n        migrations.RunPython(forwards_func),\n    ]\n"
  },
  {
    "path": "api/migrations/0008_remove_image_path.py",
    "content": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0007_migrate_to_json_field\"),\n    ]\n\n    operations = [\n        migrations.RemoveField(model_name=\"Photo\", name=\"image_path\"),\n    ]\n"
  },
  {
    "path": "api/migrations/0009_add_aspect_ratio.py",
    "content": "import exiftool\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0008_remove_image_path\"),\n    ]\n\n    def forwards_func(apps, schema):\n        Photo = apps.get_model(\"api\", \"Photo\")\n        with exiftool.ExifTool() as et:\n            for obj in Photo.objects.all():\n                if obj.thumbnail_big:\n                    try:\n                        height = et.get_tag(\"ImageHeight\", obj.thumbnail_big.path)\n                        width = et.get_tag(\"ImageWidth\", obj.thumbnail_big.path)\n                        obj.aspect_ratio = round((width / height), 2)\n                        obj.save()\n                    except Exception:\n                        print(\"Cannot convert {} object\".format(obj))\n\n    operations = [\n        migrations.AddField(\n            model_name=\"Photo\",\n            name=\"aspect_ratio\",\n            field=models.FloatField(blank=True, null=True),\n        ),\n        migrations.RunPython(forwards_func),\n    ]\n"
  },
  {
    "path": "api/migrations/0009_add_clip_embedding_field.py",
    "content": "from django.contrib.postgres.fields import ArrayField\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0008_remove_image_path\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"Photo\",\n            name=\"clip_embeddings\",\n            field=ArrayField(\n                models.FloatField(blank=True, null=True), size=512, null=True\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"Photo\",\n            name=\"clip_embeddings_magnitude\",\n            field=models.FloatField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"User\",\n            name=\"semantic_search_topk\",\n            field=models.IntegerField(default=0, null=False),\n        ),\n        migrations.RemoveField(\n            model_name=\"Photo\",\n            name=\"encoding\",\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0010_merge_20210725_1547.py",
    "content": "# Generated by Django 3.1.8 on 2021-07-25 21:47\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0009_add_aspect_ratio\"),\n        (\"api\", \"0009_add_clip_embedding_field\"),\n    ]\n\n    operations = []\n"
  },
  {
    "path": "api/migrations/0011_a_add_rating.py",
    "content": "# Generated by Django 3.1.8 on 2021-08-06 11:32\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0010_merge_20210725_1547\"),\n    ]\n\n    run_before = [(\"api\", \"0011_b_migrate_favorited_to_rating\")]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"rating\",\n            field=models.IntegerField(db_index=True, default=0),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0011_b_migrate_favorited_to_rating.py",
    "content": "# Generated by Django 3.1.8 on 2021-08-06 11:32\n\nfrom django.db import migrations\n\n\ndef favorited_to_rating(apps, schema_editor):\n    Photo = apps.get_model(\"api\", \"Photo\")\n    for photo in Photo.objects.all():\n        photo.rating = 4 if photo.favorited else 0\n        photo.save()\n\n\ndef rating_to_favorited(apps, schema_editor):\n    Photo = apps.get_model(\"api\", \"Photo\")\n    for photo in Photo.objects.all():\n        photo.favorited = photo.rating >= 4\n        photo.save()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0011_a_add_rating\"),\n    ]\n\n    run_before = [(\"api\", \"0011_c_remove_favorited\")]\n\n    operations = [migrations.RunPython(favorited_to_rating, rating_to_favorited)]\n"
  },
  {
    "path": "api/migrations/0011_c_remove_favorited.py",
    "content": "# Generated by Django 3.1.8 on 2021-08-06 11:32\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0011_b_migrate_favorited_to_rating\"),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"favorited\",\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0012_add_favorite_min_rating.py",
    "content": "# Generated by Django 3.1.8 on 2021-08-08 17:05\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0011_c_remove_favorited\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"favorite_min_rating\",\n            field=models.IntegerField(db_index=True, default=4),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0013_add_image_scale_and_misc.py",
    "content": "# Generated by Django 3.1.8 on 2021-09-09 16:06\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0012_add_favorite_min_rating\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"image_scale\",\n            field=models.FloatField(default=1),\n        ),\n        migrations.AlterField(\n            model_name=\"albumdate\",\n            name=\"location\",\n            field=models.JSONField(blank=True, db_index=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"face\",\n            name=\"image\",\n            field=models.ImageField(null=True, upload_to=\"faces\"),\n        ),\n        migrations.AlterField(\n            model_name=\"face\",\n            name=\"photo\",\n            field=models.ForeignKey(\n                null=True,\n                on_delete=django.db.models.deletion.CASCADE,\n                related_name=\"faces\",\n                to=\"api.photo\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"longrunningjob\",\n            name=\"job_type\",\n            field=models.PositiveIntegerField(\n                choices=[\n                    (1, \"Scan Photos\"),\n                    (2, \"Generate Event Albums\"),\n                    (3, \"Regenerate Event Titles\"),\n                    (4, \"Train Faces\"),\n                    (5, \"Delete Missing Photos\"),\n                    (7, \"Scan Faces\"),\n                    (6, \"Calculate Clip Embeddings\"),\n                ]\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"longrunningjob\",\n            name=\"result\",\n            field=models.JSONField(default={\"progress\": {\"target\": 0, \"current\": 0}}),\n        ),\n        migrations.AlterField(\n            model_name=\"photo\",\n            name=\"captions_json\",\n            field=models.JSONField(blank=True, db_index=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"photo\",\n            name=\"exif_json\",\n            field=models.JSONField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"photo\",\n            name=\"geolocation_json\",\n            field=models.JSONField(blank=True, db_index=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"photo\",\n            name=\"image_paths\",\n            field=models.JSONField(default=list),\n        ),\n        migrations.AlterField(\n            model_name=\"user\",\n            name=\"first_name\",\n            field=models.CharField(\n                blank=True, max_length=150, verbose_name=\"first name\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0014_add_save_metadata_to_disk.py",
    "content": "# Generated by Django 3.1.8 on 2021-08-08 17:05\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0013_add_image_scale_and_misc\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"save_metadata_to_disk\",\n            field=models.TextField(\n                choices=[\n                    (\"OFF\", \"Off\"),\n                    (\"MEDIA_FILE\", \"Media File\"),\n                    (\"SIDECAR_FILE\", \"Sidecar File\"),\n                ],\n                default=\"OFF\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0015_add_dominant_color.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0014_add_save_metadata_to_disk\"),\n    ]\n    operations = [\n        migrations.AddField(\n            model_name=\"Photo\",\n            name=\"dominant_color\",\n            field=models.TextField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0016_add_transcode_videos.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0015_add_dominant_color\"),\n    ]\n    operations = [\n        migrations.AddField(\n            model_name=\"User\",\n            name=\"transcode_videos\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0017_add_cover_photo.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0016_add_transcode_videos\"),\n    ]\n    operations = [\n        migrations.AddField(\n            model_name=\"Person\",\n            name=\"cover_photo\",\n            field=models.ForeignKey(\n                to=\"api.Photo\",\n                related_name=\"person\",\n                on_delete=models.PROTECT,\n                blank=False,\n                null=True,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0018_user_config_datetime_rules.py",
    "content": "# Generated by Django 3.1.14 on 2022-01-24 17:11\n\nfrom django.db import migrations, models\n\nimport api.models.user\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0017_add_cover_photo\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"config_datetime_rules\",\n            field=models.JSONField(\n                default=api.models.user.get_default_config_datetime_rules\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0019_change_config_datetime_rules.py",
    "content": "# Generated by Django 3.1.14 on 2022-01-24 17:11\n\nfrom django.db import migrations, models\n\nimport api.models.user\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0018_user_config_datetime_rules\"),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name=\"user\",\n            name=\"config_datetime_rules\",\n        ),\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"datetime_rules\",\n            field=models.JSONField(\n                default=api.models.user.get_default_config_datetime_rules\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0020_add_default_timezone.py",
    "content": "# Generated by Django 3.1.14 on 2022-01-24 17:11\n\nimport pytz\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0019_change_config_datetime_rules\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"default_timezone\",\n            field=models.TextField(\n                choices=[(x, x) for x in pytz.all_timezones],\n                default=\"UTC\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0021_remove_photo_image.py",
    "content": "# Generated by Django 3.1.14 on 2022-02-01 22:42\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0020_add_default_timezone\"),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"image\",\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0022_photo_video_length.py",
    "content": "# Generated by Django 3.1.14 on 2022-02-20 11:16\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0021_remove_photo_image\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"video_length\",\n            field=models.TextField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0023_photo_deleted.py",
    "content": "# Generated by Django 3.1.14 on 2022-02-23 21:29\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0022_photo_video_length\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"deleted\",\n            field=models.BooleanField(db_index=True, default=False),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0024_photo_timestamp.py",
    "content": "# Generated by Django 3.1.14 on 2022-03-18 10:35\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0023_photo_deleted\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"timestamp\",\n            field=models.DateTimeField(blank=True, db_index=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0025_add_cover_photo.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0024_photo_timestamp\"),\n    ]\n    operations = [\n        migrations.AddField(\n            model_name=\"AlbumUser\",\n            name=\"cover_photo\",\n            field=models.ForeignKey(\n                to=\"api.Photo\",\n                related_name=\"album_user\",\n                on_delete=models.PROTECT,\n                blank=False,\n                null=True,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0026_add_cluster_info.py",
    "content": "# Generated by Django 3.1.14 on 2022-07-15 21:19\n\nimport django.db.models.deletion\nimport django.db.models.manager\nfrom django.conf import settings\nfrom django.db import migrations, models\n\nimport api.models.person\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0025_add_cover_photo\"),\n    ]\n\n    operations = [\n        migrations.AlterModelManagers(\n            name=\"albumdate\",\n            managers=[\n                (\"visible\", django.db.models.manager.Manager()),\n            ],\n        ),\n        migrations.RemoveField(\n            model_name=\"person\",\n            name=\"cluster_id\",\n        ),\n        migrations.AlterField(\n            model_name=\"person\",\n            name=\"mean_face_encoding\",\n            field=models.TextField(default=\"default\"),\n        ),\n        migrations.RemoveField(\n            model_name=\"person\",\n            name=\"mean_face_encoding\",\n        ),\n        migrations.AlterField(\n            model_name=\"longrunningjob\",\n            name=\"job_type\",\n            field=models.PositiveIntegerField(\n                choices=[\n                    (1, \"Scan Photos\"),\n                    (2, \"Generate Event Albums\"),\n                    (3, \"Regenerate Event Titles\"),\n                    (4, \"Train Faces\"),\n                    (5, \"Delete Missing Photos\"),\n                    (7, \"Scan Faces\"),\n                    (6, \"Calculate Clip Embeddings\"),\n                    (8, \"Find Similar Faces\"),\n                ]\n            ),\n        ),\n        migrations.CreateModel(\n            name=\"Cluster\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"mean_face_encoding\", models.TextField()),\n                (\"cluster_id\", models.IntegerField(null=True)),\n                (\"name\", models.TextField(null=True)),\n                (\n                    \"person\",\n                    models.ForeignKey(\n                        blank=True,\n                        null=True,\n                        on_delete=models.SET(api.models.person.get_unknown_person),\n                        related_name=\"clusters\",\n                        to=\"api.person\",\n                    ),\n                ),\n                (\n                    \"owner\",\n                    models.ForeignKey(\n                        default=None,\n                        null=True,\n                        on_delete=models.SET(api.models.user.get_deleted_user),\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n        migrations.AddField(\n            model_name=\"face\",\n            name=\"cluster\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=models.SET(api.models.cluster.get_unknown_cluster),\n                related_name=\"faces\",\n                to=\"api.cluster\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"person\",\n            name=\"cluster_owner\",\n            field=models.ForeignKey(\n                default=None,\n                null=True,\n                related_name=\"owner\",\n                on_delete=models.SET(api.models.user.get_deleted_user),\n                to=settings.AUTH_USER_MODEL,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0027_rename_unknown_person.py",
    "content": "# Generated by Django 3.1.14 on 2022-07-17 19:07\nfrom django.db import migrations\n\nUNKNOWN_PERSON_NAME = \"Unknown - Other\"\nKIND_UNKNOWN = \"UNKNOWN\"\n\n\ndef migrate_unknown(apps, schema_editor):\n    Person = apps.get_model(\"api\", \"Person\")\n    person: Person\n    try:\n        person = Person.objects.get(name=\"unknown\")\n        person.name = UNKNOWN_PERSON_NAME\n        person.kind = KIND_UNKNOWN\n        person.save()\n    except Person.DoesNotExist:\n        unknown_person: Person = Person.objects.get_or_create(\n            name=UNKNOWN_PERSON_NAME, cluster_owner=None, kind=KIND_UNKNOWN\n        )[0]\n        if unknown_person.kind != KIND_UNKNOWN:\n            unknown_person.kind = KIND_UNKNOWN\n            unknown_person.save()\n\n\ndef unmigrate_unknown(apps, schema_editor):\n    Person = apps.get_model(\"api\", \"Person\")\n    try:\n        person: Person = Person.objects.get(name=UNKNOWN_PERSON_NAME)\n        person.name = \"unknown\"\n        person.kind = \"\"\n        person.save()\n    except Person.DoesNotExist:\n        pass\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0026_add_cluster_info\"),\n    ]\n\n    operations = [migrations.RunPython(migrate_unknown, unmigrate_unknown)]\n"
  },
  {
    "path": "api/migrations/0028_add_metadata_fields.py",
    "content": "# Generated by Django 3.1.14 on 2022-07-30 11:47\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0027_rename_unknown_person\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"camera\",\n            field=models.TextField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"digitalZoomRatio\",\n            field=models.FloatField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"focalLength35Equivalent\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"focal_length\",\n            field=models.FloatField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"fstop\",\n            field=models.FloatField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"height\",\n            field=models.IntegerField(default=0),\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"iso\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"lens\",\n            field=models.TextField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"shutter_speed\",\n            field=models.FloatField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"size\",\n            field=models.IntegerField(default=0),\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"subjectDistance\",\n            field=models.FloatField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"width\",\n            field=models.IntegerField(default=0),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0029_change_to_text_field.py",
    "content": "# Generated by Django 3.1.14 on 2022-07-31 11:35\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0028_add_metadata_fields\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"photo\",\n            name=\"shutter_speed\",\n            field=models.TextField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0030_user_confidence_person.py",
    "content": "# Generated by Django 3.1.14 on 2022-08-08 13:39\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0029_change_to_text_field\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"confidence_person\",\n            field=models.FloatField(default=0.9),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0031_remove_account.py",
    "content": "# Generated by Django 3.1.14 on 2022-09-01 16:28\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0030_user_confidence_person\"),\n    ]\n\n    operations = [\n        migrations.AlterModelManagers(\n            name=\"albumdate\",\n            managers=[],\n        ),\n        migrations.RemoveField(\n            model_name=\"person\",\n            name=\"account\",\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0032_always_have_owner.py",
    "content": "# Generated by Django 3.1.8 on 2021-08-06 11:32\n\nfrom django.db import migrations\n\n\ndef add_cluster_owner(apps, schema_editor):\n    Person = apps.get_model(\"api\", \"Person\")\n    for person in Person.objects.all():\n        if person.faces.first():\n            person.cluster_owner = person.faces.first().photo.owner\n            person.save()\n\n\ndef remove_cluster_owner(apps, schema_editor):\n    Person = apps.get_model(\"api\", \"Person\")\n    for person in Person.objects.all():\n        person.cluster_owner = None\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0031_remove_account\"),\n    ]\n\n    operations = [migrations.RunPython(add_cluster_owner, remove_cluster_owner)]\n"
  },
  {
    "path": "api/migrations/0033_add_post_delete_person.py",
    "content": "# Generated by Django 3.1.14 on 2022-09-02 10:23\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0032_always_have_owner\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"face\",\n            name=\"person\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.DO_NOTHING,\n                related_name=\"faces\",\n                to=\"api.person\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0034_allow_deleting_person.py",
    "content": "# Generated by Django 3.1.14 on 2022-09-02 10:26\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0033_add_post_delete_person\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"person\",\n            name=\"cover_photo\",\n            field=models.ForeignKey(\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"person\",\n                to=\"api.photo\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0035_add_files_model.py",
    "content": "# Generated by Django 3.1.14 on 2022-11-09 17:35\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0034_allow_deleting_person\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"File\",\n            fields=[\n                (\n                    \"hash\",\n                    models.CharField(max_length=64, primary_key=True, serialize=False),\n                ),\n                (\"path\", models.TextField(blank=True, null=True)),\n                (\n                    \"type\",\n                    models.PositiveIntegerField(\n                        blank=True,\n                        choices=[\n                            (1, \"Image\"),\n                            (2, \"Video\"),\n                            (3, \"Metadata File e.g. XMP\"),\n                            (4, \"Raw File\"),\n                        ],\n                    ),\n                ),\n            ],\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"files\",\n            field=models.ManyToManyField(to=\"api.File\"),\n        ),\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"main_file\",\n            field=models.ForeignKey(\n                null=True,\n                on_delete=django.db.models.deletion.PROTECT,\n                related_name=\"main_photo\",\n                to=\"api.file\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0036_handle_missing_files.py",
    "content": "# Generated by Django 3.1.14 on 2022-11-10 08:41\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0035_add_files_model\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"file\",\n            name=\"missing\",\n            field=models.BooleanField(default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"file\",\n            name=\"type\",\n            field=models.PositiveIntegerField(\n                blank=True,\n                choices=[\n                    (1, \"Image\"),\n                    (2, \"Video\"),\n                    (3, \"Metadata File e.g. XMP\"),\n                    (4, \"Raw File\"),\n                    (5, \"Unknown\"),\n                ],\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0037_migrate_to_files.py",
    "content": "import os\n\nfrom django.db import migrations\n\nfrom api.models.file import is_metadata, is_raw, is_video\n\nIMAGE = 1\nVIDEO = 2\nMETADATA_FILE = 3\nRAW_FILE = 4\nUNKNOWN = 5\n\n\ndef find_out_type(path):\n    if is_raw(path):\n        return RAW_FILE\n    if is_video(path):\n        return VIDEO\n    if is_metadata(path):\n        return METADATA_FILE\n    return IMAGE\n\n\ndef migrate_to_files(apps, schema_editor):\n    Photo = apps.get_model(\"api\", \"Photo\")\n    File = apps.get_model(\"api\", \"File\")\n    for photo in Photo.objects.all():\n        if photo.image_paths:\n            for path in photo.image_paths:\n                file: File = File()\n                file.path = path\n                if os.path.exists(path):\n                    file.type = find_out_type(path)\n                else:\n                    file.type = UNKNOWN\n                    if photo.video:\n                        file.type = VIDEO\n                    file.missing = True\n                # This is fine, because at this point all files that belong to a photo have the same hash\n                file.hash = photo.image_hash\n                file.save()\n                photo.files.add(file)\n                photo.save()\n        # handle missing photos\n        else:\n            file: File = File()\n            file.path = None\n            file.type = UNKNOWN\n            file.missing = True\n            file.hash = photo.image_hash\n            file.save()\n            photo.files.add(file)\n            photo.save()\n\n\ndef remove_files(apps, schema_editor):\n    File = apps.get_model(\"api\", \"File\")\n    for file in File.objects.all():\n        file.delete()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0036_handle_missing_files\"),\n    ]\n\n    operations = [migrations.RunPython(migrate_to_files, remove_files)]\n"
  },
  {
    "path": "api/migrations/0038_add_main_file.py",
    "content": "from django.db import migrations\n\nfrom api.models.file import is_metadata, is_raw, is_video\n\nIMAGE = 1\nVIDEO = 2\nMETADATA_FILE = 3\nRAW_FILE = 4\nUNKNOWN = 5\n\n\ndef find_out_type(path):\n    if is_raw(path):\n        return RAW_FILE\n    if is_video(path):\n        return VIDEO\n    if is_metadata(path):\n        return METADATA_FILE\n    return IMAGE\n\n\ndef add_main_file(apps, schema_editor):\n    Photo = apps.get_model(\"api\", \"Photo\")\n    for photo in Photo.objects.all():\n        if photo.files.count() > 0:\n            photo.main_file = photo.files.first()\n            photo.save()\n\n\ndef remove_main_file(apps, schema_editor):\n    Photo = apps.get_model(\"api\", \"Photo\")\n    for photo in Photo.objects.all():\n        photo.main_file = None\n        photo.save()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0037_migrate_to_files\"),\n    ]\n\n    operations = [migrations.RunPython(add_main_file, remove_main_file)]\n"
  },
  {
    "path": "api/migrations/0039_remove_photo_image_paths.py",
    "content": "# Generated by Django 3.1.14 on 2022-12-21 09:24\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0038_add_main_file\"),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"image_paths\",\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0040_add_user_public_sharing_flag.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0039_remove_photo_image_paths\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"public_sharing\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0041_apply_user_enum_for_person.py",
    "content": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    def apply_enum(apps, schema_editor):\n        Person = apps.get_model(\"api\", \"Person\")\n        for person in Person.objects.filter(kind=\"\").all():\n            person.kind = \"USER\"\n            person.save()\n\n    def remove_enum(apps, schema_editor):\n        Person = apps.get_model(\"api\", \"Person\")\n        for person in Person.objects.filter(kind=\"\").all():\n            person.kind = \"\"\n            person.save()\n\n    dependencies = [\n        (\"api\", \"0040_add_user_public_sharing_flag\"),\n    ]\n\n    operations = [migrations.RunPython(apply_enum, remove_enum)]\n"
  },
  {
    "path": "api/migrations/0042_alter_albumuser_cover_photo_alter_photo_main_file.py",
    "content": "# Generated by Django 4.2rc1 on 2023-04-04 09:14\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0041_apply_user_enum_for_person\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"albumuser\",\n            name=\"cover_photo\",\n            field=models.ForeignKey(\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"album_user\",\n                to=\"api.photo\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"photo\",\n            name=\"main_file\",\n            field=models.ForeignKey(\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"main_photo\",\n                to=\"api.file\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0043_alter_photo_size.py",
    "content": "# Generated by Django 4.2rc1 on 2023-04-05 07:34\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0042_alter_albumuser_cover_photo_alter_photo_main_file\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"photo\",\n            name=\"size\",\n            field=models.BigIntegerField(default=0),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0044_alter_cluster_person_alter_person_cluster_owner.py",
    "content": "# Generated by Django 4.2rc1 on 2023-04-07 19:02\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0043_alter_photo_size\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"cluster\",\n            name=\"person\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"clusters\",\n                to=\"api.person\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"person\",\n            name=\"cluster_owner\",\n            field=models.ForeignKey(\n                default=None,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"owner\",\n                to=settings.AUTH_USER_MODEL,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0045_alter_face_cluster.py",
    "content": "# Generated by Django 4.2rc1 on 2023-04-07 19:15\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0044_alter_cluster_person_alter_person_cluster_owner\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"face\",\n            name=\"cluster\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"faces\",\n                to=\"api.cluster\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0046_add_embedded_media.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0045_alter_face_cluster\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"file\",\n            name=\"embedded_media\",\n            field=models.ManyToManyField(\"File\"),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0047_alter_file_embedded_media.py",
    "content": "# Generated by Django 4.2rc1 on 2023-04-15 10:42\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0046_add_embedded_media\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"file\",\n            name=\"embedded_media\",\n            field=models.ManyToManyField(to=\"api.file\"),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0048_fix_null_height.py",
    "content": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0047_alter_file_embedded_media\"),\n    ]\n\n    operations = [\n        migrations.RunSQL(\"UPDATE api_photo SET height=0 WHERE height IS NULL;\")\n    ]\n"
  },
  {
    "path": "api/migrations/0049_fix_metadata_files_as_main_files.py",
    "content": "from django.db import migrations\n\n\ndef delete_photos_with_metadata_as_main(apps, schema_editor):\n    Photo = apps.get_model(\"api\", \"Photo\")\n    for photo in Photo.objects.filter(main_file__type=3):\n        photo.delete()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0048_fix_null_height\"),\n    ]\n\n    operations = [\n        migrations.RunPython(delete_photos_with_metadata_as_main),\n    ]\n"
  },
  {
    "path": "api/migrations/0050_person_face_count.py",
    "content": "# Generated by Django 4.2.1 on 2023-06-20 09:46\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0049_fix_metadata_files_as_main_files\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"person\",\n            name=\"face_count\",\n            field=models.IntegerField(default=0),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0051_set_person_defaults.py",
    "content": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    def apply_default(apps, schema_editor):\n        Person = apps.get_model(\"api\", \"Person\")\n        User = apps.get_model(\"api\", \"User\")\n\n        for person in Person.objects.filter(kind=\"USER\").all():\n            number_of_faces = person.faces.filter(\n                photo__hidden=False,\n                photo__deleted=False,\n                photo__owner=person.cluster_owner.id,\n            ).count()\n            if not person.cover_photo and number_of_faces > 0:\n                person.cover_photo = (\n                    person.faces.filter(\n                        photo__hidden=False,\n                        photo__deleted=False,\n                        photo__owner=person.cluster_owner.id,\n                    )\n                    .first()\n                    .photo\n                )\n            confidence_person = (\n                User.objects.filter(id=person.cluster_owner.id)\n                .first()\n                .confidence_person\n            )\n            person.face_count = person.faces.filter(\n                photo__hidden=False,\n                photo__deleted=False,\n                photo__owner=person.cluster_owner.id,\n                person_label_probability__gte=confidence_person,\n            ).count()\n            person.save()\n\n    def remove_default(apps, schema_editor):\n        Person = apps.get_model(\"api\", \"Person\")\n        for person in Person.objects.all():\n            person.face_count = 0\n            person.save()\n\n    dependencies = [\n        (\"api\", \"0050_person_face_count\"),\n    ]\n\n    operations = [migrations.RunPython(apply_default, remove_default)]\n"
  },
  {
    "path": "api/migrations/0052_alter_person_name.py",
    "content": "# Generated by Django 4.2.1 on 2023-06-26 12:14\n\nimport django.core.validators\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0051_set_person_defaults\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"person\",\n            name=\"name\",\n            field=models.CharField(\n                max_length=128,\n                validators=[django.core.validators.MinLengthValidator(1)],\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0053_user_confidence_unknown_face_and_more.py",
    "content": "# Generated by Django 4.2.1 on 2023-07-09 11:08\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0052_alter_person_name\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"confidence_unknown_face\",\n            field=models.FloatField(default=0.5),\n        ),\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"face_recognition_model\",\n            field=models.TextField(\n                choices=[(\"HOG\", \"Hog\"), (\"CNN\", \"Cnn\")], default=\"HOG\"\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"min_cluster_size\",\n            field=models.IntegerField(default=0),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0054_user_cluster_selection_epsilon_user_min_samples.py",
    "content": "# Generated by Django 4.2.1 on 2023-07-11 11:06\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0053_user_confidence_unknown_face_and_more\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"cluster_selection_epsilon\",\n            field=models.FloatField(default=0.05),\n        ),\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"min_samples\",\n            field=models.IntegerField(default=1),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0055_alter_longrunningjob_job_type.py",
    "content": "# Generated by Django 4.2.6 on 2023-10-27 13:01\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0054_user_cluster_selection_epsilon_user_min_samples\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"longrunningjob\",\n            name=\"job_type\",\n            field=models.PositiveIntegerField(\n                choices=[\n                    (1, \"Scan Photos\"),\n                    (2, \"Generate Event Albums\"),\n                    (3, \"Regenerate Event Titles\"),\n                    (4, \"Train Faces\"),\n                    (5, \"Delete Missing Photos\"),\n                    (7, \"Scan Faces\"),\n                    (6, \"Calculate Clip Embeddings\"),\n                    (8, \"Find Similar Faces\"),\n                    (9, \"Download Selected Photos\"),\n                ]\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0056_user_llm_settings_alter_longrunningjob_job_type.py",
    "content": "# Generated by Django 4.2.8 on 2023-12-21 11:16\n\nfrom django.db import migrations, models\n\nimport api.models.user\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0055_alter_longrunningjob_job_type\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"llm_settings\",\n            field=models.JSONField(default=api.models.user.get_default_llm_settings),\n        ),\n        migrations.AlterField(\n            model_name=\"longrunningjob\",\n            name=\"job_type\",\n            field=models.PositiveIntegerField(\n                choices=[\n                    (1, \"Scan Photos\"),\n                    (2, \"Generate Event Albums\"),\n                    (3, \"Regenerate Event Titles\"),\n                    (4, \"Train Faces\"),\n                    (5, \"Delete Missing Photos\"),\n                    (7, \"Scan Faces\"),\n                    (6, \"Calculate Clip Embeddings\"),\n                    (8, \"Find Similar Faces\"),\n                    (9, \"Download Selected Photos\"),\n                    (10, \"Download Models\"),\n                ]\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0057_remove_face_image_path_and_more.py",
    "content": "# Generated by Django 4.2.8 on 2024-01-10 17:12\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0056_user_llm_settings_alter_longrunningjob_job_type\"),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name=\"face\",\n            name=\"image_path\",\n        ),\n        migrations.AlterField(\n            model_name=\"face\",\n            name=\"person_label_is_inferred\",\n            field=models.BooleanField(db_index=True, default=False),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0058_alter_user_avatar_alter_user_nextcloud_app_password_and_more.py",
    "content": "# Generated by Django 4.2.9 on 2024-02-02 16:36\n\nimport django_cryptography.fields\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0057_remove_face_image_path_and_more\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"user\",\n            name=\"avatar\",\n            field=models.ImageField(blank=True, null=True, upload_to=\"avatars\"),\n        ),\n        migrations.AlterField(\n            model_name=\"user\",\n            name=\"nextcloud_app_password\",\n            field=django_cryptography.fields.encrypt(\n                models.CharField(blank=True, default=None, max_length=64, null=True)\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"user\",\n            name=\"nextcloud_scan_directory\",\n            field=models.CharField(\n                blank=True, db_index=True, max_length=512, null=True\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"user\",\n            name=\"nextcloud_server_address\",\n            field=models.CharField(blank=True, default=None, max_length=200, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"user\",\n            name=\"nextcloud_username\",\n            field=models.CharField(blank=True, default=None, max_length=64, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0059_person_cover_face.py",
    "content": "# Generated by Django 4.2.11 on 2024-03-29 16:03\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0058_alter_user_avatar_alter_user_nextcloud_app_password_and_more\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"person\",\n            name=\"cover_face\",\n            field=models.ForeignKey(\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"face\",\n                to=\"api.face\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0060_apply_default_face_cover.py",
    "content": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    def apply_default(apps, schema_editor):\n        Person = apps.get_model(\"api\", \"Person\")\n\n        for person in Person.objects.filter(kind=\"USER\").all():\n            if not person.cover_face and person.faces.count() > 0:\n                person.cover_face = person.faces.first()\n                person.save()\n            if (\n                not person.cover_face\n                and person.cover_photo\n                and person.cover_photo.faces.count() > 0\n            ):\n                person.cover_face = person.cover_photo.faces.filter(\n                    person__name=person.name\n                ).first()\n                person.save()\n\n    def remove_default(apps, schema_editor):\n        Person = apps.get_model(\"api\", \"Person\")\n        for person in Person.objects.all():\n            person.cover_face = None\n\n    dependencies = [\n        (\"api\", \"0059_person_cover_face\"),\n    ]\n\n    operations = [migrations.RunPython(apply_default, remove_default)]\n"
  },
  {
    "path": "api/migrations/0061_alter_person_name.py",
    "content": "# Generated by Django 4.2.11 on 2024-03-29 17:20\n\nimport django.core.validators\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0060_apply_default_face_cover\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"person\",\n            name=\"name\",\n            field=models.CharField(\n                db_index=True,\n                max_length=128,\n                validators=[django.core.validators.MinLengthValidator(1)],\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0062_albumthing_cover_photos.py",
    "content": "# Generated by Django 4.2.11 on 2024-03-29 17:33\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0061_alter_person_name\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"albumthing\",\n            name=\"cover_photos\",\n            field=models.ManyToManyField(\n                related_name=\"album_thing_cover_photos\", to=\"api.photo\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0063_apply_default_album_things_cover.py",
    "content": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    def apply_default(apps, schema_editor):\n        AlbumThing = apps.get_model(\"api\", \"AlbumThing\")\n\n        for thing in AlbumThing.objects.all():\n            if thing.photos.count() > 0:\n                thing.cover_photos.add(*thing.photos.all()[:4])\n                thing.save()\n\n    def remove_default(apps, schema_editor):\n        AlbumThing = apps.get_model(\"api\", \"AlbumThing\")\n        for thing in AlbumThing.objects.all():\n            thing.cover_photos = None\n            thing.save()\n\n    dependencies = [\n        (\"api\", \"0062_albumthing_cover_photos\"),\n    ]\n\n    operations = [migrations.RunPython(apply_default, remove_default)]\n"
  },
  {
    "path": "api/migrations/0064_albumthing_photo_count.py",
    "content": "# Generated by Django 4.2.11 on 2024-03-30 13:20\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0063_apply_default_album_things_cover\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"albumthing\",\n            name=\"photo_count\",\n            field=models.IntegerField(default=0),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0065_apply_default_photo_count.py",
    "content": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    def apply_default(apps, schema_editor):\n        AlbumThing = apps.get_model(\"api\", \"AlbumThing\")\n\n        for thing in AlbumThing.objects.all():\n            thing.photo_count = thing.photos.filter(hidden=False).count()\n            thing.save()\n\n    def remove_default(apps, schema_editor):\n        AlbumThing = apps.get_model(\"api\", \"AlbumThing\")\n        for thing in AlbumThing.objects.all():\n            thing.photo_count = 0\n            thing.save()\n\n    dependencies = [\n        (\"api\", \"0064_albumthing_photo_count\"),\n    ]\n\n    operations = [migrations.RunPython(apply_default, remove_default)]\n"
  },
  {
    "path": "api/migrations/0066_photo_last_modified_alter_longrunningjob_job_type.py",
    "content": "# Generated by Django 4.2.13 on 2024-06-12 15:09\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0065_apply_default_photo_count\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"last_modified\",\n            field=models.DateTimeField(auto_now=True),\n        ),\n        migrations.AlterField(\n            model_name=\"longrunningjob\",\n            name=\"job_type\",\n            field=models.PositiveIntegerField(\n                choices=[\n                    (1, \"Scan Photos\"),\n                    (2, \"Generate Event Albums\"),\n                    (3, \"Regenerate Event Titles\"),\n                    (4, \"Train Faces\"),\n                    (5, \"Delete Missing Photos\"),\n                    (7, \"Scan Faces\"),\n                    (6, \"Calculate Clip Embeddings\"),\n                    (8, \"Find Similar Faces\"),\n                    (9, \"Download Selected Photos\"),\n                    (10, \"Download Models\"),\n                    (11, \"Add Geolocation\"),\n                    (12, \"Generate Tags\"),\n                ]\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0067_alter_longrunningjob_job_type.py",
    "content": "# Generated by Django 4.2.13 on 2024-06-16 15:40\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0066_photo_last_modified_alter_longrunningjob_job_type\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"longrunningjob\",\n            name=\"job_type\",\n            field=models.PositiveIntegerField(\n                choices=[\n                    (1, \"Scan Photos\"),\n                    (2, \"Generate Event Albums\"),\n                    (3, \"Regenerate Event Titles\"),\n                    (4, \"Train Faces\"),\n                    (5, \"Delete Missing Photos\"),\n                    (7, \"Scan Faces\"),\n                    (6, \"Calculate Clip Embeddings\"),\n                    (8, \"Find Similar Faces\"),\n                    (9, \"Download Selected Photos\"),\n                    (10, \"Download Models\"),\n                    (11, \"Add Geolocation\"),\n                    (12, \"Generate Tags\"),\n                    (13, \"Generate Face Embeddings\"),\n                ]\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0068_remove_longrunningjob_result_and_more.py",
    "content": "# Generated by Django 4.2.13 on 2024-06-18 13:18\nfrom django.db import migrations, models\n\n\ndef copy_progress_data(apps, schema_editor):\n    LongRunningJob = apps.get_model(\"api\", \"LongRunningJob\")\n    for job in LongRunningJob.objects.all():\n        result = job.result\n        job.progress_current = result[\"progress\"][\"current\"]\n        job.progress_target = result[\"progress\"][\"target\"]\n        job.save()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0067_alter_longrunningjob_job_type\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"longrunningjob\",\n            name=\"progress_current\",\n            field=models.PositiveIntegerField(default=0),\n        ),\n        migrations.AddField(\n            model_name=\"longrunningjob\",\n            name=\"progress_target\",\n            field=models.PositiveIntegerField(default=0),\n        ),\n        migrations.RunPython(copy_progress_data),\n        migrations.RemoveField(\n            model_name=\"longrunningjob\",\n            name=\"result\",\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0069_rename_to_in_trashcan.py",
    "content": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0068_remove_longrunningjob_result_and_more\"),\n    ]\n\n    operations = [\n        migrations.RenameField(\n            model_name=\"photo\",\n            old_name=\"deleted\",\n            new_name=\"in_trashcan\",\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0070_photo_removed.py",
    "content": "# Generated by Django 4.2.14 on 2024-08-21 19:17\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0069_rename_to_in_trashcan\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"photo\",\n            name=\"removed\",\n            field=models.BooleanField(db_index=True, default=False),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0071_rename_person_label_probability_face_cluster_probability_and_more.py",
    "content": "# Generated by Django 4.2.16 on 2024-09-20 18:56\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0070_photo_removed\"),\n    ]\n\n    operations = [\n        migrations.RenameField(\n            model_name=\"face\",\n            old_name=\"person_label_probability\",\n            new_name=\"cluster_probability\",\n        ),\n        migrations.RemoveField(\n            model_name=\"face\",\n            name=\"person_label_is_inferred\",\n        ),\n        migrations.AddField(\n            model_name=\"face\",\n            name=\"classification_person\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"classification_faces\",\n                to=\"api.person\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"face\",\n            name=\"classification_probability\",\n            field=models.FloatField(db_index=True, default=0.0),\n        ),\n        migrations.AddField(\n            model_name=\"face\",\n            name=\"cluster_person\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"cluster_classification_faces\",\n                to=\"api.person\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"face\",\n            name=\"deleted\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0072_alter_face_person.py",
    "content": "# Generated by Django 4.2.16 on 2024-09-20 19:07\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\n            \"api\",\n            \"0071_rename_person_label_probability_face_cluster_probability_and_more\",\n        ),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"face\",\n            name=\"person\",\n            field=models.ForeignKey(\n                null=True,\n                on_delete=django.db.models.deletion.DO_NOTHING,\n                related_name=\"faces\",\n                to=\"api.person\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0073_remove_unknown_person.py",
    "content": "from django.db import migrations\n\n\ndef delete_unknown_person_and_update_faces(apps, schema_editor):\n    # Get models\n    Person = apps.get_model(\"api\", \"Person\")\n    Face = apps.get_model(\"api\", \"Face\")\n\n    # Define the name for unknown persons\n    unknown_person_name = \"Unknown - Other\"\n\n    # Find all persons with the name \"Unknown - Other\"\n    unknown_persons = Person.objects.filter(name=unknown_person_name)\n\n    # Iterate through each unknown person and set faces' person field to null\n    for unknown_person in unknown_persons:\n        # Set all faces' person field referencing the \"Unknown - Other\" person to null\n        Face.objects.filter(person=unknown_person).update(person=None)\n\n        # Delete the \"Unknown - Other\" person\n        unknown_person.delete()\n\n\ndef recreate_unknown_person_and_restore_faces(apps, schema_editor):\n    # Get models\n    Person = apps.get_model(\"api\", \"Person\")\n    Face = apps.get_model(\"api\", \"Face\")\n    User = apps.get_model(\"api\", \"User\")\n\n    # Define the name for unknown persons\n    unknown_person_name = \"Unknown - Other\"\n\n    # Retrieve all users to recreate their unknown persons\n    users = User.objects.all()\n\n    for user in users:\n        # Recreate the \"Unknown - Other\" person for each user\n        unknown_person = Person.objects.create(\n            name=unknown_person_name, kind=Person.KIND_UNKNOWN, cluster_owner=user\n        )\n\n        # Restore faces for each recreated person based on user ownership\n        Face.objects.filter(person=None, photo__owner=user).update(\n            person=unknown_person\n        )\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\n            \"api\",\n            \"0072_alter_face_person\",\n        ),\n    ]\n\n    operations = [\n        migrations.RunPython(\n            delete_unknown_person_and_update_faces,\n            recreate_unknown_person_and_restore_faces,\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0074_migrate_cluster_person.py",
    "content": "from django.db import migrations\n\n\ndef move_person_to_cluster_if_kind_cluster(apps, schema_editor):\n    # Get the necessary models\n    Face = apps.get_model(\"api\", \"Face\")\n\n    # Define the constant for KIND_CLUSTER\n    KIND_CLUSTER = \"CLUSTER\"\n\n    # Fetch all Face instances where person is not null\n    faces_to_update = Face.objects.filter(person__isnull=False)\n\n    # Iterate over the faces and process each one\n    for face in faces_to_update:\n        # Check if the person is of type KIND_CLUSTER\n        if face.person.kind == KIND_CLUSTER:\n            # Move the person to the cluster field and set the person field to null\n            face.cluster_person = face.person\n            face.person = None\n            face.save()\n\n\ndef restore_person_from_cluster(apps, schema_editor):\n    # Get the necessary models\n    Face = apps.get_model(\"api\", \"Face\")\n\n    # Fetch all Face instances where original_person_id is not null (from forward migration)\n    faces_to_restore = Face.objects.filter(cluster_person__isnull=False)\n\n    # Iterate over the faces and restore the person reference from the original_person_id field\n    for face in faces_to_restore:\n        face.person = face.cluster_person\n        face.cluster_person = None\n        face.save()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\n            \"api\",\n            \"0073_remove_unknown_person\",\n        ),\n    ]\n\n    operations = [\n        migrations.RunPython(\n            move_person_to_cluster_if_kind_cluster,\n            restore_person_from_cluster,\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0075_alter_face_cluster_person.py",
    "content": "# Generated by Django 4.2.16 on 2024-09-20 19:49\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0074_migrate_cluster_person\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"face\",\n            name=\"cluster_person\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"cluster_faces\",\n                to=\"api.person\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0076_alter_file_path_alter_longrunningjob_job_type_and_more.py",
    "content": "# Generated by Django 4.2.18 on 2025-03-29 12:38\n\nfrom django.db import migrations, models\nimport django_cryptography.fields\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"api\", \"0075_alter_face_cluster_person\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"file\",\n            name=\"path\",\n            field=models.TextField(blank=True, default=\"\"),\n        ),\n        migrations.AlterField(\n            model_name=\"longrunningjob\",\n            name=\"job_type\",\n            field=models.PositiveIntegerField(\n                choices=[\n                    (1, \"Scan Photos\"),\n                    (2, \"Generate Event Albums\"),\n                    (3, \"Regenerate Event Titles\"),\n                    (4, \"Train Faces\"),\n                    (5, \"Delete Missing Photos\"),\n                    (7, \"Scan Faces\"),\n                    (6, \"Calculate Clip Embeddings\"),\n                    (8, \"Find Similar Faces\"),\n                    (9, \"Download Selected Photos\"),\n                    (10, \"Download Models\"),\n                    (11, \"Add Geolocation\"),\n                    (12, \"Generate Tags\"),\n                    (13, \"Generate Face Embeddings\"),\n                    (14, \"Scan Missing Photos\"),\n                ]\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"user\",\n            name=\"nextcloud_app_password\",\n            field=django_cryptography.fields.encrypt(\n                models.CharField(blank=True, default=\"\", max_length=64)\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"user\",\n            name=\"nextcloud_scan_directory\",\n            field=models.CharField(\n                blank=True, db_index=True, default=\"\", max_length=512\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"user\",\n            name=\"nextcloud_server_address\",\n            field=models.CharField(blank=True, default=\"\", max_length=200),\n        ),\n        migrations.AlterField(\n            model_name=\"user\",\n            name=\"nextcloud_username\",\n            field=models.CharField(blank=True, default=\"\", max_length=64),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0077_alter_albumdate_title.py",
    "content": "# Generated by Django 4.2.18 on 2025-03-29 12:59\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"api\", \"0076_alter_file_path_alter_longrunningjob_job_type_and_more\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"albumdate\",\n            name=\"title\",\n            field=models.CharField(\n                blank=True, db_index=True, default=\"\", max_length=512\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0078_create_photo_thumbnail.py",
    "content": "from django.db import migrations, models\nimport django.db.models.deletion\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        ('api', '0077_alter_albumdate_title'),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='Thumbnail',\n            fields=[\n                ('photo', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='thumbnail', serialize=False, to='api.photo')),\n                ('thumbnail_big', models.ImageField(upload_to='thumbnails_big')),\n                ('square_thumbnail', models.ImageField(upload_to='square_thumbnails')),\n                ('square_thumbnail_small', models.ImageField(upload_to='square_thumbnails_small')),\n                ('aspect_ratio', models.FloatField(blank=True, null=True)),\n                ('dominant_color', models.TextField(blank=True, null=True)),\n            ],\n        ),\n        migrations.RunSQL(\n            sql=[\n                \"\"\"\n                INSERT INTO api_thumbnail (\n                    photo_id,\n                    thumbnail_big,\n                    square_thumbnail,\n                    square_thumbnail_small,\n                    aspect_ratio,\n                    dominant_color\n                )\n                SELECT \n                    image_hash,\n                    thumbnail_big,\n                    square_thumbnail,\n                    square_thumbnail_small,\n                    aspect_ratio,\n                    dominant_color\n                FROM api_photo\n                \"\"\"\n            ],\n            reverse_sql=[\n                \"\"\"\n                UPDATE api_photo p\n                SET \n                    thumbnail_big = pt.thumbnail_big,\n                    square_thumbnail = pt.square_thumbnail,\n                    square_thumbnail_small = pt.square_thumbnail_small,\n                    aspect_ratio = pt.aspect_ratio,\n                    dominant_color = pt.dominant_color\n                FROM api_thumbnail pt\n                WHERE p.image_hash = pt.photo_id\n                \"\"\"\n            ]\n        ),\n        migrations.RemoveField(\n            model_name='photo',\n            name='aspect_ratio',\n        ),\n        migrations.RemoveField(\n            model_name='photo',\n            name='dominant_color',\n        ),\n        migrations.RemoveField(\n            model_name='photo',\n            name='square_thumbnail',\n        ),\n        migrations.RemoveField(\n            model_name='photo',\n            name='square_thumbnail_small',\n        ),\n        migrations.RemoveField(\n            model_name='photo',\n            name='thumbnail_big',\n        ),\n    ] "
  },
  {
    "path": "api/migrations/0079_alter_albumauto_title.py",
    "content": "# Generated by Django 4.2.18 on 2025-05-04 14:28\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"api\", \"0078_create_photo_thumbnail\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"albumauto\",\n            name=\"title\",\n            field=models.CharField(default=\"Untitled Album\", max_length=512),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0080_create_photo_caption.py",
    "content": "# Generated migration for PhotoCaption model\n\nfrom django.db import migrations, models\nimport django.db.models.deletion\n\n\ndef migrate_caption_data(apps, schema_editor):\n    \"\"\"Migrate existing caption data from Photo to PhotoCaption\"\"\"\n    Photo = apps.get_model('api', 'Photo')\n    PhotoCaption = apps.get_model('api', 'PhotoCaption')\n    \n    db_alias = schema_editor.connection.alias\n    \n    # Create PhotoCaption instances for all photos that have caption data\n    photos_with_captions = Photo.objects.using(db_alias).filter(\n        models.Q(captions_json__isnull=False) | models.Q(search_captions__isnull=False)\n    ).exclude(captions_json={})\n    \n    captions_to_create = []\n    \n    for photo in photos_with_captions.iterator():\n        captions_to_create.append(\n            PhotoCaption(\n                photo_id=photo.image_hash,\n                captions_json=photo.captions_json,\n                search_captions=photo.search_captions\n            )\n        )\n        \n        # Process in batches to avoid memory issues\n        if len(captions_to_create) >= 1000:\n            PhotoCaption.objects.using(db_alias).bulk_create(captions_to_create, ignore_conflicts=True)\n            captions_to_create = []\n    \n    # Create remaining captions\n    if captions_to_create:\n        PhotoCaption.objects.using(db_alias).bulk_create(captions_to_create, ignore_conflicts=True)\n\n\ndef reverse_migrate_caption_data(apps, schema_editor):\n    \"\"\"Reverse migration - copy data back from PhotoCaption to Photo\"\"\"\n    Photo = apps.get_model('api', 'Photo')\n    PhotoCaption = apps.get_model('api', 'PhotoCaption')\n    \n    db_alias = schema_editor.connection.alias\n    \n    # Update photos with caption data from PhotoCaption instances\n    for caption in PhotoCaption.objects.using(db_alias).all():\n        try:\n            photo = Photo.objects.using(db_alias).get(image_hash=caption.photo_id)\n            photo.captions_json = caption.captions_json\n            photo.search_captions = caption.search_captions\n            photo.save(update_fields=['captions_json', 'search_captions'])\n        except Photo.DoesNotExist:\n            continue\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0079_alter_albumauto_title'),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='PhotoCaption',\n            fields=[\n                ('photo', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='caption_instance', serialize=False, to='api.photo')),\n                ('captions_json', models.JSONField(blank=True, db_index=True, null=True)),\n                ('search_captions', models.TextField(blank=True, db_index=True, null=True)),\n                ('created_at', models.DateTimeField(auto_now_add=True)),\n                ('updated_at', models.DateTimeField(auto_now=True)),\n            ],\n            options={\n                'db_table': 'api_photo_caption',\n            },\n        ),\n        migrations.RunPython(\n            migrate_caption_data,\n            reverse_migrate_caption_data,\n        ),\n    ] "
  },
  {
    "path": "api/migrations/0081_remove_caption_fields_from_photo.py",
    "content": "# Generated migration to remove caption fields from Photo model\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0080_create_photo_caption'),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name='photo',\n            name='captions_json',\n        ),\n        migrations.RemoveField(\n            model_name='photo',\n            name='search_captions',\n        ),\n    ] "
  },
  {
    "path": "api/migrations/0082_create_photo_search.py",
    "content": "# Generated migration for PhotoSearch model\n\nfrom django.db import migrations, models\nimport django.db.models.deletion\n\n\ndef migrate_search_data(apps, schema_editor):\n    \"\"\"Migrate existing search data from Photo and PhotoCaption to PhotoSearch\"\"\"\n    Photo = apps.get_model('api', 'Photo')\n    PhotoCaption = apps.get_model('api', 'PhotoCaption')\n    PhotoSearch = apps.get_model('api', 'PhotoSearch')\n    \n    db_alias = schema_editor.connection.alias\n    \n    # Create PhotoSearch instances for all photos that have search data\n    photos_with_search_data = Photo.objects.using(db_alias).filter(\n        search_location__isnull=False\n    ).exclude(search_location='')\n    \n    search_instances_to_create = []\n    \n    for photo in photos_with_search_data.iterator():\n        search_instances_to_create.append(\n            PhotoSearch(\n                photo=photo,\n                search_location=photo.search_location,\n                search_captions=''  # Will be populated later\n            )\n        )\n    \n    # Bulk create PhotoSearch instances\n    PhotoSearch.objects.using(db_alias).bulk_create(search_instances_to_create, ignore_conflicts=True)\n    \n    # Now migrate search_captions from PhotoCaption to PhotoSearch\n    for caption in PhotoCaption.objects.using(db_alias).filter(search_captions__isnull=False).exclude(search_captions=''):\n        search_instance, created = PhotoSearch.objects.using(db_alias).get_or_create(\n            photo=caption.photo,\n            defaults={'search_captions': caption.search_captions, 'search_location': ''}\n        )\n        if not created:\n            search_instance.search_captions = caption.search_captions\n            search_instance.save()\n\n\ndef reverse_migrate_search_data(apps, schema_editor):\n    \"\"\"Reverse migration - copy data back from PhotoSearch to Photo and PhotoCaption\"\"\"\n    Photo = apps.get_model('api', 'Photo')\n    PhotoCaption = apps.get_model('api', 'PhotoCaption')\n    PhotoSearch = apps.get_model('api', 'PhotoSearch')\n    \n    db_alias = schema_editor.connection.alias\n    \n    # Update photos with search_location from PhotoSearch instances\n    for search in PhotoSearch.objects.using(db_alias).all():\n        try:\n            photo = Photo.objects.using(db_alias).get(image_hash=search.photo_id)\n            photo.search_location = search.search_location\n            photo.save(update_fields=['search_location'])\n            \n            # Update PhotoCaption with search_captions\n            caption, created = PhotoCaption.objects.using(db_alias).get_or_create(photo=photo)\n            caption.search_captions = search.search_captions\n            caption.save(update_fields=['search_captions'])\n        except Photo.DoesNotExist:\n            continue\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0081_remove_caption_fields_from_photo'),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='PhotoSearch',\n            fields=[\n                ('photo', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='search_instance', serialize=False, to='api.photo')),\n                ('search_captions', models.TextField(blank=True, db_index=True, null=True)),\n                ('search_location', models.TextField(blank=True, db_index=True, null=True)),\n                ('created_at', models.DateTimeField(auto_now_add=True)),\n                ('updated_at', models.DateTimeField(auto_now=True)),\n            ],\n            options={\n                'db_table': 'api_photo_search',\n            },\n        ),\n        migrations.RunPython(\n            migrate_search_data,\n            reverse_migrate_search_data,\n        ),\n    ] "
  },
  {
    "path": "api/migrations/0083_remove_search_fields.py",
    "content": "# Generated migration to remove search fields from Photo and PhotoCaption models\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0082_create_photo_search'),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name='photo',\n            name='search_location',\n        ),\n        migrations.RemoveField(\n            model_name='photocaption',\n            name='search_captions',\n        ),\n    ] "
  },
  {
    "path": "api/migrations/0084_convert_arrayfield_to_json.py",
    "content": "# Migration to safely convert ArrayField to JSONField for SQLite compatibility\nfrom django.db import migrations, models\n\n\ndef copy_arrayfield_to_json(apps, schema_editor):\n    \"\"\"\n    Copy data from ArrayField to JSONField format.\n    This handles the conversion for both PostgreSQL and SQLite.\n    \"\"\"\n    Photo = apps.get_model('api', 'Photo')\n    \n    for photo in Photo.objects.all():\n        if photo.clip_embeddings is not None:\n            # ArrayField data is already in list format, just copy it\n            photo.clip_embeddings_json = photo.clip_embeddings\n            photo.save(update_fields=['clip_embeddings_json'])\n\n\ndef copy_json_to_arrayfield(apps, schema_editor):\n    \"\"\"\n    Reverse migration: copy JSONField data back to ArrayField format.\n    \"\"\"\n    Photo = apps.get_model('api', 'Photo')\n    \n    for photo in Photo.objects.all():\n        if photo.clip_embeddings_json is not None:\n            photo.clip_embeddings = photo.clip_embeddings_json\n            photo.save(update_fields=['clip_embeddings'])\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        ('api', '0083_remove_search_fields'),\n    ]\n\n    operations = [\n        # Step 1: Add new JSONField\n        migrations.AddField(\n            model_name='Photo',\n            name='clip_embeddings_json',\n            field=models.JSONField(blank=True, null=True),\n        ),\n        \n        # Step 2: Copy data from ArrayField to JSONField\n        migrations.RunPython(\n            copy_arrayfield_to_json,\n            copy_json_to_arrayfield,\n        ),\n        \n        # Step 3: Remove old ArrayField\n        migrations.RemoveField(\n            model_name='Photo',\n            name='clip_embeddings',\n        ),\n        \n        # Step 4: Rename JSONField to original name\n        migrations.RenameField(\n            model_name='Photo',\n            old_name='clip_embeddings_json',\n            new_name='clip_embeddings',\n        ),\n    ] "
  },
  {
    "path": "api/migrations/0085_albumuser_public_expires_at_albumuser_public_slug.py",
    "content": "# Generated by Django 5.2.4 on 2025-08-16 08:40\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0084_convert_arrayfield_to_json'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='albumuser',\n            name='public_expires_at',\n            field=models.DateTimeField(blank=True, db_index=True, null=True),\n        ),\n        migrations.AddField(\n            model_name='albumuser',\n            name='public_slug',\n            field=models.SlugField(blank=True, max_length=64, null=True, unique=True),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0086_remove_albumuser_public_and_more.py",
    "content": "# Generated by Django 5.2.4 on 2025-08-17 17:23\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0085_albumuser_public_expires_at_albumuser_public_slug'),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name='albumuser',\n            name='public',\n        ),\n        migrations.RemoveField(\n            model_name='albumuser',\n            name='public_expires_at',\n        ),\n        migrations.RemoveField(\n            model_name='albumuser',\n            name='public_slug',\n        ),\n        migrations.CreateModel(\n            name='AlbumUserShare',\n            fields=[\n                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),\n                ('enabled', models.BooleanField(db_index=True, default=False)),\n                ('slug', models.SlugField(blank=True, max_length=64, null=True, unique=True)),\n                ('expires_at', models.DateTimeField(blank=True, db_index=True, null=True)),\n                ('album', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='share', to='api.albumuser')),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0087_add_folder_album.py",
    "content": "# Generated by Django 5.2.4 on 2025-08-22 14:48\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0086_remove_albumuser_public_and_more'),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='FolderAlbum',\n            fields=[\n                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),\n                ('title', models.CharField(max_length=512)),\n                ('created_on', models.DateTimeField(auto_now_add=True, db_index=True)),\n                ('updated_on', models.DateTimeField(auto_now=True)),\n                ('folder_path', models.TextField()),\n                ('include_subdirectories', models.BooleanField(default=True)),\n                ('public', models.BooleanField(db_index=True, default=False)),\n                ('favorited', models.BooleanField(db_index=True, default=False)),\n                ('cover_photo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='folder_album_cover', to='api.photo')),\n                ('owner', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),\n            ],\n            options={\n                'ordering': ['-created_on'],\n                'unique_together': {('folder_path', 'owner')},\n            },\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0088_remove_folder_album.py",
    "content": "# Generated by Django 5.2.4 on 2025-08-22 15:40\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0087_add_folder_album'),\n    ]\n\n    operations = [\n        migrations.DeleteModel(\n            name='FolderAlbum',\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0089_add_text_alignment.py",
    "content": "# Generated by Django 5.2.4 on 2025-08-22 16:00\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0088_remove_folder_album'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='user',\n            name='text_alignment',\n            field=models.TextField(choices=[('left', 'Left'), ('right', 'Right')], default='right'),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0090_add_header_size.py",
    "content": "# Generated by Django 5.2.4 on 2025-08-22 16:27\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0089_add_text_alignment'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='user',\n            name='header_size',\n            field=models.TextField(choices=[('large', 'Large'), ('normal', 'Normal'), ('small', 'Small')], default='large'),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0091_alter_user_scan_directory.py",
    "content": "# Generated by Django 5.2.4 on 2025-08-30 12:34\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0090_add_header_size'),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name='user',\n            name='scan_directory',\n            field=models.CharField(blank=True, db_index=True, default='', max_length=512),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0092_add_skip_raw_files_field.py",
    "content": "# Generated by Django 5.2.7 on 2025-10-26 21:54\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0091_alter_user_scan_directory'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='user',\n            name='skip_raw_files',\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0093_migrate_photon_to_nominatim.py",
    "content": "\"\"\"\nMigration to change MAP_API_PROVIDER from 'photon' to 'nominatim'.\n\nPhoton's public API at photon.komoot.io has become unreliable (502 errors),\nso we're switching the default to Nominatim which is more stable.\n\"\"\"\n\nfrom django.db import migrations\n\n\ndef migrate_photon_to_nominatim(apps, schema_editor):\n    \"\"\"Update constance config from photon to nominatim.\"\"\"\n    try:\n        Constance = apps.get_model(\"constance\", \"Constance\")\n        config = Constance.objects.filter(key=\"MAP_API_PROVIDER\").first()\n        if config and config.value == '\"photon\"':\n            config.value = '\"nominatim\"'\n            config.save()\n    except LookupError:\n        # constance model not available, skip\n        pass\n\n\ndef reverse_migration(apps, schema_editor):\n    \"\"\"Reverse: change nominatim back to photon (not recommended).\"\"\"\n    try:\n        Constance = apps.get_model(\"constance\", \"Constance\")\n        config = Constance.objects.filter(key=\"MAP_API_PROVIDER\").first()\n        if config and config.value == '\"nominatim\"':\n            config.value = '\"photon\"'\n            config.save()\n    except LookupError:\n        pass\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"api\", \"0092_add_skip_raw_files_field\"),\n    ]\n\n    operations = [\n        migrations.RunPython(migrate_photon_to_nominatim, reverse_migration),\n    ]\n\n"
  },
  {
    "path": "api/migrations/0094_add_slideshow_interval.py",
    "content": "# Generated manually for slideshow interval feature\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0093_migrate_photon_to_nominatim'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='user',\n            name='slideshow_interval',\n            field=models.IntegerField(default=5),\n        ),\n    ]\n\n"
  },
  {
    "path": "api/migrations/0095_photo_perceptual_hash_alter_longrunningjob_job_type_and_more.py",
    "content": "# Generated by Django 5.2.9 on 2025-12-23 09:47\n\nimport api.models.user\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0094_add_slideshow_interval'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='photo',\n            name='perceptual_hash',\n            field=models.CharField(blank=True, db_index=True, max_length=64, null=True),\n        ),\n        migrations.AlterField(\n            model_name='longrunningjob',\n            name='job_type',\n            field=models.PositiveIntegerField(choices=[(1, 'Scan Photos'), (2, 'Generate Event Albums'), (3, 'Regenerate Event Titles'), (4, 'Train Faces'), (5, 'Delete Missing Photos'), (7, 'Scan Faces'), (6, 'Calculate Clip Embeddings'), (8, 'Find Similar Faces'), (9, 'Download Selected Photos'), (10, 'Download Models'), (11, 'Add Geolocation'), (12, 'Generate Tags'), (13, 'Generate Face Embeddings'), (14, 'Scan Missing Photos'), (15, 'Detect Duplicate Photos')]),\n        ),\n        migrations.CreateModel(\n            name='DuplicateGroup',\n            fields=[\n                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),\n                ('created_at', models.DateTimeField(auto_now_add=True)),\n                ('updated_at', models.DateTimeField(auto_now=True)),\n                ('status', models.CharField(choices=[('pending', 'Pending Review'), ('reviewed', 'Reviewed'), ('dismissed', 'Dismissed (Not Duplicates)')], db_index=True, default='pending', max_length=20)),\n                ('owner', models.ForeignKey(on_delete=models.SET(api.models.user.get_deleted_user), related_name='duplicate_groups', to=settings.AUTH_USER_MODEL)),\n                ('preferred_photo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='preferred_in_group', to='api.photo')),\n            ],\n            options={\n                'ordering': ['-created_at'],\n            },\n        ),\n        migrations.AddField(\n            model_name='photo',\n            name='duplicate_group',\n            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='photos', to='api.duplicategroup'),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0096_add_progress_step_and_result_to_longrunningjob.py",
    "content": "# Generated by Django 5.2.9 on 2025-12-23 12:13\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0095_photo_perceptual_hash_alter_longrunningjob_job_type_and_more'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='longrunningjob',\n            name='progress_step',\n            field=models.CharField(blank=True, max_length=100, null=True),\n        ),\n        migrations.AddField(\n            model_name='longrunningjob',\n            name='result',\n            field=models.JSONField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0097_add_duplicate_detection_settings_to_user.py",
    "content": "# Generated by Django 5.2.9 on 2025-12-23 13:00\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0096_add_progress_step_and_result_to_longrunningjob'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='user',\n            name='duplicate_clear_existing',\n            field=models.BooleanField(default=False),\n        ),\n        migrations.AddField(\n            model_name='user',\n            name='duplicate_sensitivity',\n            field=models.TextField(choices=[('strict', 'Strict'), ('normal', 'Normal'), ('loose', 'Loose')], default='normal'),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0098_add_photo_stack.py",
    "content": "# Generated migration for PhotoStack unified grouping system\n\nimport uuid\nfrom django.db import migrations, models\nimport django.db.models.deletion\n\n\ndef get_deleted_user():\n    \"\"\"Reference to the function that returns the deleted user placeholder.\"\"\"\n    from api.models.user import get_deleted_user as _get_deleted_user\n    return _get_deleted_user\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0097_add_duplicate_detection_settings_to_user'),\n    ]\n\n    operations = [\n        # Create the PhotoStack table\n        migrations.CreateModel(\n            name='PhotoStack',\n            fields=[\n                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),\n                ('stack_type', models.CharField(\n                    choices=[\n                        ('exact_copy', 'Exact Copies'),\n                        ('visual_duplicate', 'Visual Duplicates'),\n                        ('raw_jpeg', 'RAW + JPEG Pair'),\n                        ('burst', 'Burst Sequence'),\n                        ('bracket', 'Exposure Bracket'),\n                        ('live_photo', 'Live Photo'),\n                        ('manual', 'Manual Stack'),\n                    ],\n                    db_index=True,\n                    default='visual_duplicate',\n                    max_length=20,\n                )),\n                ('status', models.CharField(\n                    choices=[\n                        ('pending', 'Pending Review'),\n                        ('reviewed', 'Reviewed'),\n                        ('dismissed', 'Dismissed'),\n                    ],\n                    db_index=True,\n                    default='pending',\n                    max_length=20,\n                )),\n                ('created_at', models.DateTimeField(auto_now_add=True)),\n                ('updated_at', models.DateTimeField(auto_now=True)),\n                ('similarity_score', models.FloatField(blank=True, null=True)),\n                ('sequence_start', models.DateTimeField(blank=True, null=True)),\n                ('sequence_end', models.DateTimeField(blank=True, null=True)),\n                ('potential_savings', models.BigIntegerField(default=0)),\n                ('owner', models.ForeignKey(\n                    on_delete=django.db.models.deletion.SET(get_deleted_user),\n                    related_name='photo_stacks',\n                    to='api.user',\n                )),\n                ('primary_photo', models.ForeignKey(\n                    blank=True,\n                    null=True,\n                    on_delete=django.db.models.deletion.SET_NULL,\n                    related_name='primary_in_stack',\n                    to='api.photo',\n                )),\n            ],\n            options={\n                'verbose_name': 'Photo Stack',\n                'verbose_name_plural': 'Photo Stacks',\n                'ordering': ['-created_at'],\n            },\n        ),\n        # Add indexes for PhotoStack\n        migrations.AddIndex(\n            model_name='photostack',\n            index=models.Index(fields=['owner', 'stack_type', 'status'], name='api_photost_owner_i_abc123_idx'),\n        ),\n        migrations.AddIndex(\n            model_name='photostack',\n            index=models.Index(fields=['owner', 'status'], name='api_photost_owner_i_def456_idx'),\n        ),\n        # Add stack field to Photo\n        migrations.AddField(\n            model_name='photo',\n            name='stack',\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name='photos',\n                to='api.photostack',\n            ),\n        ),\n        # Add sub-second timestamp for burst detection\n        migrations.AddField(\n            model_name='photo',\n            name='exif_timestamp_subsec',\n            field=models.CharField(blank=True, max_length=10, null=True),\n        ),\n        # Add image sequence number for burst detection\n        migrations.AddField(\n            model_name='photo',\n            name='image_sequence_number',\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        # Remove duplicate_group FK from Photo (replaced by stack)\n        migrations.RemoveField(\n            model_name='photo',\n            name='duplicate_group',\n        ),\n        # Delete the old DuplicateGroup model\n        migrations.DeleteModel(\n            name='DuplicateGroup',\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0099_photo_uuid_primary_key.py",
    "content": "# Generated migration for Photo UUID primary key\n# This migration changes Photo from using image_hash as PK to using UUID as PK\n#\n# Supports both PostgreSQL (raw SQL) and SQLite (table recreation pattern).\n#\n# ============================================================================\n# CRITICAL WARNING: THIS MIGRATION IS NOT REVERSIBLE\n# ============================================================================\n# This migration fundamentally changes the Photo primary key from image_hash\n# (a content-based hash) to UUID (a random identifier). Reversing this would\n# require regenerating the original image_hash values from file content, which\n# is not possible without access to the original photo files and significant\n# processing time.\n#\n# BEFORE RUNNING THIS MIGRATION:\n# 1. Create a FULL DATABASE BACKUP: pg_dump -U your_user your_db > backup.sql\n# 2. Test the migration on a copy of your production database first\n# 3. Plan for downtime - this migration may take significant time on large DBs\n# 4. Ensure you have enough disk space for the migration operations\n#\n# TO ROLLBACK (if needed):\n# 1. Stop the application\n# 2. Restore from your pre-migration database backup\n# 3. Fake-migrate back: python manage.py migrate api 0098 --fake\n# ============================================================================\n\nimport uuid\nfrom django.db import migrations, models\n\n\n# ============================================================================\n# PostgreSQL raw SQL\n# ============================================================================\n\nPOSTGRESQL_FORWARD_SQL = \"\"\"\n-- Step 1: Add UUID column to api_photo\nALTER TABLE api_photo ADD COLUMN id UUID DEFAULT gen_random_uuid();\nUPDATE api_photo SET id = gen_random_uuid() WHERE id IS NULL;\nALTER TABLE api_photo ALTER COLUMN id SET NOT NULL;\n\n-- Step 2: Create a mapping table for old hash -> new UUID\nCREATE TEMP TABLE photo_id_mapping AS\nSELECT image_hash, id FROM api_photo;\nCREATE INDEX ON photo_id_mapping(image_hash);\n\n-- Step 3: Add new UUID columns to all related tables\n\n-- api_face\nALTER TABLE api_face ADD COLUMN photo_id_new UUID;\nUPDATE api_face f SET photo_id_new = m.id\nFROM photo_id_mapping m WHERE f.photo_id = m.image_hash;\n\n-- api_photo_shared_to (M2M through table)\nALTER TABLE api_photo_shared_to ADD COLUMN photo_id_new UUID;\nUPDATE api_photo_shared_to t SET photo_id_new = m.id\nFROM photo_id_mapping m WHERE t.photo_id = m.image_hash;\n\n-- api_photo_files (M2M through table)\nALTER TABLE api_photo_files ADD COLUMN photo_id_new UUID;\nUPDATE api_photo_files t SET photo_id_new = m.id\nFROM photo_id_mapping m WHERE t.photo_id = m.image_hash;\n\n-- api_albumuser_photos (M2M through table)\nALTER TABLE api_albumuser_photos ADD COLUMN photo_id_new UUID;\nUPDATE api_albumuser_photos t SET photo_id_new = m.id\nFROM photo_id_mapping m WHERE t.photo_id = m.image_hash;\n\n-- api_albumthing_photos (M2M through table)\nALTER TABLE api_albumthing_photos ADD COLUMN photo_id_new UUID;\nUPDATE api_albumthing_photos t SET photo_id_new = m.id\nFROM photo_id_mapping m WHERE t.photo_id = m.image_hash;\n\n-- api_albumplace_photos (M2M through table)\nALTER TABLE api_albumplace_photos ADD COLUMN photo_id_new UUID;\nUPDATE api_albumplace_photos t SET photo_id_new = m.id\nFROM photo_id_mapping m WHERE t.photo_id = m.image_hash;\n\n-- api_albumdate_photos (M2M through table)\nALTER TABLE api_albumdate_photos ADD COLUMN photo_id_new UUID;\nUPDATE api_albumdate_photos t SET photo_id_new = m.id\nFROM photo_id_mapping m WHERE t.photo_id = m.image_hash;\n\n-- api_albumauto_photos (M2M through table)\nALTER TABLE api_albumauto_photos ADD COLUMN photo_id_new UUID;\nUPDATE api_albumauto_photos t SET photo_id_new = m.id\nFROM photo_id_mapping m WHERE t.photo_id = m.image_hash;\n\n-- api_person (cover_photo_id)\nALTER TABLE api_person ADD COLUMN cover_photo_id_new UUID;\nUPDATE api_person p SET cover_photo_id_new = m.id\nFROM photo_id_mapping m WHERE p.cover_photo_id = m.image_hash;\n\n-- api_albumuser (cover_photo_id)\nALTER TABLE api_albumuser ADD COLUMN cover_photo_id_new UUID;\nUPDATE api_albumuser a SET cover_photo_id_new = m.id\nFROM photo_id_mapping m WHERE a.cover_photo_id = m.image_hash;\n\n-- api_albumthing_cover_photos (M2M through table for cover photos)\nALTER TABLE api_albumthing_cover_photos ADD COLUMN photo_id_new UUID;\nUPDATE api_albumthing_cover_photos t SET photo_id_new = m.id\nFROM photo_id_mapping m WHERE t.photo_id = m.image_hash;\n\n-- api_thumbnail (OneToOne with PK)\nALTER TABLE api_thumbnail ADD COLUMN photo_id_new UUID;\nUPDATE api_thumbnail t SET photo_id_new = m.id\nFROM photo_id_mapping m WHERE t.photo_id = m.image_hash;\n\n-- api_photo_caption (OneToOne with PK)\nALTER TABLE api_photo_caption ADD COLUMN photo_id_new UUID;\nUPDATE api_photo_caption t SET photo_id_new = m.id\nFROM photo_id_mapping m WHERE t.photo_id = m.image_hash;\n\n-- api_photo_search (OneToOne with PK)\nALTER TABLE api_photo_search ADD COLUMN photo_id_new UUID;\nUPDATE api_photo_search t SET photo_id_new = m.id\nFROM photo_id_mapping m WHERE t.photo_id = m.image_hash;\n\n-- api_photostack (primary_photo_id)\nALTER TABLE api_photostack ADD COLUMN primary_photo_id_new UUID;\nUPDATE api_photostack s SET primary_photo_id_new = m.id\nFROM photo_id_mapping m WHERE s.primary_photo_id = m.image_hash;\n\n-- Step 4: Drop all FK constraints\nALTER TABLE api_face DROP CONSTRAINT IF EXISTS api_face_photo_id_6f997226_fk_api_photo_image_hash;\nALTER TABLE api_photo_shared_to DROP CONSTRAINT IF EXISTS api_photo_shared_to_photo_id_852923c7_fk_api_photo_image_hash;\nALTER TABLE api_photo_files DROP CONSTRAINT IF EXISTS api_photo_files_photo_id_f4365127_fk_api_photo_image_hash;\nALTER TABLE api_albumuser_photos DROP CONSTRAINT IF EXISTS api_albumuser_photos_photo_id_b9df1b14_fk_api_photo_image_hash;\nALTER TABLE api_albumthing_photos DROP CONSTRAINT IF EXISTS api_albumthing_photos_photo_id_d0832fc3_fk_api_photo_image_hash;\nALTER TABLE api_albumplace_photos DROP CONSTRAINT IF EXISTS api_albumplace_photos_photo_id_8fd88190_fk_api_photo_image_hash;\nALTER TABLE api_albumdate_photos DROP CONSTRAINT IF EXISTS api_albumdate_photos_photo_id_26095959_fk_api_photo_image_hash;\nALTER TABLE api_albumauto_photos DROP CONSTRAINT IF EXISTS api_albumauto_photos_photo_id_3320c2f0_fk_api_photo_image_hash;\nALTER TABLE api_person DROP CONSTRAINT IF EXISTS api_person_cover_photo_id_e0d8a6ab_fk_api_photo_image_hash;\nALTER TABLE api_albumuser DROP CONSTRAINT IF EXISTS api_albumuser_cover_photo_id_69b304ac_fk_api_photo_image_hash;\nALTER TABLE api_albumthing_cover_photos DROP CONSTRAINT IF EXISTS api_albumthing_cover_photo_id_ae113997_fk_api_photo;\nALTER TABLE api_thumbnail DROP CONSTRAINT IF EXISTS api_thumbnail_photo_id_484afcd0_fk_api_photo_image_hash;\nALTER TABLE api_photo_caption DROP CONSTRAINT IF EXISTS api_photo_caption_photo_id_363f8856_fk_api_photo_image_hash;\nALTER TABLE api_photo_search DROP CONSTRAINT IF EXISTS api_photo_search_photo_id_b4055a77_fk_api_photo_image_hash;\nALTER TABLE api_photostack DROP CONSTRAINT IF EXISTS api_photostack_primary_photo_id_a2e9fc96_fk_api_photo;\n\n-- Step 5: Drop PKs on related tables that use photo as PK\nALTER TABLE api_thumbnail DROP CONSTRAINT IF EXISTS api_thumbnail_pkey;\nALTER TABLE api_photo_caption DROP CONSTRAINT IF EXISTS api_photo_caption_pkey;\nALTER TABLE api_photo_search DROP CONSTRAINT IF EXISTS api_photo_search_pkey;\n\n-- Step 6: Drop old PK on api_photo, add new one\nALTER TABLE api_photo DROP CONSTRAINT api_photo_pkey;\nALTER TABLE api_photo ADD PRIMARY KEY (id);\n\n-- Step 7: Add unique constraint on image_hash (for deduplication)\nCREATE UNIQUE INDEX api_photo_image_hash_unique ON api_photo(image_hash);\n\n-- Step 8: Drop old FK columns, rename new ones\n\nALTER TABLE api_face DROP COLUMN photo_id;\nALTER TABLE api_face RENAME COLUMN photo_id_new TO photo_id;\n\nALTER TABLE api_photo_shared_to DROP COLUMN photo_id;\nALTER TABLE api_photo_shared_to RENAME COLUMN photo_id_new TO photo_id;\n\nALTER TABLE api_photo_files DROP COLUMN photo_id;\nALTER TABLE api_photo_files RENAME COLUMN photo_id_new TO photo_id;\n\nALTER TABLE api_albumuser_photos DROP COLUMN photo_id;\nALTER TABLE api_albumuser_photos RENAME COLUMN photo_id_new TO photo_id;\n\nALTER TABLE api_albumthing_photos DROP COLUMN photo_id;\nALTER TABLE api_albumthing_photos RENAME COLUMN photo_id_new TO photo_id;\n\nALTER TABLE api_albumplace_photos DROP COLUMN photo_id;\nALTER TABLE api_albumplace_photos RENAME COLUMN photo_id_new TO photo_id;\n\nALTER TABLE api_albumdate_photos DROP COLUMN photo_id;\nALTER TABLE api_albumdate_photos RENAME COLUMN photo_id_new TO photo_id;\n\nALTER TABLE api_albumauto_photos DROP COLUMN photo_id;\nALTER TABLE api_albumauto_photos RENAME COLUMN photo_id_new TO photo_id;\n\nALTER TABLE api_person DROP COLUMN cover_photo_id;\nALTER TABLE api_person RENAME COLUMN cover_photo_id_new TO cover_photo_id;\n\nALTER TABLE api_albumuser DROP COLUMN cover_photo_id;\nALTER TABLE api_albumuser RENAME COLUMN cover_photo_id_new TO cover_photo_id;\n\nALTER TABLE api_albumthing_cover_photos DROP COLUMN photo_id;\nALTER TABLE api_albumthing_cover_photos RENAME COLUMN photo_id_new TO photo_id;\n\nALTER TABLE api_thumbnail DROP COLUMN photo_id;\nALTER TABLE api_thumbnail RENAME COLUMN photo_id_new TO photo_id;\nALTER TABLE api_thumbnail ALTER COLUMN photo_id SET NOT NULL;\nALTER TABLE api_thumbnail ADD PRIMARY KEY (photo_id);\n\nALTER TABLE api_photo_caption DROP COLUMN photo_id;\nALTER TABLE api_photo_caption RENAME COLUMN photo_id_new TO photo_id;\nALTER TABLE api_photo_caption ALTER COLUMN photo_id SET NOT NULL;\nALTER TABLE api_photo_caption ADD PRIMARY KEY (photo_id);\n\nALTER TABLE api_photo_search DROP COLUMN photo_id;\nALTER TABLE api_photo_search RENAME COLUMN photo_id_new TO photo_id;\nALTER TABLE api_photo_search ALTER COLUMN photo_id SET NOT NULL;\nALTER TABLE api_photo_search ADD PRIMARY KEY (photo_id);\n\nALTER TABLE api_photostack DROP COLUMN primary_photo_id;\nALTER TABLE api_photostack RENAME COLUMN primary_photo_id_new TO primary_photo_id;\n\n-- Step 9: Recreate all FK constraints with new UUID type\nALTER TABLE api_face ADD CONSTRAINT api_face_photo_id_fk_api_photo\n    FOREIGN KEY (photo_id) REFERENCES api_photo(id) ON DELETE CASCADE;\n\nALTER TABLE api_photo_shared_to ADD CONSTRAINT api_photo_shared_to_photo_id_fk\n    FOREIGN KEY (photo_id) REFERENCES api_photo(id) ON DELETE CASCADE;\n\nALTER TABLE api_photo_files ADD CONSTRAINT api_photo_files_photo_id_fk\n    FOREIGN KEY (photo_id) REFERENCES api_photo(id) ON DELETE CASCADE;\n\nALTER TABLE api_albumuser_photos ADD CONSTRAINT api_albumuser_photos_photo_id_fk\n    FOREIGN KEY (photo_id) REFERENCES api_photo(id) ON DELETE CASCADE;\n\nALTER TABLE api_albumthing_photos ADD CONSTRAINT api_albumthing_photos_photo_id_fk\n    FOREIGN KEY (photo_id) REFERENCES api_photo(id) ON DELETE CASCADE;\n\nALTER TABLE api_albumplace_photos ADD CONSTRAINT api_albumplace_photos_photo_id_fk\n    FOREIGN KEY (photo_id) REFERENCES api_photo(id) ON DELETE CASCADE;\n\nALTER TABLE api_albumdate_photos ADD CONSTRAINT api_albumdate_photos_photo_id_fk\n    FOREIGN KEY (photo_id) REFERENCES api_photo(id) ON DELETE CASCADE;\n\nALTER TABLE api_albumauto_photos ADD CONSTRAINT api_albumauto_photos_photo_id_fk\n    FOREIGN KEY (photo_id) REFERENCES api_photo(id) ON DELETE CASCADE;\n\nALTER TABLE api_person ADD CONSTRAINT api_person_cover_photo_id_fk\n    FOREIGN KEY (cover_photo_id) REFERENCES api_photo(id) ON DELETE SET NULL;\n\nALTER TABLE api_albumuser ADD CONSTRAINT api_albumuser_cover_photo_id_fk\n    FOREIGN KEY (cover_photo_id) REFERENCES api_photo(id) ON DELETE SET NULL;\n\nALTER TABLE api_albumthing_cover_photos ADD CONSTRAINT api_albumthing_cover_photos_photo_id_fk\n    FOREIGN KEY (photo_id) REFERENCES api_photo(id) ON DELETE CASCADE;\n\nALTER TABLE api_thumbnail ADD CONSTRAINT api_thumbnail_photo_id_fk\n    FOREIGN KEY (photo_id) REFERENCES api_photo(id) ON DELETE CASCADE;\n\nALTER TABLE api_photo_caption ADD CONSTRAINT api_photo_caption_photo_id_fk\n    FOREIGN KEY (photo_id) REFERENCES api_photo(id) ON DELETE CASCADE;\n\nALTER TABLE api_photo_search ADD CONSTRAINT api_photo_search_photo_id_fk\n    FOREIGN KEY (photo_id) REFERENCES api_photo(id) ON DELETE CASCADE;\n\nALTER TABLE api_photostack ADD CONSTRAINT api_photostack_primary_photo_id_fk\n    FOREIGN KEY (primary_photo_id) REFERENCES api_photo(id) ON DELETE SET NULL;\n\n-- Step 10: Create indexes for performance\nCREATE INDEX api_face_photo_id_idx ON api_face(photo_id);\nCREATE INDEX api_photo_shared_to_photo_id_idx ON api_photo_shared_to(photo_id);\nCREATE INDEX api_photo_files_photo_id_idx ON api_photo_files(photo_id);\nCREATE INDEX api_person_cover_photo_id_idx ON api_person(cover_photo_id);\nCREATE INDEX api_albumuser_cover_photo_id_idx ON api_albumuser(cover_photo_id);\nCREATE INDEX api_photostack_primary_photo_id_idx ON api_photostack(primary_photo_id);\n\n-- Clean up temp table\nDROP TABLE photo_id_mapping;\n\"\"\"\n\n\n# ============================================================================\n# Forward / reverse Python entry points\n# ============================================================================\n\ndef migrate_forward(apps, schema_editor):\n    \"\"\"Forward migration - dispatches to PostgreSQL or SQLite implementation.\"\"\"\n    vendor = schema_editor.connection.vendor\n    if vendor == \"postgresql\":\n        _migrate_postgresql(schema_editor)\n    elif vendor == \"sqlite\":\n        _migrate_sqlite(schema_editor)\n    else:\n        raise ValueError(\n            f\"Unsupported database backend: {vendor}. \"\n            f\"This migration supports PostgreSQL and SQLite only.\"\n        )\n\n\ndef migrate_reverse(apps, schema_editor):\n    \"\"\"Reverse migration - not supported for any backend.\"\"\"\n    raise RuntimeError(\n        \"Migration 0099_photo_uuid_primary_key cannot be reversed automatically. \"\n        \"Please restore from your pre-migration database backup and run: \"\n        \"python manage.py migrate api 0098 --fake\"\n    )\n\n\n# ============================================================================\n# PostgreSQL implementation\n# ============================================================================\n\ndef _migrate_postgresql(schema_editor):\n    \"\"\"Execute the PostgreSQL-specific migration using raw SQL.\"\"\"\n    statements = schema_editor.connection.ops.prepare_sql_script(\n        POSTGRESQL_FORWARD_SQL\n    )\n    for statement in statements:\n        schema_editor.execute(statement)\n\n\n# ============================================================================\n# SQLite implementation  (table-recreation pattern)\n# ============================================================================\n#\n# SQLite does not support most ALTER TABLE operations required by the\n# PostgreSQL path (DROP/ADD CONSTRAINT, ADD PRIMARY KEY, ALTER COLUMN,\n# UPDATE … FROM, gen_random_uuid(), etc.).\n#\n# Instead we use the standard SQLite table-recreation pattern:\n#   1. CREATE TABLE …__new  (with the desired schema)\n#   2. INSERT INTO …__new SELECT … FROM …  (copy data, translating FKs)\n#   3. DROP TABLE …\n#   4. ALTER TABLE …__new RENAME TO …\n#   5. Re-create indexes\n# ============================================================================\n\ndef _migrate_sqlite(schema_editor):\n    \"\"\"Execute the SQLite-compatible migration via table recreation.\"\"\"\n    cursor = schema_editor.connection.cursor()\n\n    # Disable FK enforcement while we recreate tables\n    cursor.execute(\"PRAGMA foreign_keys = OFF\")\n\n    try:\n        # -- Step 1: Build image_hash → UUID mapping --------------------------\n        cursor.execute('SELECT \"image_hash\" FROM \"api_photo\"')\n        mapping = {row[0]: str(uuid.uuid4()) for row in cursor.fetchall()}\n\n        # -- Step 2: Add id column to api_photo and populate UUIDs ------------\n        cursor.execute('ALTER TABLE \"api_photo\" ADD COLUMN \"id\" TEXT')\n        for image_hash, new_id in mapping.items():\n            cursor.execute(\n                'UPDATE \"api_photo\" SET \"id\" = ? WHERE \"image_hash\" = ?',\n                [new_id, image_hash],\n            )\n\n        # -- Step 3: Recreate api_photo with id as PK ------------------------\n        _sqlite_recreate_table(\n            cursor,\n            \"api_photo\",\n            pk_column=\"id\",\n            column_overrides={\n                \"id\": '\"id\" TEXT NOT NULL',\n                \"image_hash\": '\"image_hash\" varchar(64) NOT NULL UNIQUE',\n            },\n        )\n\n        # -- Step 4: Update FK references in every related table --------------\n        _FK_TABLES = [\n            (\"api_face\", \"photo_id\"),\n            (\"api_photo_shared_to\", \"photo_id\"),\n            (\"api_photo_files\", \"photo_id\"),\n            (\"api_albumuser_photos\", \"photo_id\"),\n            (\"api_albumthing_photos\", \"photo_id\"),\n            (\"api_albumplace_photos\", \"photo_id\"),\n            (\"api_albumdate_photos\", \"photo_id\"),\n            (\"api_albumauto_photos\", \"photo_id\"),\n            (\"api_albumthing_cover_photos\", \"photo_id\"),\n            (\"api_person\", \"cover_photo_id\"),\n            (\"api_albumuser\", \"cover_photo_id\"),\n            (\"api_photostack\", \"primary_photo_id\"),\n            (\"api_thumbnail\", \"photo_id\"),\n            (\"api_photo_caption\", \"photo_id\"),\n            (\"api_photo_search\", \"photo_id\"),\n        ]\n        for table_name, fk_column in _FK_TABLES:\n            _sqlite_update_fk_table(cursor, table_name, fk_column, mapping)\n\n        # -- Step 5: Create performance indexes -------------------------------\n        _sqlite_create_indexes(cursor)\n\n    finally:\n        cursor.execute(\"PRAGMA foreign_keys = ON\")\n\n\n# -- SQLite helpers -----------------------------------------------------------\n\ndef _sqlite_recreate_table(cursor, table_name, pk_column, column_overrides):\n    \"\"\"\n    Recreate *api_photo* with a new primary-key column.\n\n    * ``pk_column``        – name of the column that becomes PRIMARY KEY\n    * ``column_overrides`` – dict  column_name → SQL column-definition fragment\n                             (without PRIMARY KEY – that is appended automatically\n                              for *pk_column*)\n    \"\"\"\n    columns = _sqlite_column_info(cursor, table_name)\n    indexes = _sqlite_index_info(cursor, table_name)\n\n    col_defs = []\n    col_names = []\n    for _cid, name, type_, notnull, dflt_value, _pk in columns:\n        col_names.append(name)\n        if name in column_overrides:\n            defn = column_overrides[name]\n            if name == pk_column:\n                defn += \" PRIMARY KEY\"\n            col_defs.append(defn)\n        else:\n            parts = [f'\"{name}\"', type_ or \"TEXT\"]\n            if name == pk_column:\n                parts.append(\"NOT NULL PRIMARY KEY\")\n            elif notnull:\n                parts.append(\"NOT NULL\")\n            if dflt_value is not None and name != pk_column:\n                parts.append(f\"DEFAULT {dflt_value}\")\n            col_defs.append(\" \".join(parts))\n\n    cols_quoted = \", \".join(f'\"{c}\"' for c in col_names)\n    new_table = f\"{table_name}__new\"\n\n    cursor.execute(f'CREATE TABLE \"{new_table}\" ({\", \".join(col_defs)})')\n    cursor.execute(\n        f'INSERT INTO \"{new_table}\" ({cols_quoted}) '\n        f'SELECT {cols_quoted} FROM \"{table_name}\"'\n    )\n    cursor.execute(f'DROP TABLE \"{table_name}\"')\n    cursor.execute(f'ALTER TABLE \"{new_table}\" RENAME TO \"{table_name}\"')\n\n    # Re-create any existing indexes\n    for _idx_name, idx_sql in indexes:\n        try:\n            cursor.execute(idx_sql)\n        except Exception:\n            pass  # index may conflict with new UNIQUE constraint\n\n\ndef _sqlite_update_fk_table(cursor, table_name, fk_column, mapping):\n    \"\"\"\n    Recreate *table_name* so that every value in *fk_column* is translated\n    from the old image_hash to the new UUID via *mapping*.\n    \"\"\"\n    # Guard: skip if the table or column doesn't exist\n    cursor.execute(\n        \"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?\",\n        [table_name],\n    )\n    if cursor.fetchone()[0] == 0:\n        return\n\n    columns = _sqlite_column_info(cursor, table_name)\n    col_names_list = [col[1] for col in columns]\n    if fk_column not in col_names_list:\n        return\n\n    indexes = _sqlite_index_info(cursor, table_name)\n\n    # Build column definitions – change the FK column type to TEXT\n    col_defs = []\n    col_names = []\n    fk_col_idx = None\n\n    for _cid, name, type_, notnull, dflt_value, pk in columns:\n        col_names.append(name)\n        if name == fk_column:\n            fk_col_idx = len(col_names) - 1\n            parts = [f'\"{name}\" TEXT']\n            if pk:\n                parts.append(\"NOT NULL PRIMARY KEY\")\n            col_defs.append(\" \".join(parts))\n        else:\n            parts = [f'\"{name}\"', type_ or \"TEXT\"]\n            if pk:\n                parts.append(\"PRIMARY KEY\")\n            if notnull and not pk:\n                parts.append(\"NOT NULL\")\n            if dflt_value is not None:\n                parts.append(f\"DEFAULT {dflt_value}\")\n            col_defs.append(\" \".join(parts))\n\n    cols_quoted = \", \".join(f'\"{c}\"' for c in col_names)\n    new_table = f\"{table_name}__new\"\n\n    # Create new table and bulk-copy data\n    cursor.execute(f'CREATE TABLE \"{new_table}\" ({\", \".join(col_defs)})')\n    cursor.execute(\n        f'INSERT INTO \"{new_table}\" ({cols_quoted}) '\n        f'SELECT {cols_quoted} FROM \"{table_name}\"'\n    )\n\n    # Translate FK values in the new table\n    for old_hash, new_uuid in mapping.items():\n        cursor.execute(\n            f'UPDATE \"{new_table}\" SET \"{fk_column}\" = ? '\n            f'WHERE \"{fk_column}\" = ?',\n            [new_uuid, old_hash],\n        )\n\n    # Swap tables\n    cursor.execute(f'DROP TABLE \"{table_name}\"')\n    cursor.execute(f'ALTER TABLE \"{new_table}\" RENAME TO \"{table_name}\"')\n\n    # Re-create indexes\n    for _idx_name, idx_sql in indexes:\n        try:\n            cursor.execute(idx_sql)\n        except Exception:\n            pass\n\n\ndef _sqlite_create_indexes(cursor):\n    \"\"\"Create the same performance indexes as the PostgreSQL path.\"\"\"\n    index_defs = [\n        (True, \"api_photo_image_hash_unique\", \"api_photo\", \"image_hash\"),\n        (False, \"api_face_photo_id_idx\", \"api_face\", \"photo_id\"),\n        (False, \"api_photo_shared_to_photo_id_idx\", \"api_photo_shared_to\", \"photo_id\"),\n        (False, \"api_photo_files_photo_id_idx\", \"api_photo_files\", \"photo_id\"),\n        (False, \"api_person_cover_photo_id_idx\", \"api_person\", \"cover_photo_id\"),\n        (False, \"api_albumuser_cover_photo_id_idx\", \"api_albumuser\", \"cover_photo_id\"),\n        (False, \"api_photostack_primary_photo_id_idx\", \"api_photostack\", \"primary_photo_id\"),\n    ]\n    for unique, idx_name, table, column in index_defs:\n        unique_kw = \"UNIQUE \" if unique else \"\"\n        try:\n            cursor.execute(\n                f'CREATE {unique_kw}INDEX IF NOT EXISTS '\n                f'\"{idx_name}\" ON \"{table}\"(\"{column}\")'\n            )\n        except Exception:\n            pass\n\n\ndef _sqlite_column_info(cursor, table_name):\n    \"\"\"Return PRAGMA table_info rows for *table_name*.\"\"\"\n    cursor.execute(f'PRAGMA table_info(\"{table_name}\")')\n    return cursor.fetchall()\n\n\ndef _sqlite_index_info(cursor, table_name):\n    \"\"\"Return (name, sql) for every explicit index on *table_name*.\"\"\"\n    cursor.execute(\n        \"SELECT name, sql FROM sqlite_master \"\n        \"WHERE type='index' AND tbl_name=? AND sql IS NOT NULL\",\n        [table_name],\n    )\n    return cursor.fetchall()\n\n\n# ============================================================================\n# Migration class\n# ============================================================================\n\nclass Migration(migrations.Migration):\n    \"\"\"\n    Migration to change Photo primary key from image_hash (CharField) to id (UUIDField).\n\n    WARNING: This migration is NOT reversible through Django's migration system.\n    You MUST have a database backup before running this migration.\n\n    Steps:\n    1. Add UUID column to api_photo\n    2. Generate UUIDs for existing photos\n    3. Add UUID columns to all related tables (to store new FK values)\n    4. Populate new UUID FK columns from image_hash lookups\n    5. Drop all old FK constraints\n    6. Drop old PK, add new PK\n    7. Drop old FK columns, rename new FK columns\n    8. Recreate all FK constraints\n    \"\"\"\n\n    dependencies = [\n        ('api', '0098_add_photo_stack'),\n    ]\n\n    operations = [\n        migrations.SeparateDatabaseAndState(\n            database_operations=[\n                migrations.RunPython(migrate_forward, migrate_reverse),\n            ],\n            state_operations=[\n                # Add the new UUID primary key field\n                migrations.AddField(\n                    model_name='photo',\n                    name='id',\n                    field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),\n                ),\n                # Change image_hash from primary_key=True to just unique=True\n                migrations.AlterField(\n                    model_name='photo',\n                    name='image_hash',\n                    field=models.CharField(db_index=True, max_length=64, unique=True),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0100_metadataedit_metadatafile_photometadata_stackreview_and_more.py",
    "content": "# Generated by Django 5.2.9 on 2025-12-25 14:15\n\nimport api.models.user\nimport django.db.models.deletion\nimport uuid\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0099_photo_uuid_primary_key'),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='MetadataEdit',\n            fields=[\n                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),\n                ('field_name', models.CharField(max_length=100)),\n                ('old_value', models.JSONField(blank=True, null=True)),\n                ('new_value', models.JSONField(blank=True, null=True)),\n                ('synced_to_file', models.BooleanField(default=False)),\n                ('synced_at', models.DateTimeField(blank=True, null=True)),\n                ('created_at', models.DateTimeField(auto_now_add=True)),\n            ],\n            options={\n                'verbose_name': 'Metadata Edit',\n                'verbose_name_plural': 'Metadata Edits',\n                'ordering': ['-created_at'],\n            },\n        ),\n        migrations.CreateModel(\n            name='MetadataFile',\n            fields=[\n                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),\n                ('file_type', models.CharField(choices=[('xmp', 'XMP Sidecar'), ('json', 'JSON Metadata'), ('exif', 'EXIF Extract'), ('other', 'Other')], default='xmp', max_length=10)),\n                ('source', models.CharField(choices=[('original', 'Original Sidecar'), ('software', 'Software Generated'), ('librephotos', 'LibrePhotos Generated'), ('user', 'User Created')], default='original', max_length=20)),\n                ('priority', models.IntegerField(default=0)),\n                ('creator_software', models.CharField(blank=True, max_length=100, null=True)),\n                ('created_at', models.DateTimeField(auto_now_add=True)),\n                ('updated_at', models.DateTimeField(auto_now=True)),\n            ],\n            options={\n                'verbose_name': 'Metadata File',\n                'verbose_name_plural': 'Metadata Files',\n                'ordering': ['-priority', '-updated_at'],\n            },\n        ),\n        migrations.CreateModel(\n            name='PhotoMetadata',\n            fields=[\n                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),\n                ('aperture', models.FloatField(blank=True, db_index=True, null=True)),\n                ('shutter_speed', models.CharField(blank=True, max_length=20, null=True)),\n                ('shutter_speed_seconds', models.FloatField(blank=True, null=True)),\n                ('iso', models.IntegerField(blank=True, db_index=True, null=True)),\n                ('focal_length', models.FloatField(blank=True, null=True)),\n                ('focal_length_35mm', models.IntegerField(blank=True, null=True)),\n                ('exposure_compensation', models.FloatField(blank=True, null=True)),\n                ('flash_fired', models.BooleanField(blank=True, null=True)),\n                ('metering_mode', models.CharField(blank=True, max_length=50, null=True)),\n                ('white_balance', models.CharField(blank=True, max_length=50, null=True)),\n                ('camera_make', models.CharField(blank=True, db_index=True, max_length=100, null=True)),\n                ('camera_model', models.CharField(blank=True, db_index=True, max_length=100, null=True)),\n                ('lens_make', models.CharField(blank=True, max_length=100, null=True)),\n                ('lens_model', models.CharField(blank=True, db_index=True, max_length=100, null=True)),\n                ('serial_number', models.CharField(blank=True, max_length=100, null=True)),\n                ('width', models.IntegerField(blank=True, null=True)),\n                ('height', models.IntegerField(blank=True, null=True)),\n                ('orientation', models.IntegerField(blank=True, null=True)),\n                ('color_space', models.CharField(blank=True, max_length=50, null=True)),\n                ('bit_depth', models.IntegerField(blank=True, null=True)),\n                ('date_taken', models.DateTimeField(blank=True, db_index=True, null=True)),\n                ('date_taken_subsec', models.CharField(blank=True, max_length=10, null=True)),\n                ('date_modified', models.DateTimeField(blank=True, null=True)),\n                ('timezone_offset', models.CharField(blank=True, max_length=10, null=True)),\n                ('gps_latitude', models.FloatField(blank=True, db_index=True, null=True)),\n                ('gps_longitude', models.FloatField(blank=True, db_index=True, null=True)),\n                ('gps_altitude', models.FloatField(blank=True, null=True)),\n                ('location_country', models.CharField(blank=True, db_index=True, max_length=100, null=True)),\n                ('location_state', models.CharField(blank=True, max_length=100, null=True)),\n                ('location_city', models.CharField(blank=True, db_index=True, max_length=100, null=True)),\n                ('location_address', models.TextField(blank=True, null=True)),\n                ('title', models.CharField(blank=True, max_length=500, null=True)),\n                ('caption', models.TextField(blank=True, null=True)),\n                ('keywords', models.JSONField(blank=True, null=True)),\n                ('rating', models.IntegerField(blank=True, db_index=True, null=True)),\n                ('copyright', models.TextField(blank=True, null=True)),\n                ('creator', models.CharField(blank=True, max_length=200, null=True)),\n                ('source', models.CharField(choices=[('embedded', 'Embedded in File'), ('sidecar', 'XMP Sidecar'), ('user_edit', 'User Edit'), ('computed', 'Computed')], default='embedded', max_length=20)),\n                ('raw_exif', models.JSONField(blank=True, null=True)),\n                ('raw_xmp', models.JSONField(blank=True, null=True)),\n                ('raw_iptc', models.JSONField(blank=True, null=True)),\n                ('version', models.IntegerField(default=1)),\n                ('created_at', models.DateTimeField(auto_now_add=True)),\n                ('updated_at', models.DateTimeField(auto_now=True)),\n            ],\n            options={\n                'verbose_name': 'Photo Metadata',\n                'verbose_name_plural': 'Photo Metadata',\n            },\n        ),\n        migrations.CreateModel(\n            name='StackReview',\n            fields=[\n                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),\n                ('decision', models.CharField(choices=[('pending', 'Pending Review'), ('resolved', 'Resolved'), ('dismissed', 'Dismissed')], db_index=True, default='pending', max_length=20)),\n                ('trashed_count', models.IntegerField(default=0)),\n                ('created_at', models.DateTimeField(auto_now_add=True)),\n                ('reviewed_at', models.DateTimeField(blank=True, null=True)),\n                ('note', models.TextField(blank=True, null=True)),\n            ],\n            options={\n                'verbose_name': 'Stack Review',\n                'verbose_name_plural': 'Stack Reviews',\n                'ordering': ['-created_at'],\n            },\n        ),\n        migrations.RemoveIndex(\n            model_name='photostack',\n            name='api_photost_owner_i_abc123_idx',\n        ),\n        migrations.RemoveIndex(\n            model_name='photostack',\n            name='api_photost_owner_i_def456_idx',\n        ),\n        migrations.RemoveField(\n            model_name='photostack',\n            name='status',\n        ),\n        migrations.AlterField(\n            model_name='photostack',\n            name='owner',\n            field=models.ForeignKey(on_delete=models.SET(api.models.user.get_deleted_user), related_name='photo_stacks', to=settings.AUTH_USER_MODEL),\n        ),\n        migrations.AddIndex(\n            model_name='photostack',\n            index=models.Index(fields=['owner', 'stack_type'], name='api_photost_owner_i_40a369_idx'),\n        ),\n        migrations.AddField(\n            model_name='metadataedit',\n            name='photo',\n            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='metadata_edits', to='api.photo'),\n        ),\n        migrations.AddField(\n            model_name='metadataedit',\n            name='user',\n            field=models.ForeignKey(on_delete=models.SET(api.models.user.get_deleted_user), related_name='metadata_edits', to=settings.AUTH_USER_MODEL),\n        ),\n        migrations.AddField(\n            model_name='metadatafile',\n            name='file',\n            field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='metadata_info', to='api.file'),\n        ),\n        migrations.AddField(\n            model_name='metadatafile',\n            name='photo',\n            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='metadata_files', to='api.photo'),\n        ),\n        migrations.AddField(\n            model_name='photometadata',\n            name='photo',\n            field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='metadata', to='api.photo'),\n        ),\n        migrations.AddField(\n            model_name='stackreview',\n            name='kept_photo',\n            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='kept_in_reviews', to='api.photo'),\n        ),\n        migrations.AddField(\n            model_name='stackreview',\n            name='reviewer',\n            field=models.ForeignKey(on_delete=models.SET(api.models.user.get_deleted_user), related_name='stack_reviews', to=settings.AUTH_USER_MODEL),\n        ),\n        migrations.AddField(\n            model_name='stackreview',\n            name='stack',\n            field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review', to='api.photostack'),\n        ),\n        migrations.AddIndex(\n            model_name='metadataedit',\n            index=models.Index(fields=['photo', '-created_at'], name='api_metadat_photo_i_21e478_idx'),\n        ),\n        migrations.AddIndex(\n            model_name='photometadata',\n            index=models.Index(fields=['camera_make', 'camera_model'], name='api_photome_camera__361eac_idx'),\n        ),\n        migrations.AddIndex(\n            model_name='photometadata',\n            index=models.Index(fields=['date_taken'], name='api_photome_date_ta_35e59b_idx'),\n        ),\n        migrations.AddIndex(\n            model_name='photometadata',\n            index=models.Index(fields=['location_country', 'location_city'], name='api_photome_locatio_1ca8bb_idx'),\n        ),\n        migrations.AddIndex(\n            model_name='stackreview',\n            index=models.Index(fields=['reviewer', 'decision'], name='api_stackre_reviewe_48380f_idx'),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0101_populate_photo_metadata.py",
    "content": "# Generated migration to populate PhotoMetadata from existing Photo data\n\nfrom django.db import migrations, transaction\nfrom django.db.models import Exists, OuterRef\n\n\nBATCH_SIZE = 1000\n\n\ndef populate_photo_metadata(apps, schema_editor):\n    \"\"\"\n    Populate PhotoMetadata for all existing photos.\n\n    This copies metadata fields from Photo model to the structured PhotoMetadata model.\n    PhotoMetadata provides:\n    - Normalized field names\n    - Edit history tracking\n    - XMP sidecar support\n    - Better organization of camera/lens/settings\n\n    Optimized for SQLite and PostgreSQL compatibility:\n    - Fetches all photo IDs upfront (avoids open cursor + writes conflict on SQLite)\n    - Loads caption data per-batch to avoid memory issues\n    - Processes in batches with per-batch transactions (no huge outer transaction)\n    \"\"\"\n    Photo = apps.get_model(\"api\", \"Photo\")\n    PhotoMetadata = apps.get_model(\"api\", \"PhotoMetadata\")\n    PhotoCaption = apps.get_model(\"api\", \"PhotoCaption\")\n\n    # Exists subquery for efficient filtering\n    existing_metadata = PhotoMetadata.objects.filter(photo_id=OuterRef('pk'))\n\n    # Collect all photo IDs that need metadata upfront.\n    # Using values_list avoids keeping a cursor open while we write below,\n    # which can cause SQLite \"database is locked\" / cursor-state issues.\n    photo_ids = list(\n        Photo.objects\n        .filter(~Exists(existing_metadata))\n        .values_list('pk', flat=True)\n    )\n\n    total_count = len(photo_ids)\n    if total_count == 0:\n        print(\"No photos need metadata population.\")\n        return\n\n    print(f\"Populating metadata for {total_count} photos...\")\n\n    processed = 0\n\n    for i in range(0, total_count, BATCH_SIZE):\n        chunk_ids = photo_ids[i:i + BATCH_SIZE]\n\n        # Load caption data for this chunk only\n        captions = {\n            c.photo_id: c.captions_json\n            for c in PhotoCaption.objects.filter(photo_id__in=chunk_ids)\n        }\n\n        batch = []\n        for photo in Photo.objects.filter(pk__in=chunk_ids):\n            captions_json = captions.get(photo.pk)\n            metadata = PhotoMetadata(\n                photo=photo,\n                # Camera info\n                camera_make=None,  # Not stored in Photo model separately\n                camera_model=photo.camera,\n                lens_make=None,  # Not stored separately\n                lens_model=photo.lens,\n                # Capture settings\n                aperture=photo.fstop,\n                shutter_speed=photo.shutter_speed,\n                iso=photo.iso,\n                focal_length=photo.focal_length,\n                focal_length_35mm=photo.focalLength35Equivalent,\n                # Image properties\n                width=photo.width,\n                height=photo.height,\n                # Date/time\n                date_taken=photo.exif_timestamp,\n                # GPS\n                gps_latitude=photo.exif_gps_lat,\n                gps_longitude=photo.exif_gps_lon,\n                # Content\n                title=None,  # Photo doesn't have separate title\n                caption=captions_json.get(\"user_caption\") if captions_json else None,\n                keywords=list(captions_json.get(\"keywords\", [])) if captions_json else [],\n                rating=photo.rating,\n                # Source\n                source=\"embedded\",  # All existing data came from EXIF\n                version=1,\n            )\n            batch.append(metadata)\n\n        with transaction.atomic():\n            PhotoMetadata.objects.bulk_create(batch, ignore_conflicts=True)\n        processed += len(batch)\n        print(f\"  Processed {processed}/{total_count} photos ({100*processed//total_count}%)\")\n\n    print(f\"Completed populating metadata for {processed} photos.\")\n\n\ndef reverse_populate(apps, schema_editor):\n    \"\"\"\n    Reverse migration - delete PhotoMetadata records.\n    Note: This will lose any user edits made through PhotoMetadata.\n    \"\"\"\n    PhotoMetadata = apps.get_model(\"api\", \"PhotoMetadata\")\n    PhotoMetadata.objects.all().delete()\n\n\nclass Migration(migrations.Migration):\n    \"\"\"\n    Data migration to populate PhotoMetadata from existing Photo data.\n    \n    This ensures backwards compatibility:\n    - Photo model still has all the original fields\n    - PhotoMetadata provides structured access + edit history\n    - API can read from either, preferring PhotoMetadata when available\n    \"\"\"\n\n    dependencies = [\n        (\"api\", \"0100_metadataedit_metadatafile_photometadata_stackreview_and_more\"),\n    ]\n\n    operations = [\n        migrations.RunPython(\n            populate_photo_metadata,\n            reverse_populate,\n            atomic=False,\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0102_photo_stacks_manytomany.py",
    "content": "\"\"\"\nMigration to convert Photo.stack ForeignKey to Photo.stacks ManyToManyField.\n\nThis change allows a photo to belong to multiple stacks of different types\nsimultaneously, preventing data loss when photos have multiple relationships:\n- A RAW+JPEG pair can also be visually similar to other photos\n- A burst sequence can also have exact copies\n- etc.\n\"\"\"\n\nfrom django.db import migrations, models\n\n\ndef migrate_fk_to_m2m(apps, schema_editor):\n    \"\"\"\n    Migrate existing ForeignKey relationships to ManyToMany.\n    \n    For each photo that has a stack ForeignKey set, add that stack\n    to the new ManyToMany relationship.\n    \"\"\"\n    Photo = apps.get_model('api', 'Photo')\n    \n    # Get all photos with a stack set (using the old FK field)\n    photos_with_stacks = Photo.objects.filter(stack__isnull=False).select_related('stack')\n    \n    for photo in photos_with_stacks:\n        # Add the old FK stack to the new M2M relationship\n        photo.stacks.add(photo.stack)\n\n\ndef reverse_m2m_to_fk(apps, schema_editor):\n    \"\"\"\n    Reverse migration: convert ManyToMany back to ForeignKey.\n    \n    For each photo, set the FK to the first stack in the M2M relationship.\n    Note: This may lose data if a photo was in multiple stacks.\n    \"\"\"\n    Photo = apps.get_model('api', 'Photo')\n    \n    for photo in Photo.objects.prefetch_related('stacks').all():\n        first_stack = photo.stacks.first()\n        if first_stack:\n            photo.stack = first_stack\n            photo.save(update_fields=['stack'])\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0101_populate_photo_metadata'),\n    ]\n\n    operations = [\n        # Step 1: Add the new ManyToMany field\n        migrations.AddField(\n            model_name='photo',\n            name='stacks',\n            field=models.ManyToManyField(\n                blank=True,\n                related_name='photos_m2m',\n                to='api.photostack',\n            ),\n        ),\n        \n        # Step 2: Migrate data from FK to M2M\n        migrations.RunPython(\n            migrate_fk_to_m2m,\n            reverse_m2m_to_fk,\n        ),\n        \n        # Step 3: Remove the old ForeignKey field\n        migrations.RemoveField(\n            model_name='photo',\n            name='stack',\n        ),\n        \n        # Step 4: Rename M2M related_name to final name\n        migrations.AlterField(\n            model_name='photo',\n            name='stacks',\n            field=models.ManyToManyField(\n                blank=True,\n                related_name='photos',\n                to='api.photostack',\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0103_remove_photo_metadata_fields.py",
    "content": "# Generated migration to remove deprecated metadata fields from Photo model\n# These fields have been migrated to PhotoMetadata model\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    \"\"\"\n    Remove deprecated metadata fields from Photo model.\n    \n    These fields have been migrated to the structured PhotoMetadata model:\n    - camera -> PhotoMetadata.camera_model\n    - lens -> PhotoMetadata.lens_model\n    - fstop -> PhotoMetadata.aperture\n    - shutter_speed -> PhotoMetadata.shutter_speed\n    - iso -> PhotoMetadata.iso\n    - focal_length -> PhotoMetadata.focal_length\n    - focalLength35Equivalent -> PhotoMetadata.focal_length_35mm\n    - width -> PhotoMetadata.width\n    - height -> PhotoMetadata.height\n    - digitalZoomRatio -> Not migrated (rarely used)\n    - subjectDistance -> Not migrated (rarely used)\n    \n    Data was already copied in migration 0101_populate_photo_metadata.\n    \"\"\"\n\n    dependencies = [\n        (\"api\", \"0102_photo_stacks_manytomany\"),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"fstop\",\n        ),\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"focal_length\",\n        ),\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"iso\",\n        ),\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"shutter_speed\",\n        ),\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"camera\",\n        ),\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"lens\",\n        ),\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"width\",\n        ),\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"height\",\n        ),\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"focalLength35Equivalent\",\n        ),\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"digitalZoomRatio\",\n        ),\n        migrations.RemoveField(\n            model_name=\"photo\",\n            name=\"subjectDistance\",\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0104_remove_photostack_potential_savings_and_more.py",
    "content": "# Generated by Django 5.2.9 on 2025-12-26 15:05\n\nimport api.models.user\nimport django.db.models.deletion\nimport uuid\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0103_remove_photo_metadata_fields'),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name='photostack',\n            name='potential_savings',\n        ),\n        migrations.RemoveField(\n            model_name='photostack',\n            name='similarity_score',\n        ),\n        migrations.AlterField(\n            model_name='photostack',\n            name='stack_type',\n            field=models.CharField(choices=[('raw_jpeg', 'RAW + JPEG Pair'), ('burst', 'Burst Sequence'), ('bracket', 'Exposure Bracket'), ('live_photo', 'Live Photo'), ('manual', 'Manual Stack')], db_index=True, default='raw_jpeg', max_length=20),\n        ),\n        migrations.CreateModel(\n            name='Duplicate',\n            fields=[\n                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),\n                ('duplicate_type', models.CharField(choices=[('exact_copy', 'Exact Copies'), ('visual_duplicate', 'Visual Duplicates')], db_index=True, default='visual_duplicate', max_length=20)),\n                ('review_status', models.CharField(choices=[('pending', 'Pending Review'), ('resolved', 'Resolved'), ('dismissed', 'Dismissed')], db_index=True, default='pending', max_length=20)),\n                ('created_at', models.DateTimeField(auto_now_add=True)),\n                ('updated_at', models.DateTimeField(auto_now=True)),\n                ('reviewed_at', models.DateTimeField(blank=True, null=True)),\n                ('similarity_score', models.FloatField(blank=True, null=True)),\n                ('potential_savings', models.BigIntegerField(default=0)),\n                ('trashed_count', models.IntegerField(default=0)),\n                ('note', models.TextField(blank=True, null=True)),\n                ('kept_photo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='kept_in_duplicates', to='api.photo')),\n                ('owner', models.ForeignKey(on_delete=models.SET(api.models.user.get_deleted_user), related_name='duplicates', to=settings.AUTH_USER_MODEL)),\n            ],\n            options={\n                'verbose_name': 'Duplicate',\n                'verbose_name_plural': 'Duplicates',\n                'ordering': ['-created_at'],\n            },\n        ),\n        migrations.AddField(\n            model_name='photo',\n            name='duplicates',\n            field=models.ManyToManyField(blank=True, related_name='photos', to='api.duplicate'),\n        ),\n        migrations.AddIndex(\n            model_name='duplicate',\n            index=models.Index(fields=['owner', 'duplicate_type'], name='api_duplica_owner_i_78a3a4_idx'),\n        ),\n        migrations.AddIndex(\n            model_name='duplicate',\n            index=models.Index(fields=['owner', 'review_status'], name='api_duplica_owner_i_039fa3_idx'),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0105_alter_photo_image_hash.py",
    "content": "# Generated by Django 5.2.9 on 2025-12-26 16:12\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0104_remove_photostack_potential_savings_and_more'),\n    ]\n\n    operations = [\n        # Drop the unique index created by migration 0099 (it was created as an index, not a constraint)\n        migrations.RunSQL(\n            sql=\"DROP INDEX IF EXISTS api_photo_image_hash_unique;\",\n            reverse_sql=\"CREATE UNIQUE INDEX IF NOT EXISTS api_photo_image_hash_unique ON api_photo(image_hash);\",\n        ),\n        migrations.AlterField(\n            model_name='photo',\n            name='image_hash',\n            field=models.CharField(db_index=True, max_length=64),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0106_alter_longrunningjob_options.py",
    "content": "# Generated by Django 5.2.9 on 2025-12-26 19:35\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0105_alter_photo_image_hash'),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name='longrunningjob',\n            options={'ordering': ['-queued_at'], 'verbose_name': 'Long Running Job', 'verbose_name_plural': 'Long Running Jobs'},\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0107_add_burst_detection_rules.py",
    "content": "# Generated by Django 5.0 on 2024-12-26\n\nfrom django.db import migrations, models\n\nimport api.models.user\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0106_alter_longrunningjob_options\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"burst_detection_rules\",\n            field=models.JSONField(\n                default=api.models.user.get_default_config_burst_detection_rules\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0108_add_stack_raw_jpeg_field.py",
    "content": "# Generated migration for adding stack_raw_jpeg field\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"api\", \"0107_add_burst_detection_rules\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"stack_raw_jpeg\",\n            field=models.BooleanField(default=True),\n        ),\n    ]\n\n\n"
  },
  {
    "path": "api/migrations/0109_migrate_skip_raw_to_stack_raw_jpeg.py",
    "content": "# Data migration to set stack_raw_jpeg based on skip_raw_files\n# If skip_raw_files was True (skip RAWs), then stack_raw_jpeg should be False (don't stack)\n# If skip_raw_files was False (don't skip RAWs), then stack_raw_jpeg should be True (stack them)\n\nfrom django.db import migrations\n\n\ndef migrate_skip_raw_to_stack_raw_jpeg(apps, schema_editor):\n    User = apps.get_model(\"api\", \"User\")\n    # Set stack_raw_jpeg = not skip_raw_files\n    # If user was skipping RAW files, they probably don't want them stacked\n    # If user was not skipping RAW files, enable stacking by default\n    User.objects.filter(skip_raw_files=True).update(stack_raw_jpeg=False)\n    User.objects.filter(skip_raw_files=False).update(stack_raw_jpeg=True)\n\n\ndef reverse_migration(apps, schema_editor):\n    # Reverse migration: set skip_raw_files based on stack_raw_jpeg\n    User = apps.get_model(\"api\", \"User\")\n    User.objects.filter(stack_raw_jpeg=False).update(skip_raw_files=True)\n    User.objects.filter(stack_raw_jpeg=True).update(skip_raw_files=False)\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"api\", \"0108_add_stack_raw_jpeg_field\"),\n    ]\n\n    operations = [\n        migrations.RunPython(migrate_skip_raw_to_stack_raw_jpeg, reverse_migration),\n    ]\n\n\n"
  },
  {
    "path": "api/migrations/0110_fix_file_embedded_media_self_reference.py",
    "content": "# Generated migration to fix self-referential ManyToManyField\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"api\", \"0109_migrate_skip_raw_to_stack_raw_jpeg\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"file\",\n            name=\"embedded_media\",\n            field=models.ManyToManyField(\"self\", symmetrical=False),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0111_alter_file_embedded_media.py",
    "content": "# Generated by Django 5.2.9 on 2026-01-08 19:16\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0110_fix_file_embedded_media_self_reference'),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name='file',\n            name='embedded_media',\n            field=models.ManyToManyField(to='api.file'),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0112_convert_file_stacks_to_variants.py",
    "content": "\"\"\"\nMigration to convert RAW_JPEG_PAIR and LIVE_PHOTO stacks to file variants.\n\nThis migration implements the PhotoPrism-like file variant model where:\n- RAW+JPEG pairs become one Photo with multiple files\n- Live Photos (image+video) become one Photo with multiple files\n\nInstead of having 2 Photo entities in a stack, we now have 1 Photo entity\nwith multiple File entries in its files ManyToMany field.\n\nThis is a data migration that:\n1. For each RAW_JPEG_PAIR stack:\n   - Identifies the JPEG Photo (primary) and RAW Photo\n   - Moves the RAW file to the JPEG Photo's files field\n   - Deletes the RAW Photo entity\n   - Deletes the stack\n2. For each LIVE_PHOTO stack:\n   - Identifies the image Photo (primary) and video Photo\n   - Moves the video file to the image Photo's files field\n   - Deletes the video Photo entity\n   - Deletes the stack\n\nOptimized for large datasets:\n- Uses prefetch_related to eliminate N+1 queries\n- Uses bulk M2M operations via through model\n- Processes in batches with progress logging\n\"\"\"\n\nfrom django.db import migrations, transaction\nfrom django.db.models import Prefetch\n\n\nBATCH_SIZE = 500\n\n\ndef convert_raw_jpeg_stacks_to_file_variants(apps, schema_editor):\n    \"\"\"Convert RAW_JPEG_PAIR stacks to Photo.files variants.\"\"\"\n    PhotoStack = apps.get_model('api', 'PhotoStack')\n    Photo = apps.get_model('api', 'Photo')\n    File = apps.get_model('api', 'File')\n    \n    # Get through model for bulk M2M operations\n    PhotoFiles = Photo.files.through\n    PhotoStacks = Photo.stacks.through\n    \n    # RAW_JPEG_PAIR = \"raw_jpeg\"\n    # Count total for progress logging\n    total_count = PhotoStack.objects.filter(stack_type=\"raw_jpeg\").count()\n    if total_count == 0:\n        print(\"No RAW_JPEG_PAIR stacks to convert.\")\n        return\n    \n    print(f\"Converting {total_count} RAW_JPEG_PAIR stacks to file variants...\")\n    \n    # Prefetch photos with their files and main_file to eliminate N+1 queries\n    raw_jpeg_stacks = (\n        PhotoStack.objects\n        .filter(stack_type=\"raw_jpeg\")\n        .prefetch_related(\n            Prefetch(\n                'photos',\n                queryset=Photo.objects.select_related('main_file').prefetch_related('files')\n            )\n        )\n    )\n    \n    converted_count = 0\n    error_count = 0\n    \n    # Collect bulk operations\n    m2m_files_to_create = []\n    photos_to_delete = []\n    stacks_to_delete = []\n    m2m_stacks_to_delete = []\n    \n    for stack in raw_jpeg_stacks.iterator(chunk_size=BATCH_SIZE):\n        try:\n            # Photos are already prefetched - no extra query\n            photos = list(stack.photos.all())\n            \n            if len(photos) != 2:\n                print(f\"WARNING: RAW_JPEG stack {stack.id} has {len(photos)} photos, expected 2. Skipping.\")\n                error_count += 1\n                continue\n            \n            # Identify JPEG and RAW photos\n            # RAW files have type=4 in File model\n            jpeg_photo = None\n            raw_photo = None\n            \n            for photo in photos:\n                if photo.main_file and photo.main_file.type == 4:  # RAW_FILE\n                    raw_photo = photo\n                else:\n                    jpeg_photo = photo\n            \n            if not jpeg_photo or not raw_photo:\n                print(f\"WARNING: Could not identify JPEG/RAW in stack {stack.id}. Skipping.\")\n                error_count += 1\n                continue\n            \n            # Collect files to add to jpeg_photo (using prefetched data)\n            files_to_add = list(raw_photo.files.all())\n            if raw_photo.main_file:\n                files_to_add.append(raw_photo.main_file)\n            \n            # Build M2M through model entries for bulk create\n            existing_file_hashes = set(f.hash for f in jpeg_photo.files.all())\n            for file in files_to_add:\n                if file.hash not in existing_file_hashes:\n                    m2m_files_to_create.append(\n                        PhotoFiles(photo_id=jpeg_photo.pk, file_id=file.hash)\n                    )\n                    existing_file_hashes.add(file.hash)\n            \n            # Collect M2M stack relationships to delete\n            for photo in photos:\n                m2m_stacks_to_delete.append((photo.pk, stack.pk))\n            \n            photos_to_delete.append(raw_photo.pk)\n            stacks_to_delete.append(stack.pk)\n            converted_count += 1\n            \n            # Process in batches to avoid memory buildup\n            if len(stacks_to_delete) >= BATCH_SIZE:\n                _flush_raw_jpeg_batch(\n                    PhotoFiles, PhotoStacks, Photo, PhotoStack,\n                    m2m_files_to_create, m2m_stacks_to_delete, photos_to_delete, stacks_to_delete\n                )\n                m2m_files_to_create = []\n                m2m_stacks_to_delete = []\n                photos_to_delete = []\n                stacks_to_delete = []\n                print(f\"  Processed {converted_count}/{total_count} stacks ({100*converted_count//total_count}%)\")\n            \n        except Exception as e:\n            print(f\"ERROR converting RAW_JPEG stack {stack.id}: {e}\")\n            error_count += 1\n    \n    # Flush remaining batch\n    if stacks_to_delete:\n        _flush_raw_jpeg_batch(\n            PhotoFiles, PhotoStacks, Photo, PhotoStack,\n            m2m_files_to_create, m2m_stacks_to_delete, photos_to_delete, stacks_to_delete\n        )\n    \n    print(f\"Converted {converted_count} RAW_JPEG_PAIR stacks to file variants ({error_count} errors)\")\n\n\ndef _flush_raw_jpeg_batch(PhotoFiles, PhotoStacks, Photo, PhotoStack,\n                          m2m_files_to_create, m2m_stacks_to_delete, photos_to_delete, stacks_to_delete):\n    \"\"\"Flush a batch of operations to the database.\"\"\"\n    with transaction.atomic():\n        # Bulk create M2M file relationships\n        if m2m_files_to_create:\n            PhotoFiles.objects.bulk_create(m2m_files_to_create, ignore_conflicts=True)\n        \n        # Bulk delete M2M stack relationships\n        if m2m_stacks_to_delete:\n            for photo_id, stack_id in m2m_stacks_to_delete:\n                PhotoStacks.objects.filter(photo_id=photo_id, photostack_id=stack_id).delete()\n        \n        # Bulk delete photos\n        if photos_to_delete:\n            Photo.objects.filter(pk__in=photos_to_delete).delete()\n        \n        # Bulk delete stacks\n        if stacks_to_delete:\n            PhotoStack.objects.filter(pk__in=stacks_to_delete).delete()\n\n\ndef convert_live_photo_stacks_to_file_variants(apps, schema_editor):\n    \"\"\"Convert LIVE_PHOTO stacks to Photo.files variants.\"\"\"\n    PhotoStack = apps.get_model('api', 'PhotoStack')\n    Photo = apps.get_model('api', 'Photo')\n    File = apps.get_model('api', 'File')\n    \n    # Get through model for bulk M2M operations\n    PhotoFiles = Photo.files.through\n    PhotoStacks = Photo.stacks.through\n    \n    # LIVE_PHOTO = \"live_photo\"\n    # Count total for progress logging\n    total_count = PhotoStack.objects.filter(stack_type=\"live_photo\").count()\n    if total_count == 0:\n        print(\"No LIVE_PHOTO stacks to convert.\")\n        return\n    \n    print(f\"Converting {total_count} LIVE_PHOTO stacks to file variants...\")\n    \n    # Prefetch photos with their files and main_file to eliminate N+1 queries\n    live_photo_stacks = (\n        PhotoStack.objects\n        .filter(stack_type=\"live_photo\")\n        .prefetch_related(\n            Prefetch(\n                'photos',\n                queryset=Photo.objects.select_related('main_file').prefetch_related('files')\n            )\n        )\n    )\n    \n    converted_count = 0\n    error_count = 0\n    \n    # Collect bulk operations\n    m2m_files_to_create = []\n    photos_to_delete = []\n    stacks_to_delete = []\n    m2m_stacks_to_delete = []\n    \n    for stack in live_photo_stacks.iterator(chunk_size=BATCH_SIZE):\n        try:\n            # Photos are already prefetched - no extra query\n            photos = list(stack.photos.all())\n            \n            if len(photos) != 2:\n                print(f\"WARNING: LIVE_PHOTO stack {stack.id} has {len(photos)} photos, expected 2. Skipping.\")\n                error_count += 1\n                continue\n            \n            # Identify image and video photos\n            # VIDEO files have type=2 in File model\n            image_photo = None\n            video_photo = None\n            \n            for photo in photos:\n                if photo.main_file and photo.main_file.type == 2:  # VIDEO\n                    video_photo = photo\n                elif photo.video:\n                    video_photo = photo\n                else:\n                    image_photo = photo\n            \n            if not image_photo or not video_photo:\n                print(f\"WARNING: Could not identify image/video in LIVE_PHOTO stack {stack.id}. Skipping.\")\n                error_count += 1\n                continue\n            \n            # Collect files to add to image_photo (using prefetched data)\n            files_to_add = list(video_photo.files.all())\n            if video_photo.main_file:\n                files_to_add.append(video_photo.main_file)\n            \n            # Build M2M through model entries for bulk create\n            existing_file_hashes = set(f.hash for f in image_photo.files.all())\n            for file in files_to_add:\n                if file.hash not in existing_file_hashes:\n                    m2m_files_to_create.append(\n                        PhotoFiles(photo_id=image_photo.pk, file_id=file.hash)\n                    )\n                    existing_file_hashes.add(file.hash)\n            \n            # Collect M2M stack relationships to delete\n            for photo in photos:\n                m2m_stacks_to_delete.append((photo.pk, stack.pk))\n            \n            photos_to_delete.append(video_photo.pk)\n            stacks_to_delete.append(stack.pk)\n            converted_count += 1\n            \n            # Process in batches to avoid memory buildup\n            if len(stacks_to_delete) >= BATCH_SIZE:\n                _flush_live_photo_batch(\n                    PhotoFiles, PhotoStacks, Photo, PhotoStack,\n                    m2m_files_to_create, m2m_stacks_to_delete, photos_to_delete, stacks_to_delete\n                )\n                m2m_files_to_create = []\n                m2m_stacks_to_delete = []\n                photos_to_delete = []\n                stacks_to_delete = []\n                print(f\"  Processed {converted_count}/{total_count} stacks ({100*converted_count//total_count}%)\")\n            \n        except Exception as e:\n            print(f\"ERROR converting LIVE_PHOTO stack {stack.id}: {e}\")\n            error_count += 1\n    \n    # Flush remaining batch\n    if stacks_to_delete:\n        _flush_live_photo_batch(\n            PhotoFiles, PhotoStacks, Photo, PhotoStack,\n            m2m_files_to_create, m2m_stacks_to_delete, photos_to_delete, stacks_to_delete\n        )\n    \n    print(f\"Converted {converted_count} LIVE_PHOTO stacks to file variants ({error_count} errors)\")\n\n\ndef _flush_live_photo_batch(PhotoFiles, PhotoStacks, Photo, PhotoStack,\n                            m2m_files_to_create, m2m_stacks_to_delete, photos_to_delete, stacks_to_delete):\n    \"\"\"Flush a batch of operations to the database.\"\"\"\n    with transaction.atomic():\n        # Bulk create M2M file relationships\n        if m2m_files_to_create:\n            PhotoFiles.objects.bulk_create(m2m_files_to_create, ignore_conflicts=True)\n        \n        # Bulk delete M2M stack relationships\n        if m2m_stacks_to_delete:\n            for photo_id, stack_id in m2m_stacks_to_delete:\n                PhotoStacks.objects.filter(photo_id=photo_id, photostack_id=stack_id).delete()\n        \n        # Bulk delete photos\n        if photos_to_delete:\n            Photo.objects.filter(pk__in=photos_to_delete).delete()\n        \n        # Bulk delete stacks\n        if stacks_to_delete:\n            PhotoStack.objects.filter(pk__in=stacks_to_delete).delete()\n\n\ndef forward_migration(apps, schema_editor):\n    \"\"\"Run both conversions.\"\"\"\n    convert_raw_jpeg_stacks_to_file_variants(apps, schema_editor)\n    convert_live_photo_stacks_to_file_variants(apps, schema_editor)\n\n\ndef reverse_migration(apps, schema_editor):\n    \"\"\"\n    Reverse migration is not fully supported as we've deleted Photo entities.\n    This would require recreating the deleted Photos which is complex.\n    Instead, we just print a warning.\n    \"\"\"\n    print(\"WARNING: Reverse migration is not supported. \"\n          \"RAW_JPEG_PAIR and LIVE_PHOTO stacks cannot be recreated automatically. \"\n          \"Run a full rescan to detect file variants again.\")\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0111_alter_file_embedded_media'),\n    ]\n\n    operations = [\n        migrations.RunPython(forward_migration, reverse_migration),\n    ]\n"
  },
  {
    "path": "api/migrations/0113_alter_photostack_stack_type.py",
    "content": "# Generated by Django 5.2.9 on 2026-01-21 13:36\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0112_convert_file_stacks_to_variants'),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name='photostack',\n            name='stack_type',\n            field=models.CharField(choices=[('burst', 'Burst Sequence'), ('bracket', 'Exposure Bracket'), ('manual', 'Manual Stack'), ('raw_jpeg', 'RAW + JPEG Pair (Deprecated)'), ('live_photo', 'Live Photo (Deprecated)')], db_index=True, default='manual', max_length=20),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0114_add_file_path_unique.py",
    "content": "# Generated migration to add unique constraint on File.path\n# This migration handles existing duplicate paths before adding the constraint\n\nfrom django.db import migrations, models, transaction\nfrom django.db.models import Count, Exists, OuterRef, Case, When, Value, IntegerField\n\n\nBATCH_SIZE = 500\n\n\ndef deduplicate_file_paths(apps, schema_editor):\n    \"\"\"\n    Deduplicate File records that have the same path.\n    \n    Strategy:\n    1. Find all paths that have multiple File records\n    2. For each duplicate group, keep the \"best\" File:\n       - Prefer non-missing files\n       - Prefer files that are main_file for a Photo\n       - Prefer files linked to more Photos\n    3. Reassign all Photo associations from deleted Files to kept File\n    4. Delete duplicate File records\n    \n    Optimized for large datasets:\n    - Uses annotations to compute scores in database instead of per-file queries\n    - Uses bulk M2M operations via through model\n    - Uses prefetch_related to eliminate N+1 queries\n    - Processes in batches with progress logging\n    \"\"\"\n    File = apps.get_model('api', 'File')\n    Photo = apps.get_model('api', 'Photo')\n    \n    # Get through models for bulk M2M operations\n    PhotoFiles = Photo.files.through\n    FileEmbeddedMedia = File.embedded_media.through\n    \n    # Find paths that have duplicates (excluding empty paths)\n    duplicate_paths = list(\n        File.objects\n        .exclude(path='')\n        .exclude(path__isnull=True)\n        .values('path')\n        .annotate(count=Count('hash'))\n        .filter(count__gt=1)\n    )\n    \n    total_count = len(duplicate_paths)\n    if total_count == 0:\n        print(\"No duplicate file paths to deduplicate.\")\n        return\n    \n    print(f\"Deduplicating {total_count} file paths...\")\n    \n    processed = 0\n    deleted_count = 0\n    \n    for dup in duplicate_paths:\n        path = dup['path']\n        \n        # Subquery to check if file is main_file for any Photo\n        is_main_file = Photo.objects.filter(main_file_id=OuterRef('hash'))\n        \n        # Get all Files with this path, annotated with scores computed in DB\n        files = list(\n            File.objects\n            .filter(path=path)\n            .annotate(\n                photo_count=Count('photo', distinct=True),\n                is_main=Case(\n                    When(Exists(is_main_file), then=Value(50)),\n                    default=Value(0),\n                    output_field=IntegerField()\n                ),\n                missing_penalty=Case(\n                    When(missing=False, then=Value(100)),\n                    default=Value(0),\n                    output_field=IntegerField()\n                ),\n            )\n            .prefetch_related('photo_set', 'embedded_media')\n            .order_by('-missing_penalty', '-is_main', '-photo_count')\n        )\n        \n        if len(files) <= 1:\n            continue\n        \n        # First file is the best one (sorted by score descending)\n        keep_file = files[0]\n        delete_files = files[1:]\n        \n        with transaction.atomic():\n            # Collect M2M entries to create\n            m2m_photo_files_to_create = []\n            m2m_photo_files_to_delete = []\n            m2m_embedded_to_create = []\n            m2m_embedded_to_delete = []\n            photos_to_update_main = []\n            \n            for del_file in delete_files:\n                # Get all Photos that have this file in their files M2M (prefetched)\n                for photo in del_file.photo_set.all():\n                    # Schedule add of keep_file to photo\n                    m2m_photo_files_to_create.append(\n                        PhotoFiles(photo_id=photo.pk, file_id=keep_file.hash)\n                    )\n                    # Schedule removal of del_file from photo\n                    m2m_photo_files_to_delete.append((photo.pk, del_file.hash))\n                \n                # Update main_file references in bulk\n                photos_with_main = Photo.objects.filter(main_file=del_file)\n                for photo in photos_with_main:\n                    photo.main_file = keep_file\n                    photos_to_update_main.append(photo)\n                \n                # Handle embedded_media M2M (prefetched)\n                for parent_file in File.objects.filter(embedded_media=del_file):\n                    m2m_embedded_to_create.append(\n                        FileEmbeddedMedia(from_file_id=parent_file.hash, to_file_id=keep_file.hash)\n                    )\n                    m2m_embedded_to_delete.append((parent_file.hash, del_file.hash))\n            \n            # Execute bulk operations\n            if m2m_photo_files_to_create:\n                PhotoFiles.objects.bulk_create(m2m_photo_files_to_create, ignore_conflicts=True)\n            \n            if m2m_photo_files_to_delete:\n                for photo_id, file_id in m2m_photo_files_to_delete:\n                    PhotoFiles.objects.filter(photo_id=photo_id, file_id=file_id).delete()\n            \n            if photos_to_update_main:\n                Photo.objects.bulk_update(photos_to_update_main, ['main_file'])\n            \n            if m2m_embedded_to_create:\n                FileEmbeddedMedia.objects.bulk_create(m2m_embedded_to_create, ignore_conflicts=True)\n            \n            if m2m_embedded_to_delete:\n                for from_id, to_id in m2m_embedded_to_delete:\n                    FileEmbeddedMedia.objects.filter(from_file_id=from_id, to_file_id=to_id).delete()\n            \n            # Delete duplicate files in bulk\n            delete_hashes = [f.hash for f in delete_files]\n            File.objects.filter(hash__in=delete_hashes).delete()\n            deleted_count += len(delete_files)\n        \n        processed += 1\n        if processed % 100 == 0:\n            print(f\"  Processed {processed}/{total_count} duplicate paths ({100*processed//total_count}%)\")\n    \n    print(f\"Completed deduplication. Deleted {deleted_count} duplicate files.\")\n\n\ndef reverse_deduplicate(apps, schema_editor):\n    \"\"\"\n    Reverse migration is a no-op since we can't restore deleted duplicates.\n    The unique constraint will be dropped by the AlterField reverse.\n    \"\"\"\n    pass\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0113_alter_photostack_stack_type'),\n    ]\n\n    operations = [\n        # First, deduplicate existing paths\n        migrations.RunPython(\n            deduplicate_file_paths,\n            reverse_deduplicate,\n        ),\n        # Then add the unique constraint\n        migrations.AlterField(\n            model_name='file',\n            name='path',\n            field=models.TextField(blank=True, default=\"\", unique=True),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0115_cleanup_duplicate_photos.py",
    "content": "# Generated migration to cleanup duplicate Photo records\n# This migration handles Photos with the same image_hash for the same owner\n\nfrom django.db import migrations, transaction\nfrom django.db.models import Count, Case, When, Value, F, IntegerField\n\n\nBATCH_SIZE = 100\n\n\ndef cleanup_duplicate_photos(apps, schema_editor):\n    \"\"\"\n    Cleanup Photo records that have the same image_hash for the same owner.\n    \n    Strategy:\n    1. Find all (image_hash, owner) combinations that have multiple Photo records\n    2. For each duplicate group, keep the \"best\" Photo:\n       - Prefer non-removed photos\n       - Prefer non-trashed photos\n       - Prefer photos with main_file\n       - Prefer photos with more files attached\n       - Prefer photos with more metadata (faces, albums, etc.)\n       - Prefer older photos (smaller added_on)\n    3. Merge associations from duplicate Photos to kept Photo:\n       - files (M2M)\n       - albums (album_user, album_thing, album_place, album_date)\n       - faces\n       - stacks\n       - duplicates (duplicate groups)\n       - shared_to\n    4. Delete duplicate Photos\n    \n    Optimized for large datasets:\n    - Uses database annotations to compute scores instead of per-photo queries\n    - Uses bulk M2M operations via through model\n    - Uses prefetch_related to eliminate N+1 queries\n    - Processes in batches with progress logging\n    \"\"\"\n    Photo = apps.get_model('api', 'Photo')\n    Face = apps.get_model('api', 'Face')\n    AlbumUser = apps.get_model('api', 'AlbumUser')\n    \n    # Get through models for bulk M2M operations\n    PhotoFiles = Photo.files.through\n    PhotoStacks = Photo.stacks.through\n    PhotoDuplicates = Photo.duplicates.through\n    PhotoSharedTo = Photo.shared_to.through\n    AlbumUserPhotos = AlbumUser.photos.through\n    \n    # Find (image_hash, owner) combinations with duplicates\n    # Exclude already removed photos from consideration\n    duplicate_groups = list(\n        Photo.objects\n        .filter(removed=False)\n        .values('image_hash', 'owner')\n        .annotate(count=Count('id'))\n        .filter(count__gt=1)\n    )\n    \n    total_count = len(duplicate_groups)\n    if total_count == 0:\n        print(\"No duplicate photos to clean up.\")\n        return\n    \n    print(f\"Cleaning up {total_count} duplicate photo groups...\")\n    \n    merged_count = 0\n    processed = 0\n    \n    for dup in duplicate_groups:\n        image_hash = dup['image_hash']\n        owner_id = dup['owner']\n        \n        # Get all non-removed Photos with this hash/owner\n        # Annotate with scores computed in database\n        photos = list(\n            Photo.objects\n            .filter(image_hash=image_hash, owner_id=owner_id, removed=False)\n            .annotate(\n                file_count=Count('files', distinct=True),\n                face_count=Count('face', distinct=True),\n                album_count=Count('albumuser', distinct=True),\n                stack_count=Count('stacks', distinct=True),\n                # Compute score in database\n                score=(\n                    Case(When(removed=False, then=Value(1000)), default=Value(0), output_field=IntegerField()) +\n                    Case(When(in_trashcan=False, then=Value(500)), default=Value(0), output_field=IntegerField()) +\n                    Case(When(main_file__isnull=False, then=Value(200)), default=Value(0), output_field=IntegerField()) +\n                    F('file_count') * 10 +\n                    F('face_count') * 5 +\n                    F('album_count') * 2 +\n                    F('stack_count') * 2 +\n                    Case(When(clip_embeddings__isnull=False, then=Value(50)), default=Value(0), output_field=IntegerField()) +\n                    Case(When(perceptual_hash__isnull=False, then=Value(30)), default=Value(0), output_field=IntegerField()) +\n                    Case(When(geolocation_json__isnull=False, then=Value(20)), default=Value(0), output_field=IntegerField())\n                )\n            )\n            .select_related('main_file')\n            .prefetch_related('files', 'stacks', 'duplicates', 'shared_to', 'albumuser_set')\n            .order_by('-score', 'added_on')  # Higher score first, then older\n        )\n        \n        if len(photos) <= 1:\n            continue\n        \n        keep_photo = photos[0]\n        merge_photos = photos[1:]\n        merge_ids = [p.pk for p in merge_photos]\n        \n        with transaction.atomic():\n            # Bulk update faces - single query\n            Face.objects.filter(photo_id__in=merge_ids).update(photo=keep_photo)\n            \n            # Collect existing M2M IDs to prevent duplicates\n            existing_file_ids = set(keep_photo.files.values_list('hash', flat=True))\n            existing_stack_ids = set(keep_photo.stacks.values_list('id', flat=True))\n            existing_duplicate_ids = set(keep_photo.duplicates.values_list('id', flat=True))\n            existing_shared_ids = set(keep_photo.shared_to.values_list('id', flat=True))\n            existing_album_ids = set(keep_photo.albumuser_set.values_list('id', flat=True))\n            \n            # Collect M2M entries to create\n            new_files = []\n            new_stacks = []\n            new_duplicates = []\n            new_shared = []\n            new_albums = []\n            \n            # Track if we need to update main_file\n            main_file_candidate = None\n            \n            for merge_photo in merge_photos:\n                # Collect files (prefetched)\n                for file in merge_photo.files.all():\n                    if file.hash not in existing_file_ids:\n                        new_files.append(PhotoFiles(photo_id=keep_photo.pk, file_id=file.hash))\n                        existing_file_ids.add(file.hash)\n                \n                # If kept photo has no main_file but merge_photo does, remember it\n                if not keep_photo.main_file_id and merge_photo.main_file_id and not main_file_candidate:\n                    main_file_candidate = merge_photo.main_file\n                \n                # Collect stacks (prefetched)\n                for stack in merge_photo.stacks.all():\n                    if stack.id not in existing_stack_ids:\n                        new_stacks.append(PhotoStacks(photo_id=keep_photo.pk, photostack_id=stack.id))\n                        existing_stack_ids.add(stack.id)\n                \n                # Collect duplicates (prefetched)\n                for dup_group in merge_photo.duplicates.all():\n                    if dup_group.id not in existing_duplicate_ids:\n                        new_duplicates.append(PhotoDuplicates(photo_id=keep_photo.pk, duplicate_id=dup_group.id))\n                        existing_duplicate_ids.add(dup_group.id)\n                \n                # Collect shared_to (prefetched)\n                for user in merge_photo.shared_to.all():\n                    if user.id not in existing_shared_ids:\n                        new_shared.append(PhotoSharedTo(photo_id=keep_photo.pk, user_id=user.id))\n                        existing_shared_ids.add(user.id)\n                \n                # Collect album memberships (prefetched)\n                for album in merge_photo.albumuser_set.all():\n                    if album.id not in existing_album_ids:\n                        new_albums.append(AlbumUserPhotos(albumuser_id=album.id, photo_id=keep_photo.pk))\n                        existing_album_ids.add(album.id)\n            \n            # Bulk create all M2M relationships\n            if new_files:\n                PhotoFiles.objects.bulk_create(new_files, ignore_conflicts=True)\n            if new_stacks:\n                PhotoStacks.objects.bulk_create(new_stacks, ignore_conflicts=True)\n            if new_duplicates:\n                PhotoDuplicates.objects.bulk_create(new_duplicates, ignore_conflicts=True)\n            if new_shared:\n                PhotoSharedTo.objects.bulk_create(new_shared, ignore_conflicts=True)\n            if new_albums:\n                AlbumUserPhotos.objects.bulk_create(new_albums, ignore_conflicts=True)\n            \n            # Update main_file if needed\n            if main_file_candidate:\n                keep_photo.main_file = main_file_candidate\n                keep_photo.save(update_fields=['main_file'])\n            \n            # Bulk delete merge photos\n            # Django's delete() on queryset handles M2M clearing automatically\n            Photo.objects.filter(pk__in=merge_ids).delete()\n            merged_count += len(merge_ids)\n        \n        processed += 1\n        if processed % BATCH_SIZE == 0:\n            print(f\"  Processed {processed}/{total_count} duplicate groups ({100*processed//total_count}%)\")\n    \n    if merged_count > 0:\n        print(f\"Completed cleanup. Deleted {merged_count} duplicate Photo records.\")\n\n\ndef reverse_cleanup(apps, schema_editor):\n    \"\"\"\n    Reverse migration is a no-op since deleted photos cannot be restored.\n    \"\"\"\n    pass\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0114_add_file_path_unique'),\n    ]\n\n    operations = [\n        migrations.RunPython(\n            cleanup_duplicate_photos,\n            reverse_cleanup,\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0116_cleanup_duplicate_groups_removed_photos.py",
    "content": "# Generated migration to clean up Duplicate groups containing removed photos\n# This removes removed=True photos from groups and deletes groups with 0-1 photos remaining\n\nfrom django.db import migrations\n\n\ndef cleanup_duplicate_groups(apps, schema_editor):\n    \"\"\"\n    Remove removed=True photos from Duplicate groups.\n    Delete Duplicate groups that end up with 0 or 1 photos.\n    \n    This is needed because migration 0115 marks duplicate Photos as removed=True\n    but doesn't remove them from their Duplicate group M2M relationships.\n    \"\"\"\n    Duplicate = apps.get_model('api', 'Duplicate')\n    \n    cleaned_count = 0\n    deleted_count = 0\n    \n    for duplicate in Duplicate.objects.all():\n        # Get removed photos in this group\n        removed_photos = duplicate.photos.filter(removed=True)\n        removed_count = removed_photos.count()\n        \n        if removed_count > 0:\n            # Remove the removed photos from the group\n            for photo in removed_photos:\n                duplicate.photos.remove(photo)\n            cleaned_count += removed_count\n        \n        # Check if group now has 0 or 1 photos (no longer a valid duplicate group)\n        remaining = duplicate.photos.filter(removed=False).count()\n        if remaining <= 1:\n            # Clear remaining photos first to avoid orphan M2M entries\n            duplicate.photos.clear()\n            duplicate.delete()\n            deleted_count += 1\n    \n    if cleaned_count or deleted_count:\n        print(f\"Cleaned {cleaned_count} removed photos from duplicate groups\")\n        print(f\"Deleted {deleted_count} empty/single-photo duplicate groups\")\n\n\ndef reverse_cleanup(apps, schema_editor):\n    \"\"\"\n    Reverse migration is a no-op since we can't restore removed photos to groups.\n    The photos still exist (just marked removed=True) but we don't track which\n    groups they belonged to.\n    \"\"\"\n    pass\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0115_cleanup_duplicate_photos'),\n    ]\n\n    operations = [\n        migrations.RunPython(cleanup_duplicate_groups, reverse_cleanup),\n    ]\n"
  },
  {
    "path": "api/migrations/0117_delete_removed_photos.py",
    "content": "# Generated migration to delete removed photos\n# These are duplicate photos that were merged in migration 0115\n\nfrom django.db import migrations\n\n\ndef delete_removed_photos(apps, schema_editor):\n    \"\"\"\n    Delete all Photo records marked as removed=True.\n    \n    These are duplicate photos that were already merged in migration 0115.\n    Their relationships (faces, albums, files, stacks, duplicates) were\n    reassigned to the kept photo, so these are now orphan records.\n    \n    Deleting them is cleaner than soft-delete because:\n    1. No need to filter removed=True everywhere in queries\n    2. No orphan data cluttering the database\n    3. Clearer data model\n    \"\"\"\n    Photo = apps.get_model('api', 'Photo')\n    \n    # Find all removed photos\n    removed_photos = Photo.objects.filter(removed=True)\n    count = removed_photos.count()\n    \n    if count > 0:\n        # Delete them - relationships were already cleared/reassigned in 0115\n        removed_photos.delete()\n        print(f\"Deleted {count} removed (duplicate) photos\")\n\n\ndef reverse_delete(apps, schema_editor):\n    \"\"\"\n    Reverse migration is a no-op - deleted photos cannot be restored.\n    \"\"\"\n    pass\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0116_cleanup_duplicate_groups_removed_photos'),\n    ]\n\n    operations = [\n        migrations.RunPython(delete_removed_photos, reverse_delete),\n    ]\n"
  },
  {
    "path": "api/migrations/0118_alter_longrunningjob_job_type.py",
    "content": "# Generated by Django 5.2.9 on 2026-01-28 13:43\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0117_delete_removed_photos'),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name='longrunningjob',\n            name='job_type',\n            field=models.PositiveIntegerField(choices=[(1, 'Scan Photos'), (2, 'Generate Event Albums'), (3, 'Regenerate Event Titles'), (4, 'Train Faces'), (5, 'Delete Missing Photos'), (7, 'Scan Faces'), (6, 'Calculate Clip Embeddings'), (8, 'Find Similar Faces'), (9, 'Download Selected Photos'), (10, 'Download Models'), (11, 'Add Geolocation'), (12, 'Generate Tags'), (13, 'Generate Face Embeddings'), (14, 'Scan Missing Photos'), (15, 'Detect Duplicate Photos'), (16, 'Repair File Variants')]),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0119_add_public_sharing_options.py",
    "content": "# Generated migration for public sharing options\n\nfrom django.db import migrations, models\nimport api.models.user\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"api\", \"0118_alter_longrunningjob_job_type\"),\n    ]\n\n    operations = [\n        # Add public_sharing_defaults to User model\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"public_sharing_defaults\",\n            field=models.JSONField(default=api.models.user.get_default_public_sharing_settings),\n        ),\n        # Add sharing option fields to AlbumUserShare model\n        migrations.AddField(\n            model_name=\"albumusershare\",\n            name=\"share_location\",\n            field=models.BooleanField(blank=True, default=None, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"albumusershare\",\n            name=\"share_camera_info\",\n            field=models.BooleanField(blank=True, default=None, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"albumusershare\",\n            name=\"share_timestamps\",\n            field=models.BooleanField(blank=True, default=None, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"albumusershare\",\n            name=\"share_captions\",\n            field=models.BooleanField(blank=True, default=None, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"albumusershare\",\n            name=\"share_faces\",\n            field=models.BooleanField(blank=True, default=None, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0120_rename_thumbnails_uuid_to_hash.py",
    "content": "# Migration to rename thumbnail files from UUID to image_hash\n# This migration fixes the thumbnail naming issue where thumbnails were being\n# created with UUID names but the frontend expected image_hash names\n\nimport os\nfrom django.conf import settings\nfrom django.db import migrations\n\n\ndef rename_thumbnails_uuid_to_hash(apps, schema_editor):\n    \"\"\"\n    Rename existing thumbnail files from UUID-based names to image_hash-based names.\n    This only renames files that exist and updates the database records.\n    Uses batch processing for improved performance with large photo collections.\n    \"\"\"\n    Photo = apps.get_model('api', 'Photo')\n    Thumbnail = apps.get_model('api', 'Thumbnail')\n    \n    BATCH_SIZE = 1000  # Process thumbnails in batches of 1000\n    \n    # Get total count for progress reporting\n    total_count = Thumbnail.objects.count()\n    print(f\"Starting thumbnail migration for {total_count} photos...\")\n    \n    renamed_count = 0\n    skipped_count = 0\n    processed_count = 0\n    \n    # Process thumbnails in batches to avoid loading all into memory at once\n    thumbnails_to_update = []\n    \n    # Use iterator() to avoid loading all objects into memory\n    # Process in batches using only() to load only required fields\n    for thumbnail in Thumbnail.objects.select_related('photo').only(\n        'photo_id', 'photo__id', 'photo__image_hash', 'photo__video',\n        'thumbnail_big', 'square_thumbnail', 'square_thumbnail_small'\n    ).iterator(chunk_size=BATCH_SIZE):\n        photo = thumbnail.photo\n        photo_uuid = str(photo.id)\n        photo_hash = photo.image_hash\n        \n        # Skip if UUID and hash are the same (shouldn't happen, but be safe)\n        if photo_uuid == photo_hash:\n            skipped_count += 1\n            processed_count += 1\n            continue\n        \n        # Process each thumbnail type\n        thumbnail_types = [\n            ('thumbnails_big', '.webp', False),  # (path, extension, is_video)\n            ('square_thumbnails', '.webp' if not photo.video else '.mp4', photo.video),\n            ('square_thumbnails_small', '.webp' if not photo.video else '.mp4', photo.video),\n        ]\n        \n        needs_update = False\n        \n        for thumb_dir, ext, _ in thumbnail_types:\n            old_path = os.path.join(settings.MEDIA_ROOT, thumb_dir, f\"{photo_uuid}{ext}\")\n            new_path = os.path.join(settings.MEDIA_ROOT, thumb_dir, f\"{photo_hash}{ext}\")\n            \n            # Only rename if old file exists and new file doesn't\n            if os.path.exists(old_path) and not os.path.exists(new_path):\n                try:\n                    os.rename(old_path, new_path)\n                    needs_update = True\n                except Exception as e:\n                    print(f\"Warning: Could not rename {old_path} to {new_path}: {e}\")\n        \n        # Queue for batch update if any files were renamed\n        if needs_update:\n            filetype = '.mp4' if photo.video else '.webp'\n            thumbnail.thumbnail_big = os.path.join('thumbnails_big', f\"{photo_hash}.webp\")\n            thumbnail.square_thumbnail = os.path.join('square_thumbnails', f\"{photo_hash}{filetype}\")\n            thumbnail.square_thumbnail_small = os.path.join('square_thumbnails_small', f\"{photo_hash}{filetype}\")\n            thumbnails_to_update.append(thumbnail)\n            renamed_count += 1\n        else:\n            skipped_count += 1\n        \n        processed_count += 1\n        \n        # Batch update every BATCH_SIZE records\n        if len(thumbnails_to_update) >= BATCH_SIZE:\n            Thumbnail.objects.bulk_update(\n                thumbnails_to_update,\n                ['thumbnail_big', 'square_thumbnail', 'square_thumbnail_small'],\n                batch_size=BATCH_SIZE\n            )\n            print(f\"Progress: {processed_count}/{total_count} processed, {renamed_count} renamed, {skipped_count} skipped\")\n            thumbnails_to_update = []\n    \n    # Update any remaining thumbnails in the final batch\n    if thumbnails_to_update:\n        Thumbnail.objects.bulk_update(\n            thumbnails_to_update,\n            ['thumbnail_big', 'square_thumbnail', 'square_thumbnail_small'],\n            batch_size=BATCH_SIZE\n        )\n    \n    print(f\"Migration complete: {renamed_count} photos renamed, {skipped_count} photos skipped\")\n\n\ndef reverse_rename_thumbnails(apps, schema_editor):\n    \"\"\"\n    This migration cannot be easily reversed because we would need to know\n    the original UUID for each photo. The forward migration renames files\n    from UUID to image_hash, but reversing would require knowing which UUID\n    was used originally, which we don't store.\n    \"\"\"\n    print(\"Warning: This migration cannot be reversed. Thumbnails will keep image_hash names.\")\n    print(\"If you need to revert, regenerate thumbnails from scratch.\")\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('api', '0119_add_public_sharing_options'),\n    ]\n\n    operations = [\n        migrations.RunPython(\n            rename_thumbnails_uuid_to_hash,\n            reverse_rename_thumbnails,\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/0121_add_default_tagging_model.py",
    "content": "\"\"\"\nMigration to add a default TAGGING_MODEL entry to the constance database.\n\nWhen TAGGING_MODEL was added to CONSTANCE_CONFIG, existing systems that upgraded\nwould not have this key in the constance database backend. While constance normally\nfalls back to the default from CONSTANCE_CONFIG, this migration explicitly sets the\ndefault to ensure compatibility with old systems.\n\"\"\"\n\nfrom django.db import migrations\n\n\ndef add_default_tagging_model(apps, schema_editor):\n    \"\"\"Add TAGGING_MODEL default value to constance DB if it doesn't exist.\"\"\"\n    try:\n        Constance = apps.get_model(\"constance\", \"Constance\")\n        if not Constance.objects.filter(key=\"TAGGING_MODEL\").exists():\n            Constance.objects.create(key=\"TAGGING_MODEL\", value='\"places365\"')\n    except LookupError:\n        # constance model not available, skip\n        pass\n\n\ndef reverse_migration(apps, schema_editor):\n    \"\"\"Remove TAGGING_MODEL from constance DB if it has the default value.\"\"\"\n    try:\n        Constance = apps.get_model(\"constance\", \"Constance\")\n        Constance.objects.filter(key=\"TAGGING_MODEL\", value='\"places365\"').delete()\n    except LookupError:\n        pass\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"api\", \"0120_rename_thumbnails_uuid_to_hash\"),\n    ]\n\n    operations = [\n        migrations.RunPython(add_default_tagging_model, reverse_migration),\n    ]\n"
  },
  {
    "path": "api/migrations/0121_user_save_face_tags_to_disk.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"api\", \"0120_rename_thumbnails_uuid_to_hash\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"user\",\n            name=\"save_face_tags_to_disk\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "api/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "api/ml_models.py",
    "content": "import math\nimport os\nimport tarfile\nfrom pathlib import Path\n\nimport requests\nfrom constance import config as site_config\nfrom django.conf import settings\n\nfrom api import util\nfrom api.models.long_running_job import LongRunningJob\n\n\nclass MlTypes:\n    CAPTIONING = \"captioning\"\n    FACE_RECOGNITION = \"face_recognition\"\n    CATEGORIES = \"categories\"\n    CLIP = \"clip\"\n    LLM = \"llm\"\n    MOONDREAM = \"moondream\"\n    TAGGING = \"tagging\"\n\n\nML_MODELS = [\n    {\n        \"id\": 1,\n        \"name\": \"im2txt\",\n        \"url\": \"https://github.com/LibrePhotos/librephotos-docker/releases/download/0.1/im2txt.tar.gz\",\n        \"type\": MlTypes.CAPTIONING,\n        \"unpack-command\": \"tar -zxC\",\n        \"target-dir\": \"im2txt\",\n    },\n    {\n        \"id\": 2,\n        \"name\": \"clip-embeddings\",\n        \"url\": \"https://github.com/LibrePhotos/librephotos-docker/releases/download/0.1/clip-embeddings.tar.gz\",\n        \"type\": MlTypes.CLIP,\n        \"unpack-command\": \"tar -zxC\",\n        \"target-dir\": \"clip-embeddings\",\n    },\n    {\n        \"id\": 3,\n        \"name\": \"places365\",\n        \"url\": \"https://github.com/LibrePhotos/librephotos-docker/releases/download/0.1/places365.tar.gz\",\n        \"type\": MlTypes.CATEGORIES,\n        \"unpack-command\": \"tar -zxC\",\n        \"target-dir\": \"places365\",\n    },\n    {\n        \"id\": 4,\n        \"name\": \"resnet18\",\n        \"url\": \"https://download.pytorch.org/models/resnet18-5c106cde.pth\",\n        \"type\": MlTypes.CATEGORIES,\n        \"unpack-command\": None,\n        \"target-dir\": \"resnet18-5c106cde.pth\",\n    },\n    {\n        \"id\": 6,\n        \"name\": \"blip_base_capfilt_large\",\n        \"url\": \"https://huggingface.co/derneuere/librephotos_models/resolve/main/blip_large.tar.gz?download=true\",\n        \"type\": MlTypes.CAPTIONING,\n        \"unpack-command\": \"tar -zxC\",\n        \"target-dir\": \"blip\",\n    },\n    {\n        \"id\": 8,\n        \"name\": \"mistral-7b-instruct-v0.2.Q5_K_M\",\n        \"url\": \"https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.2-GGUF/resolve/main/mistral-7b-instruct-v0.2.Q5_K_M.gguf?download=true\",\n        \"type\": MlTypes.LLM,\n        \"unpack-command\": None,\n        \"target-dir\": \"mistral-7b-instruct-v0.2.Q5_K_M.gguf\",\n    },\n    {\n        \"id\": 11,\n        \"name\": \"siglip2\",\n        \"url\": \"https://huggingface.co/onnx-community/siglip2-base-patch16-384-ONNX/resolve/main/onnx/vision_model.onnx\",\n        \"type\": MlTypes.TAGGING,\n        \"unpack-command\": None,\n        \"target-dir\": \"siglip2/vision_model.onnx\",\n        \"additional_files\": [\n            {\n                \"url\": \"https://huggingface.co/onnx-community/siglip2-base-patch16-384-ONNX/resolve/main/onnx/text_model.onnx\",\n                \"target\": \"siglip2/text_model.onnx\",\n            },\n            {\n                \"url\": \"https://huggingface.co/onnx-community/siglip2-base-patch16-384-ONNX/resolve/main/tokenizer.model\",\n                \"target\": \"siglip2/tokenizer.model\",\n            },\n        ],\n    },\n    {\n        # Moondream 2 GGUF model for llama-cpp-python multimodal support\n        \"id\": 9,\n        \"name\": \"moondream\",\n        \"url\": \"https://huggingface.co/moondream/moondream-2b-2025-04-14-4bit/resolve/main/moondream2-text-model-f16.gguf?download=true\",\n        \"type\": MlTypes.MOONDREAM,\n        \"unpack-command\": None,\n        \"target-dir\": \"moondream2-text-model-f16.gguf\",\n        \"additional_files\": [\n            {\n                \"url\": \"https://huggingface.co/moondream/moondream-2b-2025-04-14-4bit/resolve/main/moondream2-mmproj-f16.gguf?download=true\",\n                \"target\": \"moondream2-mmproj-f16.gguf\",\n            }\n        ],\n    },\n]\n\n\ndef download_model(model):\n    model = model.copy()\n    if model[\"type\"] == MlTypes.LLM:\n        util.logger.info(\"Downloading LLM model\")\n        model_to_download = site_config.LLM_MODEL\n        if not model_to_download or str(model_to_download).strip().lower() == \"none\":\n            util.logger.info(\"No LLM model selected\")\n            return\n        util.logger.info(f\"Model to download: {model_to_download}\")\n        # Look through ML_MODELS and find the model with the name\n        for ml_model in ML_MODELS:\n            if ml_model[\"name\"] == model_to_download:\n                model = ml_model\n    elif model[\"type\"] == MlTypes.MOONDREAM:\n        util.logger.info(\"Downloading Moondream model\")\n        model_to_download = site_config.LLM_MODEL\n        if model_to_download != \"moondream\":\n            util.logger.info(\"Moondream not selected\")\n            return\n        util.logger.info(f\"Model to download: {model_to_download}\")\n        # Look through ML_MODELS and find the model with the name\n        for ml_model in ML_MODELS:\n            if ml_model[\"name\"] == model_to_download:\n                model = ml_model\n    elif model[\"type\"] == MlTypes.CAPTIONING:\n        util.logger.info(\"Downloading captioning model\")\n        model_to_download = site_config.CAPTIONING_MODEL\n        util.logger.info(f\"Model to download: {model_to_download}\")\n        # Look through ML_MODELS and find the model with the name\n        for ml_model in ML_MODELS:\n            if ml_model[\"name\"] == model_to_download:\n                model = ml_model\n    elif model[\"type\"] == MlTypes.TAGGING:\n        util.logger.info(\"Downloading tagging model\")\n        model_to_download = site_config.TAGGING_MODEL\n        if model_to_download != model[\"name\"]:\n            util.logger.info(\n                f\"Tagging model {model['name']} not selected (current: {model_to_download})\"\n            )\n            return\n        util.logger.info(f\"Model to download: {model_to_download}\")\n\n    util.logger.info(f\"Downloading model {model['name']}\")\n    model_folder = Path(settings.MEDIA_ROOT) / \"data_models\"\n\n    # Handle regular models\n    target_dir = model_folder / model[\"target-dir\"]\n\n    if target_dir.exists():\n        util.logger.info(f\"Model {model['name']} already downloaded\")\n        # Check if all additional files exist for models like Moondream\n        if model.get(\"additional_files\"):\n            for additional_file in model[\"additional_files\"]:\n                additional_target = model_folder / additional_file[\"target\"]\n                if not additional_target.exists():\n                    util.logger.info(\n                        f\"Additional file {additional_file['target']} missing, downloading...\"\n                    )\n                    _download_file(\n                        additional_file[\"url\"],\n                        additional_target,\n                        f\"{model['name']} ({additional_file['target']})\",\n                    )\n        return\n\n    if model[\"unpack-command\"] == \"tar -zxC\":\n        target_dir = model_folder / (model[\"target-dir\"] + \".tar.gz\")\n    if model[\"unpack-command\"] == \"tar -xvf\":\n        target_dir = model_folder / (model[\"target-dir\"] + \".tar\")\n    if model[\"unpack-command\"] is None:\n        target_dir = model_folder / model[\"target-dir\"]\n\n    _download_file(model[\"url\"], target_dir, model[\"name\"])\n\n    if model[\"unpack-command\"] == \"tar -zxC\":\n        with tarfile.open(target_dir, mode=\"r:gz\") as tar:\n            tar.extractall(path=model_folder)\n        os.remove(target_dir)\n    if model[\"unpack-command\"] == \"tar -xvf\":\n        with tarfile.open(target_dir, mode=\"r:\") as tar:\n            tar.extractall(path=model_folder)\n        os.remove(target_dir)\n\n    # Download additional files if they exist (e.g., mmproj for Moondream)\n    if model.get(\"additional_files\"):\n        for additional_file in model[\"additional_files\"]:\n            additional_target = model_folder / additional_file[\"target\"]\n            if not additional_target.exists():\n                _download_file(\n                    additional_file[\"url\"],\n                    additional_target,\n                    f\"{model['name']} ({additional_file['target']})\",\n                )\n\n\ndef _download_file(url, target_path, model_name):\n    \"\"\"Helper function to download a single file with progress tracking\"\"\"\n    target_path = Path(target_path)\n    target_path.parent.mkdir(parents=True, exist_ok=True)\n    response = requests.get(url, stream=True, allow_redirects=True)\n    total_size = int(response.headers.get(\"content-length\", 0))\n    block_size = 1024\n    current_progress = 0\n    previous_percentage = -1\n\n    with open(target_path, \"wb\") as target_file:\n        for chunk in response.iter_content(chunk_size=block_size):\n            if chunk:\n                target_file.write(chunk)\n                current_progress += len(chunk)\n\n                if total_size > 0:\n                    percentage = math.floor((current_progress / total_size) * 100)\n\n                    if percentage != previous_percentage:\n                        util.logger.info(\n                            f\"Downloading {model_name}: {current_progress}/{total_size} ({percentage}%)\"\n                        )\n                        previous_percentage = percentage\n\n    if total_size == 0:\n        util.logger.info(\n            f\"Downloaded {model_name}: {current_progress} bytes (size unknown during transfer)\"\n        )\n\n\ndef download_models(user):\n    lrj = LongRunningJob.create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_DOWNLOAD_MODELS,\n        start_now=True,\n    )\n    lrj.update_progress(current=0, target=len(ML_MODELS))\n\n    model_folder = Path(settings.MEDIA_ROOT) / \"data_models\"\n    model_folder.mkdir(parents=True, exist_ok=True)\n\n    for idx, model in enumerate(ML_MODELS):\n        download_model(model)\n        lrj.update_progress(current=idx + 1)\n\n    lrj.complete()\n\n\ndef do_all_models_exist():\n    model_folder = Path(settings.MEDIA_ROOT) / \"data_models\"\n    for model in ML_MODELS:\n        if model[\"type\"] in (MlTypes.LLM, MlTypes.MOONDREAM, MlTypes.TAGGING):\n            if not model and model != \"none\":\n                continue\n\n        # Check main model file\n        target_dir = model_folder / model[\"target-dir\"]\n        if not target_dir.exists():\n            return False\n\n        # Check additional files if they exist (like mmproj for Moondream)\n        if model.get(\"additional_files\"):\n            for additional_file in model[\"additional_files\"]:\n                additional_target = model_folder / additional_file[\"target\"]\n                if not additional_target.exists():\n                    return False\n    return True\n"
  },
  {
    "path": "api/models/__init__.py",
    "content": "from api.models.album_auto import AlbumAuto\nfrom api.models.album_date import AlbumDate\nfrom api.models.album_place import AlbumPlace\nfrom api.models.album_thing import AlbumThing\nfrom api.models.album_user import AlbumUser\nfrom api.models.cluster import Cluster\nfrom api.models.duplicate import Duplicate\nfrom api.models.face import Face\nfrom api.models.file import File\nfrom api.models.long_running_job import LongRunningJob\nfrom api.models.person import Person\nfrom api.models.photo import Photo\nfrom api.models.photo_caption import PhotoCaption\nfrom api.models.photo_metadata import MetadataEdit, MetadataFile, PhotoMetadata\nfrom api.models.photo_search import PhotoSearch\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.stack_review import StackReview\nfrom api.models.thumbnail import Thumbnail\nfrom api.models.user import User\n\n__all__ = [\n    \"AlbumAuto\",\n    \"AlbumDate\",\n    \"AlbumPlace\",\n    \"AlbumThing\",\n    \"AlbumUser\",\n    \"Cluster\",\n    \"Duplicate\",\n    \"Face\",\n    \"LongRunningJob\",\n    \"MetadataEdit\",\n    \"MetadataFile\",\n    \"Person\",\n    \"Photo\",\n    \"PhotoCaption\",\n    \"PhotoMetadata\",\n    \"PhotoSearch\",\n    \"PhotoStack\",\n    \"StackReview\",\n    \"Thumbnail\",\n    \"User\",\n    \"File\",\n]\n"
  },
  {
    "path": "api/models/album_auto.py",
    "content": "from collections import Counter\n\nfrom django.db import models\n\nfrom api import util\nfrom api.models.person import Person\nfrom api.models.photo import Photo\nfrom api.models.user import User, get_deleted_user\n\n\nclass AlbumAuto(models.Model):\n    title = models.CharField(\n        blank=False, null=False, max_length=512, default=\"Untitled Album\"\n    )\n    timestamp = models.DateTimeField(db_index=True)\n    created_on = models.DateTimeField(auto_now=False, db_index=True)\n    gps_lat = models.FloatField(blank=True, null=True)\n    gps_lon = models.FloatField(blank=True, null=True)\n    photos = models.ManyToManyField(Photo)\n    favorited = models.BooleanField(default=False, db_index=True)\n    owner = models.ForeignKey(\n        User, on_delete=models.SET(get_deleted_user), default=None\n    )\n\n    shared_to = models.ManyToManyField(User, related_name=\"album_auto_shared_to\")\n\n    class Meta:\n        unique_together = (\"timestamp\", \"owner\")\n\n    def _generate_title(self):\n        try:\n            weekday = \"\"\n            time = \"\"\n            loc = \"\"\n            if self.timestamp:\n                weekday = util.weekdays[self.timestamp.isoweekday()]\n                hour = self.timestamp.hour\n                if hour > 0 and hour < 5:\n                    time = \"Early Morning\"\n                elif hour >= 5 and hour < 12:\n                    time = \"Morning\"\n                elif hour >= 12 and hour < 18:\n                    time = \"Afternoon\"\n                elif hour >= 18 and hour <= 24:\n                    time = \"Evening\"\n\n            when = \" \".join([weekday, time])\n\n            photos = self.photos.all()\n\n            loc = \"\"\n            pep = \"\"\n\n            places = []\n            people = []\n            timestamps = []\n\n            for photo in photos:\n                if (\n                    photo.geolocation_json\n                    and \"places\" in photo.geolocation_json.keys()\n                    and len(photo.geolocation_json[\"places\"]) > 0\n                ):\n                    places = photo.geolocation_json[\"places\"]\n\n                timestamps.append(photo.exif_timestamp)\n\n                faces = photo.faces.all()\n                for face in faces:\n                    people.append(face.person.name)\n\n            if len(places) > 0:\n                cnts_places = Counter(places)\n                loc = \"in \" + \" and \".join(dict(cnts_places.most_common(2)).keys())\n            if len(people) > 0:\n                cnts_people = Counter(people)\n                names = dict(\n                    [\n                        (k, v)\n                        for k, v in cnts_people.most_common(2)\n                        if k.lower() != \"unknown\"\n                        and k.lower() != Person.UNKNOWN_PERSON_NAME\n                    ]\n                ).keys()\n                if len(names) > 0:\n                    pep = \"with \" + \" and \".join(names)\n            if len(timestamps) > 0:\n                if (max(timestamps) - min(timestamps)).days >= 3:\n                    when = \"%d days\" % ((max(timestamps) - min(timestamps)).days)\n\n                weekend = [5, 6]\n                if (\n                    max(timestamps).weekday() in weekend\n                    and min(timestamps).weekday() in weekend\n                    and not (max(timestamps).weekday() == min(timestamps).weekday())\n                ):\n                    when = \"Weekend\"\n\n            title = \" \".join([when, pep, loc]).strip()\n            # Ensure title is never empty\n            if not title:\n                title = f\"Album from {self.timestamp.strftime('%Y-%m-%d')}\"\n            self.title = title\n            self.save()\n        except Exception as e:\n            util.logger.exception(e)\n            # Set a fallback title if something goes wrong\n            self.title = f\"Album from {self.timestamp.strftime('%Y-%m-%d')}\"\n            self.save()\n\n    def __str__(self):\n        return \"%d: %s\" % (self.id, self.title)\n"
  },
  {
    "path": "api/models/album_date.py",
    "content": "from django.db import models\n\nfrom api.models.photo import Photo\nfrom api.models.user import User, get_deleted_user\n\n\nclass AlbumDate(models.Model):\n    title = models.CharField(blank=True, default=\"\", max_length=512, db_index=True)\n    date = models.DateField(db_index=True, null=True)\n    photos = models.ManyToManyField(Photo)\n    favorited = models.BooleanField(default=False, db_index=True)\n    location = models.JSONField(blank=True, db_index=True, null=True)\n    owner = models.ForeignKey(\n        User, on_delete=models.SET(get_deleted_user), default=None\n    )\n    shared_to = models.ManyToManyField(User, related_name=\"album_date_shared_to\")\n    objects = models.Manager()\n\n    class Meta:\n        unique_together = (\"date\", \"owner\")\n\n    def __str__(self):\n        return str(self.date) + \" (\" + str(self.owner) + \")\"\n\n    def ordered_photos(self):\n        return self.photos.all().order_by(\"-exif_timestamp\")\n\n\ndef get_or_create_album_date(date, owner):\n    try:\n        return AlbumDate.objects.get_or_create(date=date, owner=owner)[0]\n    except AlbumDate.MultipleObjectsReturned:\n        return AlbumDate.objects.filter(date=date, owner=owner).first()\n\n\ndef get_album_date(date, owner):\n    try:\n        return AlbumDate.objects.get(date=date, owner=owner)\n    except Exception:\n        return None\n\n\ndef get_album_nodate(owner):\n    return AlbumDate.objects.get_or_create(date=None, owner=owner)[0]\n"
  },
  {
    "path": "api/models/album_place.py",
    "content": "from django.db import models\n\nfrom api.models.photo import Photo\nfrom api.models.user import User, get_deleted_user\n\n\nclass AlbumPlace(models.Model):\n    title = models.CharField(max_length=512, db_index=True)\n    photos = models.ManyToManyField(Photo)\n    geolocation_level = models.IntegerField(db_index=True, null=True)\n    favorited = models.BooleanField(default=False, db_index=True)\n    owner = models.ForeignKey(\n        User, on_delete=models.SET(get_deleted_user), default=None\n    )\n\n    shared_to = models.ManyToManyField(User, related_name=\"album_place_shared_to\")\n\n    class Meta:\n        unique_together = (\"title\", \"owner\")\n\n    def __str__(self):\n        return \"%d: %s\" % (self.id, self.title)\n\n\ndef get_album_place(title, owner):\n    return AlbumPlace.objects.get_or_create(title=title, owner=owner)[0]\n"
  },
  {
    "path": "api/models/album_thing.py",
    "content": "from django.db import models\nfrom django.db.models.signals import m2m_changed\nfrom django.dispatch import receiver\n\nfrom api.models.photo import Photo\nfrom api.models.user import User, get_deleted_user\n\n\ndef update_default_cover_photo(instance):\n    if instance.cover_photos.count() < 4:\n        photos_to_add = instance.photos.filter(hidden=False)[\n            : 4 - instance.cover_photos.count()\n        ]\n        instance.cover_photos.add(*photos_to_add)\n\n\nclass AlbumThing(models.Model):\n    title = models.CharField(max_length=512, db_index=True)\n    photos = models.ManyToManyField(Photo)\n    thing_type = models.CharField(max_length=512, db_index=True, null=True)\n    favorited = models.BooleanField(default=False, db_index=True)\n    owner = models.ForeignKey(\n        User, on_delete=models.SET(get_deleted_user), default=None\n    )\n    shared_to = models.ManyToManyField(User, related_name=\"album_thing_shared_to\")\n    cover_photos = models.ManyToManyField(\n        Photo, related_name=\"album_thing_cover_photos\"\n    )\n    photo_count = models.IntegerField(default=0)\n\n    class Meta:\n        constraints = [\n            models.UniqueConstraint(\n                fields=[\"title\", \"thing_type\", \"owner\"], name=\"unique AlbumThing\"\n            )\n        ]\n\n    def save(self, *args, **kwargs):\n        super().save(*args, **kwargs)\n\n    def update_default_cover_photo(self):\n        update_default_cover_photo(self)\n\n    def __str__(self):\n        return \"%d: %s\" % (self.id or 0, self.title)\n\n\n@receiver(m2m_changed, sender=AlbumThing.photos.through)\ndef update_photo_count(sender, instance, action, reverse, model, pk_set, **kwargs):\n    if action == \"post_add\" or (action == \"post_remove\" and not reverse):\n        count = instance.photos.filter(hidden=False).count()\n        instance.photo_count = count\n        instance.save(update_fields=[\"photo_count\"])\n        instance.update_default_cover_photo()\n\n\ndef get_album_thing(title, owner, thing_type=None):\n    return AlbumThing.objects.get_or_create(\n        title=title, owner=owner, thing_type=thing_type\n    )[0]\n"
  },
  {
    "path": "api/models/album_user.py",
    "content": "from django.db import models\n\nfrom api.models.photo import Photo\nfrom api.models.user import User, get_deleted_user\n\n\nclass AlbumUser(models.Model):\n    title = models.CharField(max_length=512)\n    created_on = models.DateTimeField(auto_now=True, db_index=True)\n    photos = models.ManyToManyField(Photo)\n    favorited = models.BooleanField(default=False, db_index=True)\n    owner = models.ForeignKey(\n        User, on_delete=models.SET(get_deleted_user), default=None\n    )\n    cover_photo = models.ForeignKey(\n        Photo,\n        related_name=\"album_user\",\n        on_delete=models.SET_NULL,\n        blank=False,\n        null=True,\n    )\n\n    shared_to = models.ManyToManyField(User, related_name=\"album_user_shared_to\")\n\n    def __str__(self):\n        return f\"{self.title} ({self.owner.username})\"\n\n    class Meta:\n        unique_together = (\"title\", \"owner\")\n"
  },
  {
    "path": "api/models/album_user_share.py",
    "content": "import uuid\nfrom django.db import models\nfrom django.utils import timezone\n\nfrom api.models.album_user import AlbumUser\n\n\nclass AlbumUserShare(models.Model):\n    album = models.OneToOneField(\n        AlbumUser, on_delete=models.CASCADE, related_name=\"share\"\n    )\n    enabled = models.BooleanField(default=False, db_index=True)\n    slug = models.SlugField(\n        max_length=64, unique=True, null=True, blank=True, db_index=True\n    )\n    expires_at = models.DateTimeField(null=True, blank=True, db_index=True)\n\n    # Sharing options - None means inherit from user defaults, True/False overrides\n    share_location = models.BooleanField(null=True, blank=True, default=None)\n    share_camera_info = models.BooleanField(null=True, blank=True, default=None)\n    share_timestamps = models.BooleanField(null=True, blank=True, default=None)\n    share_captions = models.BooleanField(null=True, blank=True, default=None)\n    share_faces = models.BooleanField(null=True, blank=True, default=None)\n\n    def ensure_slug(self) -> None:\n        if self.enabled and not self.slug:\n            base = uuid.uuid4().hex[:12]\n            candidate = base\n            idx = 0\n            while (\n                AlbumUserShare.objects.filter(slug=candidate)\n                .exclude(id=self.id)\n                .exists()\n            ):\n                idx += 1\n                candidate = f\"{base}-{idx}\"\n            self.slug = candidate\n\n    def is_active(self) -> bool:\n        if not self.enabled:\n            return False\n        if self.expires_at is None:\n            return True\n        return self.expires_at >= timezone.now()\n\n    def save(self, *args, **kwargs):\n        if self.enabled and not self.slug:\n            self.ensure_slug()\n        super().save(*args, **kwargs)\n\n    def get_effective_sharing_settings(self) -> dict:\n        \"\"\"Resolve effective sharing settings.\n        \n        Priority: album override > user defaults > system defaults (all False)\n        \"\"\"\n        from api.models.user import get_default_public_sharing_settings\n        \n        # Start with system defaults (all False)\n        defaults = get_default_public_sharing_settings()\n        \n        # Apply user defaults if available\n        user_defaults = getattr(self.album.owner, 'public_sharing_defaults', None)\n        if user_defaults:\n            defaults.update(user_defaults)\n        \n        # Apply album-level overrides (only non-None values)\n        overrides = {\n            'share_location': self.share_location,\n            'share_camera_info': self.share_camera_info,\n            'share_timestamps': self.share_timestamps,\n            'share_captions': self.share_captions,\n            'share_faces': self.share_faces,\n        }\n        \n        for key, value in overrides.items():\n            if value is not None:\n                defaults[key] = value\n        \n        return defaults\n"
  },
  {
    "path": "api/models/cluster.py",
    "content": "import numpy as np\nfrom django.core.exceptions import MultipleObjectsReturned\nfrom django.db import models\n\nfrom api.models.person import Person\nfrom api.models.user import User, get_deleted_user\nfrom api.util import logger\n\nUNKNOWN_CLUSTER_ID = -1\nUNKNOWN_CLUSTER_NAME = \"Other Unknown Cluster\"\n\n\nclass Cluster(models.Model):\n    person = models.ForeignKey(\n        Person,\n        on_delete=models.SET_NULL,\n        related_name=\"clusters\",\n        blank=True,\n        null=True,\n    )\n    mean_face_encoding = models.TextField()\n    cluster_id = models.IntegerField(null=True)\n    name = models.TextField(null=True)\n\n    owner = models.ForeignKey(\n        User, on_delete=models.SET(get_deleted_user), default=None, null=True\n    )\n\n    def __str__(self):\n        return \"%d\" % self.id\n\n    def get_mean_encoding_array(self) -> np.ndarray:\n        return np.frombuffer(bytes.fromhex(self.mean_face_encoding))\n\n    def set_metadata(self, all_vectors):\n        self.mean_face_encoding = (\n            Cluster.calculate_mean_face_encoding(all_vectors).tobytes().hex()\n        )\n\n    @staticmethod\n    def get_or_create_cluster_by_name(user: User, name):\n        return Cluster.objects.get_or_create(owner=user, name=name)[0]\n\n    @staticmethod\n    def get_or_create_cluster_by_id(user: User, cluster_id: int):\n        try:\n            return Cluster.objects.get_or_create(owner=user, cluster_id=cluster_id)[0]\n        except MultipleObjectsReturned:\n            logger.error(\n                \"Multiple clusters found with id %d. Choosing first one\" % cluster_id\n            )\n            return Cluster.objects.filter(owner=user, cluster_id=cluster_id).first()\n\n    @staticmethod\n    def calculate_mean_face_encoding(all_encodings):\n        return np.mean(a=all_encodings, axis=0, dtype=np.float64)\n\n\ndef get_unknown_cluster(user: User) -> Cluster:\n    unknown_cluster: Cluster = Cluster.get_or_create_cluster_by_id(\n        user, UNKNOWN_CLUSTER_ID\n    )\n    if unknown_cluster.person is not None:\n        unknown_cluster.person = None\n        unknown_cluster.name = UNKNOWN_CLUSTER_NAME\n        unknown_cluster.save()\n    return unknown_cluster\n"
  },
  {
    "path": "api/models/duplicate.py",
    "content": "\"\"\"\nDuplicate model for tracking duplicate photo groups.\n\nDuplicates are photos that are either:\n- EXACT_COPY: Byte-for-byte identical files (same MD5 hash, different paths)\n- VISUAL_DUPLICATE: Visually similar photos (similar perceptual hash or CLIP embeddings)\n\nDuplicates are fundamentally different from Stacks:\n- Duplicates represent redundant storage that the user may want to clean up\n- Stacks represent related photos that should be kept together for organization\n\nThis separation allows:\n- Focused workflows: Duplicates → review/delete, Stacks → browse/organize\n- Different UX: Duplicates page focused on storage savings vs Stacks for browsing\n- Clearer data model with appropriate fields for each concept\n\"\"\"\n\nimport uuid\n\nfrom django.db import models\nfrom django.utils import timezone\n\nfrom api.models.user import User, get_deleted_user\n\n\nclass Duplicate(models.Model):\n    \"\"\"\n    Represents a group of duplicate photos that should be reviewed.\n    \n    Photos in a duplicate group are candidates for deletion - the user\n    reviews them and decides which to keep.\n    \"\"\"\n\n    class DuplicateType(models.TextChoices):\n        # Exact byte-for-byte copies (same MD5 hash, different file paths)\n        EXACT_COPY = \"exact_copy\", \"Exact Copies\"\n        # Visually similar images (similar pHash or CLIP embeddings)\n        VISUAL_DUPLICATE = \"visual_duplicate\", \"Visual Duplicates\"\n\n    class ReviewStatus(models.TextChoices):\n        # User hasn't reviewed yet\n        PENDING = \"pending\", \"Pending Review\"\n        # User selected a primary and trashed others\n        RESOLVED = \"resolved\", \"Resolved\"\n        # User marked as \"not actually duplicates\"\n        DISMISSED = \"dismissed\", \"Dismissed\"\n\n    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)\n\n    owner = models.ForeignKey(\n        User,\n        on_delete=models.SET(get_deleted_user),\n        related_name=\"duplicates\",\n    )\n\n    duplicate_type = models.CharField(\n        max_length=20,\n        choices=DuplicateType.choices,\n        default=DuplicateType.VISUAL_DUPLICATE,\n        db_index=True,\n    )\n\n    review_status = models.CharField(\n        max_length=20,\n        choices=ReviewStatus.choices,\n        default=ReviewStatus.PENDING,\n        db_index=True,\n    )\n\n    # The photo the user chose to keep (set when resolved)\n    kept_photo = models.ForeignKey(\n        \"Photo\",\n        on_delete=models.SET_NULL,\n        null=True,\n        blank=True,\n        related_name=\"kept_in_duplicates\",\n    )\n\n    # Detection metadata\n    created_at = models.DateTimeField(auto_now_add=True)\n    updated_at = models.DateTimeField(auto_now=True)\n    reviewed_at = models.DateTimeField(null=True, blank=True)\n\n    # For visual duplicates: similarity score (0-1, higher = more similar)\n    similarity_score = models.FloatField(null=True, blank=True)\n\n    # Potential storage savings if non-kept photos are removed (bytes)\n    potential_savings = models.BigIntegerField(default=0)\n\n    # Number of photos trashed when resolved\n    trashed_count = models.IntegerField(default=0)\n\n    # Optional note from user\n    note = models.TextField(blank=True, null=True)\n\n    class Meta:\n        ordering = [\"-created_at\"]\n        verbose_name = \"Duplicate\"\n        verbose_name_plural = \"Duplicates\"\n        indexes = [\n            models.Index(fields=[\"owner\", \"duplicate_type\"]),\n            models.Index(fields=[\"owner\", \"review_status\"]),\n        ]\n\n    def __str__(self):\n        return f\"Duplicate {self.id} - {self.duplicate_type} - {self.owner.username}\"\n\n    @property\n    def photo_count(self):\n        \"\"\"Number of photos in this duplicate group.\"\"\"\n        return self.photos.count()\n\n    def get_photos_ordered_by_quality(self):\n        \"\"\"\n        Returns photos ordered by quality metrics.\n        Higher resolution and larger file size are considered better quality.\n        \"\"\"\n        return self.photos.select_related('metadata').order_by(\n            \"-metadata__width\", \"-metadata__height\", \"-size\"\n        )\n\n    def auto_select_best_photo(self):\n        \"\"\"\n        Automatically selects the best quality photo as the kept photo.\n        Used as a suggestion for the user.\n        \n        For EXACT_COPY: Picks the one with shortest path (likely \"original\")\n        For VISUAL_DUPLICATE: Highest resolution\n        \n        Returns:\n            The best Photo instance or None\n        \"\"\"\n        from django.db.models.functions import Length\n        \n        photos = self.photos.all()\n        if not photos.exists():\n            return None\n\n        if self.duplicate_type == self.DuplicateType.EXACT_COPY:\n            # For exact copies, pick the one with shortest path (likely \"original\")\n            best = photos.order_by(Length(\"main_file__path\")).first()\n        else:\n            # For visual duplicates: highest resolution\n            from django.db.models import F\n            best = photos.order_by(\n                F(\"metadata__width\") * F(\"metadata__height\")\n            ).last()\n\n        return best\n\n    def calculate_potential_savings(self):\n        \"\"\"\n        Calculate how much storage could be saved if non-best photos\n        are removed from disk.\n        \"\"\"\n        best = self.auto_select_best_photo()\n        if not best:\n            self.potential_savings = 0\n        else:\n            # Sum size of all photos except best\n            from django.db.models import Sum\n            non_best_size = (\n                self.photos.exclude(pk=best.pk)\n                .aggregate(total=Sum(\"size\"))\n                .get(\"total\", 0)\n            ) or 0\n            self.potential_savings = non_best_size\n\n        self.save(update_fields=[\"potential_savings\", \"updated_at\"])\n        return self.potential_savings\n\n    def resolve(self, kept_photo, trash_others: bool = True):\n        \"\"\"\n        Mark the duplicate as resolved by selecting a photo to keep.\n        \n        Args:\n            kept_photo: The Photo instance to keep\n            trash_others: Whether to move other photos to trash\n        \"\"\"\n        # Set the kept photo\n        self.kept_photo = kept_photo\n        self.review_status = self.ReviewStatus.RESOLVED\n        self.reviewed_at = timezone.now()\n\n        # Trash others if requested\n        if trash_others:\n            other_photos = self.photos.exclude(pk=kept_photo.pk)\n            self.trashed_count = other_photos.update(in_trashcan=True)\n        \n        self.save()\n        return self\n\n    def dismiss(self):\n        \"\"\"Mark as 'not actually duplicates' and unlink photos from group.\"\"\"\n        self.review_status = self.ReviewStatus.DISMISSED\n        self.reviewed_at = timezone.now()\n        \n        # Unlink photos from duplicate group (ManyToMany)\n        for photo in self.photos.all():\n            photo.duplicates.remove(self)\n        \n        self.save()\n        return self\n\n    def revert(self):\n        \"\"\"Revert a resolved duplicate, restoring trashed photos.\"\"\"\n        if self.review_status != self.ReviewStatus.RESOLVED:\n            return 0\n\n        # Restore trashed photos in this duplicate group\n        restored_count = self.photos.filter(\n            in_trashcan=True\n        ).update(in_trashcan=False)\n\n        # Reset to pending\n        self.review_status = self.ReviewStatus.PENDING\n        self.kept_photo = None\n        self.trashed_count = 0\n        self.reviewed_at = None\n\n        self.save()\n        return restored_count\n\n    def merge_with(self, other_duplicate: \"Duplicate\"):\n        \"\"\"\n        Merge another duplicate group into this one.\n        All photos from the other group are moved here,\n        and the other group is deleted.\n        \"\"\"\n        if other_duplicate.pk == self.pk:\n            return\n\n        # Move all photos from other duplicate to this one (ManyToMany)\n        for photo in other_duplicate.photos.all():\n            photo.duplicates.add(self)\n            photo.duplicates.remove(other_duplicate)\n\n        self.calculate_potential_savings()\n\n        # Delete the now-empty duplicate group\n        other_duplicate.delete()\n\n    @classmethod\n    def create_or_merge(cls, owner, duplicate_type, photos, similarity_score=None):\n        \"\"\"\n        Create a new duplicate group or merge into existing if any photo is already grouped.\n\n        Args:\n            owner: User who owns the photos\n            duplicate_type: Type of duplicate (EXACT_COPY or VISUAL_DUPLICATE)\n            photos: Queryset or list of Photo objects to group\n            similarity_score: Optional similarity score for visual duplicates\n\n        Returns:\n            The Duplicate instance (new or existing)\n        \"\"\"\n        photo_list = list(photos)\n        if len(photo_list) < 2:\n            return None\n\n        # Check if any photo is already in a duplicate group of the same type\n        existing_duplicates = cls.objects.filter(\n            photos__in=photo_list,\n            duplicate_type=duplicate_type,\n            owner=owner,\n        ).distinct()\n\n        if existing_duplicates.exists():\n            # Merge all into the first existing duplicate group\n            target_duplicate = existing_duplicates.first()\n            for duplicate in existing_duplicates[1:]:\n                target_duplicate.merge_with(duplicate)\n\n            # Add any new photos to the group (ManyToMany)\n            for photo in photo_list:\n                if not photo.duplicates.filter(pk=target_duplicate.pk).exists():\n                    photo.duplicates.add(target_duplicate)\n\n            target_duplicate.calculate_potential_savings()\n            return target_duplicate\n        else:\n            # Create new duplicate group\n            duplicate = cls.objects.create(\n                owner=owner,\n                duplicate_type=duplicate_type,\n                similarity_score=similarity_score,\n            )\n\n            # Associate photos (ManyToMany)\n            for photo in photo_list:\n                photo.duplicates.add(duplicate)\n\n            duplicate.calculate_potential_savings()\n            return duplicate\n"
  },
  {
    "path": "api/models/face.py",
    "content": "import os\n\nimport numpy as np\nfrom django.db import models\nfrom django.dispatch import receiver\n\nfrom api.face_recognition import get_face_encodings\nfrom api.models.cluster import Cluster\nfrom api.models.person import Person\nfrom api.models.photo import Photo\n\n\nclass Face(models.Model):\n    photo = models.ForeignKey(\n        Photo, related_name=\"faces\", on_delete=models.CASCADE, blank=False, null=True\n    )\n    image = models.ImageField(upload_to=\"faces\", null=True)\n\n    person = models.ForeignKey(\n        Person, on_delete=models.DO_NOTHING, related_name=\"faces\", null=True\n    )\n\n    classification_person = models.ForeignKey(\n        Person,\n        related_name=\"classification_faces\",\n        on_delete=models.SET_NULL,\n        blank=True,\n        null=True,\n    )\n    classification_probability = models.FloatField(default=0.0, db_index=True)\n\n    cluster_person = models.ForeignKey(\n        Person,\n        related_name=\"cluster_faces\",\n        on_delete=models.SET_NULL,\n        blank=True,\n        null=True,\n    )\n    cluster_probability = models.FloatField(default=0.0, db_index=True)\n\n    deleted = models.BooleanField(default=False)\n\n    cluster = models.ForeignKey(\n        Cluster,\n        related_name=\"faces\",\n        on_delete=models.SET_NULL,\n        blank=True,\n        null=True,\n    )\n\n    location_top = models.IntegerField()\n    location_bottom = models.IntegerField()\n    location_left = models.IntegerField()\n    location_right = models.IntegerField()\n    encoding = models.TextField()\n\n    @property\n    def timestamp(self):\n        return self.photo.exif_timestamp if self.photo else None\n\n    def __str__(self):\n        return \"%d\" % self.id\n\n    def generate_encoding(self):\n        self.encoding = (\n            get_face_encodings(\n                self.photo.thumbnail.thumbnail_big.path,\n                [\n                    (\n                        self.location_top,\n                        self.location_right,\n                        self.location_bottom,\n                        self.location_left,\n                    )\n                ],\n            )[0]\n            .tobytes()\n            .hex()\n        )\n        self.save()\n\n    def get_encoding_array(self):\n        return np.frombuffer(bytes.fromhex(self.encoding))\n\n\n@receiver(models.signals.post_delete, sender=Person)\ndef reset_person(sender, instance, **kwargs):\n    instance.faces.update(person=None)\n\n\n# From: https://stackoverflow.com/questions/16041232/django-delete-filefield\n@receiver(models.signals.post_delete, sender=Face)\ndef auto_delete_file_on_delete(sender, instance, **kwargs):\n    if instance.image:\n        if os.path.isfile(instance.image.path):\n            os.remove(instance.image.path)\n"
  },
  {
    "path": "api/models/file.py",
    "content": "import hashlib\nimport os\n\nimport magic\nimport pyvips\nfrom django.db import models\n\nfrom api import util\n\n# Most optimal value for performance/memory. Found here:\n# https://stackoverflow.com/questions/17731660/hashlib-optimal-size-of-chunks-to-be-used-in-md5-update\nBUFFER_SIZE = 65536\n\n\n# To-Do: add owner to file\nclass File(models.Model):\n    IMAGE = 1\n    VIDEO = 2\n    METADATA_FILE = 3\n    RAW_FILE = 4\n    UNKNOWN = 5\n\n    FILE_TYPES = (\n        (IMAGE, \"Image\"),\n        (VIDEO, \"Video\"),\n        (METADATA_FILE, \"Metadata File e.g. XMP\"),\n        (RAW_FILE, \"Raw File\"),\n        (UNKNOWN, \"Unknown\"),\n    )\n\n    hash = models.CharField(primary_key=True, max_length=64, null=False)\n    path = models.TextField(blank=True, default=\"\", unique=True)\n    type = models.PositiveIntegerField(\n        blank=True,\n        choices=FILE_TYPES,\n    )\n    missing = models.BooleanField(default=False)\n    embedded_media = models.ManyToManyField(\"self\", symmetrical=False)\n\n    def __str__(self):\n        return self.path + \" \" + self._find_out_type()\n\n    @staticmethod\n    def create(path: str, user):\n        \"\"\"\n        Create or retrieve a File record for the given path.\n\n        Uses get_or_create pattern to handle unique path constraint:\n        - If a File with this path already exists, return it\n        - If not, create a new File with calculated hash\n\n        Handles race conditions: if concurrent creates happen for the same\n        path, only one will succeed and others will return the existing file.\n\n        Note: If file content has changed (different hash), the existing\n        File record is returned. Hash updates should be handled separately\n        during rescan operations.\n\n        Args:\n            path: The file system path to the file\n            user: The user who owns this file (used for hash calculation)\n\n        Returns:\n            File: The existing or newly created File instance\n        \"\"\"\n        from django.db import IntegrityError\n\n        # Check if a File with this path already exists\n        existing = File.objects.filter(path=path).first()\n        if existing:\n            return existing\n\n        # Create new File\n        file = File()\n        file.path = path\n        file.hash = calculate_hash(user, path)\n        file._find_out_type()\n\n        try:\n            file.save()\n            return file\n        except IntegrityError:\n            # Race condition: another thread created the file between our check and save\n            # Try to fetch by path first (unique constraint), then by hash (primary key)\n            existing = File.objects.filter(path=path).first()\n            if existing:\n                return existing\n            # If path doesn't exist, hash collision occurred - fetch by hash\n            existing = File.objects.filter(hash=file.hash).first()\n            if existing:\n                return existing\n            # Re-raise if we can't find the conflicting record\n            raise\n\n    def _find_out_type(self):\n        self.type = File.IMAGE\n        if is_raw(self.path):\n            self.type = File.RAW_FILE\n        if is_video(self.path):\n            self.type = File.VIDEO\n        if is_metadata(self.path):\n            self.type = File.METADATA_FILE\n        self.save()\n\n\ndef is_video(path):\n    try:\n        mime = magic.Magic(mime=True)\n        filename = mime.from_file(path)\n        return filename.find(\"video\") != -1\n    except Exception:\n        util.logger.error(f\"Error while checking if file is video: {path}\")\n        return False\n\n\ndef is_raw(path):\n    fileextension = os.path.splitext(path)[1]\n    rawformats = [\n        \".RWZ\",\n        \".CR2\",\n        \".NRW\",\n        \".EIP\",\n        \".RAF\",\n        \".ERF\",\n        \".RW2\",\n        \".NEF\",\n        \".ARW\",\n        \".K25\",\n        \".DNG\",\n        \".SRF\",\n        \".DCR\",\n        \".RAW\",\n        \".CRW\",\n        \".BAY\",\n        \".3FR\",\n        \".CS1\",\n        \".MEF\",\n        \".ORF\",\n        \".ARI\",\n        \".SR2\",\n        \".KDC\",\n        \".MOS\",\n        \".MFW\",\n        \".FFF\",\n        \".CR3\",\n        \".SRW\",\n        \".RWL\",\n        \".J6I\",\n        \".KC2\",\n        \".X3F\",\n        \".MRW\",\n        \".IIQ\",\n        \".PEF\",\n        \".CXI\",\n        \".MDC\",\n    ]\n    return fileextension.upper() in rawformats\n\n\ndef is_metadata(path):\n    fileextension = os.path.splitext(path)[1]\n    rawformats = [\n        \".XMP\",\n    ]\n    return fileextension.upper() in rawformats\n\n\ndef is_valid_media(path, user) -> bool:\n    if is_video(path=path) or is_metadata(path=path):\n        return True\n    if is_raw(path=path):\n        return True\n    try:\n        pyvips.Image.thumbnail(path, 10000, height=200, size=pyvips.enums.Size.DOWN)\n        return True\n    except Exception as e:\n        util.logger.info(f\"Could not handle {path}, because {str(e)}\")\n        return False\n\n\ndef calculate_hash(user, path):\n    try:\n        hash_md5 = hashlib.md5()\n        with open(path, \"rb\") as f:\n            for chunk in iter(lambda: f.read(BUFFER_SIZE), b\"\"):\n                hash_md5.update(chunk)\n        return hash_md5.hexdigest() + str(user.id)\n    except Exception as e:\n        util.logger.error(f\"Could not calculate hash for file {path}\")\n        raise e\n\n\ndef calculate_hash_b64(user, content):\n    hash_md5 = hashlib.md5()\n    with content as f:\n        for chunk in iter(lambda: f.read(BUFFER_SIZE), b\"\"):\n            hash_md5.update(chunk)\n    return hash_md5.hexdigest() + str(user.id)\n"
  },
  {
    "path": "api/models/long_running_job.py",
    "content": "import uuid\nfrom datetime import datetime, timedelta\n\nfrom django.db import models\nfrom django.utils import timezone\n\nfrom api.models.user import User, get_deleted_user\n\n\nclass LongRunningJob(models.Model):\n    JOB_SCAN_PHOTOS = 1\n    JOB_GENERATE_AUTO_ALBUMS = 2\n    JOB_GENERATE_AUTO_ALBUM_TITLES = 3\n    JOB_TRAIN_FACES = 4\n    JOB_DELETE_MISSING_PHOTOS = 5\n    JOB_CALCULATE_CLIP_EMBEDDINGS = 6\n    JOB_SCAN_FACES = 7\n    JOB_CLUSTER_ALL_FACES = 8\n    JOB_DOWNLOAD_PHOTOS = 9\n    JOB_DOWNLOAD_MODELS = 10\n    JOB_ADD_GEOLOCATION = 11\n    JOB_GENERATE_TAGS = 12\n    JOB_GENERATE_FACE_EMBEDDINGS = 13\n    JOB_SCAN_MISSING_PHOTOS = 14\n    JOB_DETECT_DUPLICATES = 15\n    JOB_REPAIR_FILE_VARIANTS = 16\n\n    JOB_TYPES = (\n        (JOB_SCAN_PHOTOS, \"Scan Photos\"),\n        (JOB_GENERATE_AUTO_ALBUMS, \"Generate Event Albums\"),\n        (JOB_GENERATE_AUTO_ALBUM_TITLES, \"Regenerate Event Titles\"),\n        (JOB_TRAIN_FACES, \"Train Faces\"),\n        (JOB_DELETE_MISSING_PHOTOS, \"Delete Missing Photos\"),\n        (JOB_SCAN_FACES, \"Scan Faces\"),\n        (JOB_CALCULATE_CLIP_EMBEDDINGS, \"Calculate Clip Embeddings\"),\n        (JOB_CLUSTER_ALL_FACES, \"Find Similar Faces\"),\n        (JOB_DOWNLOAD_PHOTOS, \"Download Selected Photos\"),\n        (JOB_DOWNLOAD_MODELS, \"Download Models\"),\n        (JOB_ADD_GEOLOCATION, \"Add Geolocation\"),\n        (JOB_GENERATE_TAGS, \"Generate Tags\"),\n        (JOB_GENERATE_FACE_EMBEDDINGS, \"Generate Face Embeddings\"),\n        (JOB_SCAN_MISSING_PHOTOS, \"Scan Missing Photos\"),\n        (JOB_DETECT_DUPLICATES, \"Detect Duplicate Photos\"),\n        (JOB_REPAIR_FILE_VARIANTS, \"Repair File Variants\"),\n    )\n\n    job_type = models.PositiveIntegerField(\n        choices=JOB_TYPES,\n    )\n\n    finished = models.BooleanField(default=False, blank=False, null=False)\n    failed = models.BooleanField(default=False, blank=False, null=False)\n    job_id = models.CharField(max_length=36, unique=True, db_index=True)\n    queued_at = models.DateTimeField(default=datetime.now, null=False)\n    started_at = models.DateTimeField(null=True)\n    finished_at = models.DateTimeField(null=True)\n    started_by = models.ForeignKey(\n        User, on_delete=models.SET(get_deleted_user), default=None\n    )\n    progress_current = models.PositiveIntegerField(default=0)\n    progress_target = models.PositiveIntegerField(default=0)\n    # New fields for detailed progress reporting\n    progress_step = models.CharField(max_length=100, null=True, blank=True)  # Current step description\n    result = models.JSONField(null=True, blank=True)  # Detailed result/progress data\n\n    class Meta:\n        ordering = [\"-queued_at\"]\n        verbose_name = \"Long Running Job\"\n        verbose_name_plural = \"Long Running Jobs\"\n\n    def __str__(self):\n        status = \"failed\" if self.failed else (\"finished\" if self.finished else \"running\" if self.started_at else \"queued\")\n        return f\"Job {self.job_id} - {self.get_job_type_display()} - {status}\"\n\n    @property\n    def is_running(self):\n        \"\"\"Check if job is currently running (started but not finished).\"\"\"\n        return self.started_at is not None and not self.finished\n\n    @property\n    def duration(self):\n        \"\"\"Return job duration in seconds, or None if not started.\"\"\"\n        if not self.started_at:\n            return None\n        end = self.finished_at or timezone.now()\n        return (end - self.started_at).total_seconds()\n\n    def start(self):\n        \"\"\"Mark job as started.\"\"\"\n        self.started_at = timezone.now()\n        self.save(update_fields=[\"started_at\"])\n\n    def complete(self, result=None):\n        \"\"\"Mark job as successfully completed.\"\"\"\n        self.finished = True\n        self.finished_at = timezone.now()\n        if result is not None:\n            self.result = result\n        self.save(update_fields=[\"finished\", \"finished_at\", \"result\"])\n\n    def fail(self, error=None):\n        \"\"\"Mark job as failed with optional error message.\"\"\"\n        self.failed = True\n        self.finished = True\n        self.finished_at = timezone.now()\n        if error is not None:\n            self.result = {\"status\": \"failed\", \"error\": str(error)}\n        self.save(update_fields=[\"failed\", \"finished\", \"finished_at\", \"result\"])\n\n    def update_progress(self, current, target=None, step=None):\n        \"\"\"Update job progress counters and optional step description.\"\"\"\n        update_fields = [\"progress_current\"]\n        self.progress_current = current\n        if target is not None:\n            self.progress_target = target\n            update_fields.append(\"progress_target\")\n        if step is not None:\n            self.progress_step = step\n            update_fields.append(\"progress_step\")\n        self.save(update_fields=update_fields)\n\n    def set_result(self, result):\n        \"\"\"Update the job result/progress data.\"\"\"\n        self.result = result\n        self.save(update_fields=[\"result\"])\n\n    @classmethod\n    def create_job(cls, user, job_type, job_id=None, start_now=False):\n        \"\"\"\n        Factory method to create a new job with proper defaults.\n        \n        Args:\n            user: The user who started the job\n            job_type: One of the JOB_* constants\n            job_id: Optional job ID (auto-generated UUID if not provided)\n            start_now: If True, set started_at to now\n        \n        Returns:\n            The newly created LongRunningJob instance\n        \"\"\"\n        if job_id is None:\n            job_id = str(uuid.uuid4())\n        job = cls.objects.create(\n            started_by=user,\n            job_id=str(job_id),\n            queued_at=timezone.now(),\n            job_type=job_type,\n        )\n        if start_now:\n            job.start()\n        return job\n\n    @classmethod\n    def get_or_create_job(cls, user, job_type, job_id):\n        \"\"\"\n        Get an existing job by job_id or create a new one.\n        \n        This is useful for queued jobs where the job_id is known ahead of time.\n        If the job exists, it will be marked as started. If not, a new job is created.\n        \n        Args:\n            user: The user who started the job\n            job_type: One of the JOB_* constants\n            job_id: The job ID to look up or use for creation\n        \n        Returns:\n            The LongRunningJob instance (existing or newly created)\n        \"\"\"\n        if cls.objects.filter(job_id=job_id).exists():\n            job = cls.objects.get(job_id=job_id)\n            job.start()\n            return job\n        return cls.create_job(user=user, job_type=job_type, job_id=job_id, start_now=True)\n\n    @classmethod\n    def cleanup_stuck_jobs(cls, hours=24):\n        \"\"\"\n        Mark jobs as failed if they've been running for too long.\n        \n        Jobs that have started_at set but finished=False for longer than\n        the specified hours are considered stuck and will be marked as failed.\n        \n        Args:\n            hours: Number of hours after which a running job is considered stuck\n        \n        Returns:\n            Number of jobs marked as failed\n        \"\"\"\n        cutoff = timezone.now() - timedelta(hours=hours)\n        stuck_jobs = cls.objects.filter(\n            finished=False,\n            started_at__isnull=False,\n            started_at__lt=cutoff\n        )\n        count = stuck_jobs.count()\n        stuck_jobs.update(\n            failed=True,\n            finished=True,\n            finished_at=timezone.now(),\n            result={\"status\": \"failed\", \"error\": f\"Job timed out after {hours} hours\"}\n        )\n        return count\n\n    @classmethod\n    def cleanup_old_jobs(cls, days=30):\n        \"\"\"\n        Delete completed/failed jobs older than specified days.\n        \n        Args:\n            days: Number of days after which completed jobs should be deleted\n        \n        Returns:\n            Number of jobs deleted\n        \"\"\"\n        cutoff = timezone.now() - timedelta(days=days)\n        deleted, _ = cls.objects.filter(\n            finished=True,\n            finished_at__lt=cutoff\n        ).delete()\n        return deleted\n"
  },
  {
    "path": "api/models/person.py",
    "content": "import datetime\n\nimport pytz\nfrom django.core.validators import MinLengthValidator\nfrom django.db import models\nfrom django.db.models import Prefetch\n\nfrom api.models.photo import Photo\nfrom api.models.user import User\n\nutc = pytz.UTC\n\n\nclass Person(models.Model):\n    UNKNOWN_PERSON_NAME = \"Unknown - Other\"\n    KIND_USER = \"USER\"\n    KIND_CLUSTER = \"CLUSTER\"\n    KIND_UNKNOWN = \"UNKNOWN\"\n    KIND_CHOICES = (\n        (KIND_USER, \"User Labelled\"),\n        (KIND_CLUSTER, \"Cluster ID\"),\n        (KIND_UNKNOWN, \"Unknown Person\"),\n    )\n    name = models.CharField(\n        blank=False, max_length=128, validators=[MinLengthValidator(1)], db_index=True\n    )\n    kind = models.CharField(choices=KIND_CHOICES, max_length=10)\n    cover_photo = models.ForeignKey(\n        Photo, related_name=\"person\", on_delete=models.SET_NULL, blank=False, null=True\n    )\n    cover_face = models.ForeignKey(\n        \"Face\",\n        related_name=\"face\",\n        on_delete=models.SET_NULL,\n        blank=False,\n        null=True,\n    )\n    face_count = models.IntegerField(default=0)\n    cluster_owner = models.ForeignKey(\n        User,\n        related_name=\"owner\",\n        on_delete=models.SET_NULL,\n        default=None,\n        null=True,\n    )\n\n    def __str__(self):\n        return (\n            self.name\n            + \" (\"\n            + self.kind\n            + \")\"\n            + \" (\"\n            + str(self.id)\n            + \")\"\n            + \" (\"\n            + str(self.cluster_owner)\n            + \")\"\n        )\n\n    def _calculate_face_count(self):\n        self.face_count = self.faces.filter(\n            photo__hidden=False,\n            photo__in_trashcan=False,\n            photo__owner=self.cluster_owner.id,\n        ).count()\n        self.save()\n\n    def _set_default_cover_photo(self):\n        if not self.cover_photo and self.faces.count() > 0:\n            self.cover_photo = self.faces.first().photo\n            self.cover_face = self.faces.first()\n            self.save()\n\n    def get_photos(self, owner):\n        faces = list(\n            self.faces.prefetch_related(\n                Prefetch(\n                    \"photo\",\n                    queryset=Photo.objects.exclude(image_hash=None)\n                    .filter(hidden=False, owner=owner)\n                    .order_by(\"-exif_timestamp\")\n                    .only(\n                        \"image_hash\",\n                        \"exif_timestamp\",\n                        \"rating\",\n                        \"owner__id\",\n                        \"public\",\n                        \"hidden\",\n                    )\n                    .prefetch_related(\"owner\"),\n                )\n            )\n        )\n\n        photos = [face.photo for face in faces if hasattr(face.photo, \"owner\")]\n        photos.sort(\n            key=lambda x: x.exif_timestamp or utc.localize(datetime.datetime.min),\n            reverse=True,\n        )\n        return photos\n\n\n# TODO: Should be removed in the future, as it is not used, only in migrations\ndef get_unknown_person(owner: User = None):\n    unknown_person: Person = Person.objects.get_or_create(\n        name=Person.UNKNOWN_PERSON_NAME, cluster_owner=owner, kind=Person.KIND_UNKNOWN\n    )[0]\n    if unknown_person.kind != Person.KIND_UNKNOWN:\n        unknown_person.kind = Person.KIND_UNKNOWN\n        unknown_person.save()\n    return unknown_person\n\n\ndef get_or_create_person(name, owner: User = None, kind: str = Person.KIND_UNKNOWN):\n    return Person.objects.get_or_create(name=name, cluster_owner=owner, kind=kind)[0]\n"
  },
  {
    "path": "api/models/photo.py",
    "content": "import json\nimport numbers\nimport os\nimport uuid\nfrom fractions import Fraction\nfrom io import BytesIO\n\nimport numpy as np\nimport PIL\nfrom django.core.files.base import ContentFile\nfrom django.db import models\nfrom django.db.models import Q\nfrom django.db.utils import IntegrityError\n\nimport api.models\nfrom api import date_time_extractor, face_extractor, util\nfrom api.geocode import GEOCODE_VERSION\nfrom api.geocode.geocode import reverse_geocode\nfrom api.metadata.reader import get_metadata\nfrom api.metadata.tags import Tags\nfrom api.metadata.writer import write_metadata\nfrom api.models.file import File\nfrom api.models.user import User, get_deleted_user\nfrom api.util import logger\n\n\nclass VisiblePhotoManager(models.Manager):\n    def get_queryset(self):\n        return (\n            super()\n            .get_queryset()\n            .filter(\n                Q(hidden=False)\n                & Q(thumbnail__aspect_ratio__isnull=False)\n                & Q(in_trashcan=False)\n                & Q(removed=False)\n            )\n        )\n\n\nclass Photo(models.Model):\n    # UUID primary key (like Immich) - enables flexible asset management\n    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)\n\n    # Content hash for deduplication - unique per user\n    # Format: MD5 hash + user_id (e.g., \"abc123def456...789\" + \"1\")\n    image_hash = models.CharField(max_length=64, db_index=True)\n\n    files = models.ManyToManyField(File)\n    main_file = models.ForeignKey(\n        File,\n        related_name=\"main_photo\",\n        on_delete=models.SET_NULL,\n        blank=False,\n        null=True,\n    )\n\n    added_on = models.DateTimeField(null=False, blank=False, db_index=True)\n\n    exif_gps_lat = models.FloatField(blank=True, null=True)\n    exif_gps_lon = models.FloatField(blank=True, null=True)\n    exif_timestamp = models.DateTimeField(blank=True, null=True, db_index=True)\n\n    exif_json = models.JSONField(blank=True, null=True)\n\n    geolocation_json = models.JSONField(blank=True, null=True, db_index=True)\n\n    timestamp = models.DateTimeField(blank=True, null=True, db_index=True)\n    rating = models.IntegerField(default=0, db_index=True)\n    in_trashcan = models.BooleanField(default=False, db_index=True)\n    removed = models.BooleanField(default=False, db_index=True)\n    hidden = models.BooleanField(default=False, db_index=True)\n    video = models.BooleanField(default=False)\n    video_length = models.TextField(blank=True, null=True)\n    size = models.BigIntegerField(default=0)\n    # Metadata fields (camera, lens, fstop, etc.) moved to PhotoMetadata model\n    # See migration 0103_remove_photo_metadata_fields.py\n\n    owner = models.ForeignKey(\n        User, on_delete=models.SET(get_deleted_user), default=None\n    )\n\n    shared_to = models.ManyToManyField(User, related_name=\"photo_shared_to\")\n\n    public = models.BooleanField(default=False, db_index=True)\n\n    # Use JSONField for database compatibility (works with both PostgreSQL and SQLite)\n    clip_embeddings = models.JSONField(blank=True, null=True)\n\n    clip_embeddings_magnitude = models.FloatField(blank=True, null=True)\n    last_modified = models.DateTimeField(auto_now=True)\n\n    # Perceptual hash for duplicate detection (pHash algorithm)\n    perceptual_hash = models.CharField(\n        max_length=64, blank=True, null=True, db_index=True\n    )\n\n    # Organizational photo stacks (RAW+JPEG pairs, bursts, brackets, live photos, manual)\n    # A photo can belong to multiple stacks of different types simultaneously\n    stacks = models.ManyToManyField(\n        \"PhotoStack\",\n        blank=True,\n        related_name=\"photos\",\n    )\n\n    # Duplicate groups (exact copies, visual duplicates)\n    # Separate from stacks because duplicates are about cleanup, not organization\n    duplicates = models.ManyToManyField(\n        \"Duplicate\",\n        blank=True,\n        related_name=\"photos\",\n    )\n\n    # Sub-second timestamp precision for burst detection\n    # Stores the fractional seconds from EXIF:SubSecTimeOriginal\n    exif_timestamp_subsec = models.CharField(max_length=10, blank=True, null=True)\n\n    # Camera image sequence number (for burst/sequence detection)\n    # From EXIF:ImageNumber or MakerNotes\n    image_sequence_number = models.IntegerField(blank=True, null=True)\n\n    objects = models.Manager()\n    visible = VisiblePhotoManager()\n\n    _loaded_values = {}\n\n    def get_clip_embeddings(self):\n        \"\"\"Get clip embeddings as a list, regardless of storage format\"\"\"\n        if not self.clip_embeddings:\n            return None\n\n        # Handle case where embeddings might be stored as JSON string\n        if isinstance(self.clip_embeddings, str):\n            try:\n                import json\n\n                return json.loads(self.clip_embeddings)\n            except (json.JSONDecodeError, TypeError):\n                return None\n\n        return self.clip_embeddings\n\n    def set_clip_embeddings(self, embeddings):\n        \"\"\"Set clip embeddings, automatically handling storage format\"\"\"\n        self.clip_embeddings = embeddings if embeddings else None\n\n    @classmethod\n    def from_db(cls, db, field_names, values):\n        instance = super().from_db(db, field_names, values)\n\n        # save original values, when model is loaded from database,\n        # in a separate attribute on the model\n        instance._loaded_values = dict(zip(field_names, values))\n\n        return instance\n\n    def save(\n        self,\n        force_insert=False,\n        force_update=False,\n        using=None,\n        update_fields=None,\n        save_metadata=True,\n    ):\n        modified_fields = [\n            field_name\n            for field_name, value in self._loaded_values.items()\n            if value != getattr(self, field_name)\n        ]\n        user = User.objects.get(username=self.owner)\n        if save_metadata and user.save_metadata_to_disk != User.SaveMetadata.OFF:\n            self._save_metadata(\n                modified_fields,\n                user.save_metadata_to_disk == User.SaveMetadata.SIDECAR_FILE,\n            )\n        return super().save(\n            force_insert=force_insert,\n            force_update=force_update,\n            using=using,\n            update_fields=update_fields,\n        )\n\n    def _save_metadata(\n        self, modified_fields=None, use_sidecar=True, metadata_types=None\n    ):\n        \"\"\"Write metadata tags to the photo's file or sidecar.\n\n        Args:\n            modified_fields: List of changed field names (from Photo.save() diff).\n                When None, writes all applicable tags unconditionally.\n            use_sidecar: Write to XMP sidecar file if True, media file if False.\n            metadata_types: List of metadata categories to write, e.g.\n                [\"ratings\", \"face_tags\"]. When None, uses default behavior\n                (ratings/timestamps only, for backward compatibility).\n        \"\"\"\n        tags_to_write = {}\n\n        write_ratings = metadata_types is None or \"ratings\" in metadata_types\n        write_face_tags = metadata_types is not None and \"face_tags\" in metadata_types\n\n        if write_ratings:\n            if modified_fields is None or \"rating\" in modified_fields:\n                tags_to_write[Tags.RATING] = self.rating\n            if modified_fields is not None and \"timestamp\" in modified_fields:\n                # To-Do: Only works for files and not for the sidecar file\n                tags_to_write[Tags.DATE_TIME] = self.timestamp\n\n        if write_face_tags:\n            from api.metadata.face_regions import get_face_region_tags\n\n            tags_to_write.update(get_face_region_tags(self))\n\n        if tags_to_write:\n            write_metadata(self.main_file.path, tags_to_write, use_sidecar=use_sidecar)\n\n    def _find_album_place(self):\n        return api.models.album_place.AlbumPlace.objects.filter(\n            Q(photos__in=[self])\n        ).all()\n\n    def _find_album_date(self):\n        old_album_date = None\n        if self.exif_timestamp:\n            possible_old_album_date = api.models.album_date.get_album_date(\n                date=self.exif_timestamp.date(), owner=self.owner\n            )\n            if (\n                possible_old_album_date is not None\n                and possible_old_album_date.photos.filter(\n                    image_hash=self.image_hash\n                ).exists()\n            ):\n                old_album_date = possible_old_album_date\n        else:\n            possible_old_album_date = api.models.album_date.get_album_date(\n                date=None, owner=self.owner\n            )\n            if (\n                possible_old_album_date is not None\n                and possible_old_album_date.photos.filter(\n                    image_hash=self.image_hash\n                ).exists()\n            ):\n                old_album_date = possible_old_album_date\n        return old_album_date\n\n    def _extract_date_time_from_exif(self, commit=True):\n        def exif_getter(tags):\n            return get_metadata(self.main_file.path, tags=tags, try_sidecar=True)\n\n        datetime_config = json.loads(self.owner.datetime_rules)\n        extracted_local_time = date_time_extractor.extract_local_date_time(\n            self.main_file.path,\n            date_time_extractor.as_rules(datetime_config),\n            exif_getter,\n            self.exif_gps_lat,\n            self.exif_gps_lon,\n            self.owner.default_timezone,\n            self.timestamp,\n        )\n\n        old_album_date = self._find_album_date()\n        if self.exif_timestamp != extracted_local_time:\n            self.exif_timestamp = extracted_local_time\n\n        if old_album_date is not None:\n            old_album_date.photos.remove(self)\n            old_album_date.save()\n\n        album_date = None\n\n        if self.exif_timestamp:\n            album_date = api.models.album_date.get_or_create_album_date(\n                date=self.exif_timestamp.date(), owner=self.owner\n            )\n            album_date.photos.add(self)\n        else:\n            album_date = api.models.album_date.get_or_create_album_date(\n                date=None, owner=self.owner\n            )\n            album_date.photos.add(self)\n\n        if commit:\n            self.save()\n        album_date.save()\n\n    def _geolocate(self, commit=True):\n        old_gps_lat = self.exif_gps_lat\n        old_gps_lon = self.exif_gps_lon\n        new_gps_lat, new_gps_lon = get_metadata(\n            self.main_file.path,\n            tags=[Tags.LATITUDE, Tags.LONGITUDE],\n            try_sidecar=True,\n        )\n        old_album_places = self._find_album_place()\n        # Skip if it hasn't changed or is null\n        if not new_gps_lat or not new_gps_lon:\n            return\n        if (\n            old_gps_lat == float(new_gps_lat)\n            and old_gps_lon == float(new_gps_lon)\n            and old_album_places.count() != 0\n            and self.geolocation_json\n            and \"_v\" in self.geolocation_json\n            and self.geolocation_json[\"_v\"] == GEOCODE_VERSION\n        ):\n            return\n        self.exif_gps_lon = float(new_gps_lon)\n        self.exif_gps_lat = float(new_gps_lat)\n        if commit:\n            self.save()\n        try:\n            res = reverse_geocode(new_gps_lat, new_gps_lon)\n            if not res:\n                return\n        except Exception as e:\n            util.logger.warning(e)\n            util.logger.warning(\"Something went wrong with geolocating\")\n            return\n\n        self.geolocation_json = res\n\n        # Update search location through PhotoSearch model\n        from api.models.photo_search import PhotoSearch\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=self)\n        search_instance.update_search_location(res)\n        search_instance.save()\n\n        # Delete photo from album places if location has changed\n        if old_album_places is not None:\n            for old_album_place in old_album_places:\n                old_album_place.photos.remove(self)\n                old_album_place.save()\n\n        # Add photo to new album places\n        for geolocation_level, feature in enumerate(self.geolocation_json[\"features\"]):\n            if \"text\" not in feature.keys() or feature[\"text\"].isnumeric():\n                continue\n            album_place = api.models.album_place.get_album_place(\n                feature[\"text\"], owner=self.owner\n            )\n            if album_place.photos.filter(image_hash=self.image_hash).count() == 0:\n                album_place.geolocation_level = (\n                    len(self.geolocation_json[\"features\"]) - geolocation_level\n                )\n            album_place.photos.add(self)\n            album_place.save()\n\n        if commit:\n            self.save()\n\n    def _add_location_to_album_dates(self):\n        if not self.geolocation_json:\n            return\n        if len(self.geolocation_json[\"places\"]) < 2:\n            logger.info(self.geolocation_json)\n            return\n\n        album_date = self._find_album_date()\n        city_name = self.geolocation_json[\"places\"][-2]\n        if album_date.location and len(album_date.location) > 0:\n            prev_value = album_date.location\n            new_value = prev_value\n            if city_name not in prev_value[\"places\"]:\n                new_value[\"places\"].append(city_name)\n                new_value[\"places\"] = list(set(new_value[\"places\"]))\n                album_date.location = new_value\n        else:\n            album_date.location = {\"places\": [city_name]}\n        # Safe geolocation_json\n        album_date.save()\n\n    def _extract_faces(self, second_try=False):\n        unknown_cluster: api.models.cluster.Cluster = (\n            api.models.cluster.get_unknown_cluster(user=self.owner)\n        )\n        try:\n            big_thumbnail_image = np.array(\n                PIL.Image.open(self.thumbnail.thumbnail_big.path)\n            )\n\n            face_locations = face_extractor.extract(\n                self.main_file.path, self.thumbnail.thumbnail_big.path, self.owner\n            )\n\n            if len(face_locations) == 0:\n                return\n\n            for idx_face, face_location in enumerate(face_locations):\n                top, right, bottom, left, person_name = face_location\n                if person_name:\n                    person = api.models.person.get_or_create_person(\n                        name=person_name, owner=self.owner\n                    )\n                    person.save()\n                else:\n                    person = None\n\n                face_image = big_thumbnail_image[top:bottom, left:right]\n                face_image = PIL.Image.fromarray(face_image)\n\n                image_path = self.image_hash + \"_\" + str(idx_face) + \".jpg\"\n\n                margin = int((right - left) * 0.05)\n                existing_faces = api.models.face.Face.objects.filter(\n                    photo=self,\n                    location_top__lte=top + margin,\n                    location_top__gte=top - margin,\n                    location_right__lte=right + margin,\n                    location_right__gte=right - margin,\n                    location_bottom__lte=bottom + margin,\n                    location_bottom__gte=bottom - margin,\n                    location_left__lte=left + margin,\n                    location_left__gte=left - margin,\n                )\n\n                if existing_faces.count() != 0:\n                    continue\n\n                face = api.models.face.Face(\n                    photo=self,\n                    location_top=top,\n                    location_right=right,\n                    location_bottom=bottom,\n                    location_left=left,\n                    encoding=\"\",\n                    person=person,\n                    cluster=unknown_cluster,\n                )\n                if person_name:\n                    person._calculate_face_count()\n                    person._set_default_cover_photo()\n                face_io = BytesIO()\n                face_image.save(face_io, format=\"JPEG\")\n                face.image.save(image_path, ContentFile(face_io.getvalue()))\n                face_io.close()\n                face.save()\n            logger.info(f\"image {self.image_hash}: {len(face_locations)} face(s) saved\")\n        except IntegrityError:\n            # When using multiple processes, then we can save at the same time, which leads to this error\n            if self.files.count() > 0:\n                # print out the location of the image only if we have a path\n                logger.info(f\"image {self.main_file.path}: rescan face failed\")\n            if not second_try:\n                self._extract_faces(True)\n            elif self.files.count() > 0:\n                logger.error(f\"image {self.main_file.path}: rescan face failed\")\n            else:\n                logger.error(f\"image {self}: rescan face failed\")\n        except Exception as e:\n            logger.error(f\"image {self}: scan face failed\")\n            raise e\n\n    def _add_to_album_thing(self):\n        if (\n            hasattr(self, \"caption_instance\")\n            and self.caption_instance\n            and self.caption_instance.captions_json\n            and type(self.caption_instance.captions_json) is dict\n            and \"places365\" in self.caption_instance.captions_json.keys()\n        ):\n            for attribute in self.caption_instance.captions_json[\"places365\"][\n                \"attributes\"\n            ]:\n                album_thing = api.models.album_thing.get_album_thing(\n                    title=attribute,\n                    owner=self.owner,\n                )\n                if album_thing.photos.filter(image_hash=self.image_hash).count() == 0:\n                    album_thing.photos.add(self)\n                    album_thing.thing_type = \"places365_attribute\"\n                    album_thing.save()\n            for category in self.caption_instance.captions_json[\"places365\"][\n                \"categories\"\n            ]:\n                album_thing = api.models.album_thing.get_album_thing(\n                    title=category,\n                    owner=self.owner,\n                )\n                if album_thing.photos.filter(image_hash=self.image_hash).count() == 0:\n                    album_thing = api.models.album_thing.get_album_thing(\n                        title=category, owner=self.owner\n                    )\n                    album_thing.photos.add(self)\n                    album_thing.thing_type = \"places365_category\"\n                    album_thing.save()\n\n    def _check_files(self):\n        for file in self.files.all():\n            if not file.path or not os.path.exists(file.path):\n                self.files.remove(file)\n                file.missing = True\n                file.save()\n        self.save()\n\n    def manual_delete(self):\n        # Store stack references before cleanup (ManyToMany)\n        photo_stacks = list(self.stacks.all())\n\n        # Store duplicate group references before cleanup (ManyToMany)\n        photo_duplicates = list(self.duplicates.all())\n\n        # Handle file cleanup - only delete files not shared with other Photos\n        for file in self.files.all():\n            # Check if this file is used by other Photos (via files M2M or as main_file)\n            other_photos_using_file = (\n                file.photo_set.exclude(pk=self.pk).exists()\n                or file.main_photo.exclude(pk=self.pk).exists()\n            )\n\n            if other_photos_using_file:\n                # File is shared - just unlink from this photo, don't delete\n                logger.info(\n                    f\"File {file.path} is shared with other photos, unlinking only\"\n                )\n                self.files.remove(file)\n            else:\n                # File is only used by this photo - safe to delete\n                if os.path.isfile(file.path):\n                    logger.info(f\"Removing photo {file.path}\")\n                    os.remove(file.path)\n                file.delete()\n\n        self.files.set([])\n        self.main_file = None\n        self.removed = True\n\n        # Clear all stack references from this photo (ManyToMany)\n        self.stacks.clear()\n\n        # Clear all duplicate group references from this photo (ManyToMany)\n        self.duplicates.clear()\n\n        result = self.save()\n\n        # Clean up stacks if they're now empty or have only one photo left\n        for photo_stack in photo_stacks:\n            remaining_photos = photo_stack.photos.filter(removed=False).count()\n            if remaining_photos <= 1:\n                # If 0 or 1 photos left, delete the stack (no longer a valid grouping)\n                logger.info(\n                    f\"Deleting photo stack {photo_stack.id} - only {remaining_photos} photos remaining\"\n                )\n                # Unlink remaining photos from stack first\n                for remaining_photo in photo_stack.photos.all():\n                    remaining_photo.stacks.remove(photo_stack)\n                photo_stack.delete()\n\n        # Clean up duplicate groups if they're now empty or have only one photo left\n        for duplicate in photo_duplicates:\n            remaining_photos = duplicate.photos.filter(removed=False).count()\n            if remaining_photos <= 1:\n                # If 0 or 1 photos left, delete the duplicate group (no longer valid)\n                logger.info(\n                    f\"Deleting duplicate group {duplicate.id} - only {remaining_photos} photos remaining\"\n                )\n                # Unlink remaining photos from duplicate first\n                for remaining_photo in duplicate.photos.all():\n                    remaining_photo.duplicates.remove(duplicate)\n                duplicate.delete()\n\n        # To-Do: Handle wrong file permissions\n        return result\n\n    def _set_embedded_media(self, obj):\n        return obj.main_file.embedded_media\n\n    def __str__(self):\n        main_file_path = (\n            self.main_file.path if self.main_file is not None else \"No main file\"\n        )\n        return f\"{self.image_hash} - {self.owner} - {main_file_path}\"\n"
  },
  {
    "path": "api/models/photo_caption.py",
    "content": "from django.db import models\nfrom django.db.models import Q\n\nimport api.models\nfrom api import util\nfrom api.image_captioning import generate_caption\nfrom api.llm import generate_prompt\nfrom api.models.user import User\n\n\nclass PhotoCaption(models.Model):\n    \"\"\"Model for handling image captions and related functionality\"\"\"\n\n    photo = models.OneToOneField(\n        \"Photo\",\n        on_delete=models.CASCADE,\n        related_name=\"caption_instance\",\n        primary_key=True,\n    )\n    captions_json = models.JSONField(blank=True, null=True, db_index=True)\n\n    created_at = models.DateTimeField(auto_now_add=True)\n    updated_at = models.DateTimeField(auto_now=True)\n\n    class Meta:\n        db_table = \"api_photo_caption\"\n\n    def __str__(self):\n        return f\"Captions for {self.photo.image_hash}\"\n\n    def generate_captions_im2txt(self, commit=True):\n        \"\"\"Generate im2txt captions for the photo\"\"\"\n        if not self.photo.thumbnail or not self.photo.thumbnail.thumbnail_big:\n            util.logger.warning(\n                f\"No thumbnail available for photo {self.photo.image_hash}\"\n            )\n            return False\n\n        util.logger.info(\"Generating captions with Im2txt\")\n\n        try:\n            image_path = self.photo.thumbnail.thumbnail_big.path\n        except Exception:\n            util.logger.warning(\n                f\"Cannot access thumbnail path for photo {self.photo.image_hash}\"\n            )\n            return False\n        if self.captions_json is None:\n            self.captions_json = {}\n        captions = self.captions_json\n\n        try:\n            from constance import config as site_config\n\n            if site_config.CAPTIONING_MODEL == \"None\":\n                util.logger.info(\"Generating captions is disabled\")\n                return False\n\n            if site_config.CAPTIONING_MODEL == \"moondream\":\n                util.logger.info(\"Generating captions with Moondream\")\n                return self._generate_captions_moondream(commit=commit)\n\n            blip = False\n            if site_config.CAPTIONING_MODEL == \"blip_base_capfilt_large\":\n                blip = True\n\n            caption = generate_caption(image_path=image_path, blip=blip)\n            caption = caption.replace(\"<start>\", \"\").replace(\"<end>\", \"\").strip()\n\n            settings = User.objects.get(username=self.photo.owner).llm_settings\n            if site_config.LLM_MODEL != \"None\" and settings[\"enabled\"]:\n                face = api.models.Face.objects.filter(photo=self.photo).first()\n                person_name = \"\"\n                if face and settings[\"add_person\"]:\n                    person_name = \" Person: \" + face.person.name\n                place = \"\"\n                if (\n                    self.photo.search_instance\n                    and self.photo.search_instance.search_location\n                    and settings[\"add_location\"]\n                ):\n                    place = \" Place: \" + self.photo.search_instance.search_location\n                keywords = \"\"\n                if settings[\"add_keywords\"]:\n                    keywords = \" and tags or keywords\"\n                prompt = (\n                    \"Q: Your task is to improve the following image caption: \"\n                    + caption\n                    + \". You also know the following information about the image:\"\n                    + place\n                    + person_name\n                    + \". Stick as closely as possible to the caption, while replacing generic information with information you know about the image. Only output the caption\"\n                    + keywords\n                    + \". \\n A:\"\n                )\n                util.logger.info(prompt)\n                caption = generate_prompt(prompt)\n\n            captions[\"im2txt\"] = caption\n            self.captions_json = captions\n            self.recreate_search_captions()\n            if commit:\n                self.save()\n\n            util.logger.info(\n                f\"generated im2txt captions for image {image_path} with SiteConfig {site_config.CAPTIONING_MODEL} with Blip: {blip} caption: {caption}\"\n            )\n            return True\n        except Exception:\n            util.logger.exception(\n                f\"could not generate im2txt captions for image {image_path}\"\n            )\n            return False\n\n    def _generate_captions_moondream(self, commit=True):\n        \"\"\"Generate captions using Moondream with enhanced prompt\"\"\"\n        if not self.photo.thumbnail or not self.photo.thumbnail.thumbnail_big:\n            util.logger.warning(\n                f\"No thumbnail available for photo {self.photo.image_hash}\"\n            )\n            return False\n\n        try:\n            image_path = self.photo.thumbnail.thumbnail_big.path\n        except Exception:\n            util.logger.warning(\n                f\"Cannot access thumbnail path for photo {self.photo.image_hash}\"\n            )\n            return False\n        if self.captions_json is None:\n            self.captions_json = {}\n        captions = self.captions_json\n\n        try:\n            from constance import config as site_config\n\n            util.logger.info(\"Generating Moondream captions\")\n\n            settings = User.objects.get(username=self.photo.owner).llm_settings\n\n            # Default prompt\n            prompt = \"Describe this image in a short, natural image caption.\"\n\n            # Enhanced prompting if LLM is enabled\n            if site_config.LLM_MODEL != \"None\" and settings[\"enabled\"]:\n                face = api.models.Face.objects.filter(photo=self.photo).first()\n                person_name = \"\"\n                if face and settings[\"add_person\"]:\n                    person_name = (\n                        f\" The person in the photo is named {face.person.name}. \"\n                        f\"Use the name '{face.person.name}' directly in the caption — do not say 'a person named'. \"\n                        f\"Keep the caption casual and to the point, like a friend tagging a photo.\"\n                    )\n\n                place = \"\"\n                if (\n                    self.photo.search_instance\n                    and self.photo.search_instance.search_location\n                    and settings[\"add_location\"]\n                ):\n                    place = f\" This photo was taken at {self.photo.search_instance.search_location}.\"\n\n                keywords_instruction = \"\"\n                if settings[\"add_keywords\"]:\n                    keywords_instruction = \" Include relevant tags and keywords.\"\n\n                prompt = (\n                    \"Write a short, natural image caption.\"\n                    + person_name\n                    + place\n                    + keywords_instruction\n                )\n\n            util.logger.info(f\"Moondream prompt: {prompt}\")\n\n            # Generate caption with the final prompt\n            caption = generate_prompt(image_path=image_path, prompt=prompt)\n            caption = caption.replace(\"<start>\", \"\").replace(\"<end>\", \"\").strip()\n\n            # Save the result\n            captions[\"im2txt\"] = caption\n            self.captions_json = captions\n            self.recreate_search_captions()\n            if commit:\n                self.save()\n\n            util.logger.info(\n                f\"Generated Moondream captions for image {image_path}, caption: {caption}\"\n            )\n            return True\n        except Exception:\n            util.logger.exception(\n                f\"Could not generate Moondream captions for image {image_path}\"\n            )\n            return False\n\n    def save_user_caption(self, caption, commit=True):\n        \"\"\"Save user-provided caption\"\"\"\n        if not self.photo.thumbnail or not self.photo.thumbnail.thumbnail_big:\n            util.logger.warning(\n                f\"No thumbnail available for photo {self.photo.image_hash}\"\n            )\n            return False\n\n        try:\n            image_path = self.photo.thumbnail.thumbnail_big.path\n        except Exception:\n            util.logger.warning(\n                f\"Cannot access thumbnail path for photo {self.photo.image_hash}\"\n            )\n            return False\n\n        try:\n            caption = caption.replace(\"<start>\", \"\").replace(\"<end>\", \"\").strip()\n\n            if self.captions_json is None:\n                self.captions_json = {}\n            self.captions_json[\"user_caption\"] = caption\n            self.recreate_search_captions()\n\n            if commit:\n                self.save()\n\n            util.logger.info(\n                f\"saved captions for image {image_path}. caption: {caption}. captions_json: {self.captions_json}.\"\n            )\n\n            # Handle hashtags\n            hashtags = [\n                word\n                for word in caption.split()\n                if word.startswith(\"#\") and len(word) > 1\n            ]\n\n            for hashtag in hashtags:\n                album_thing = api.models.album_thing.get_album_thing(\n                    title=hashtag,\n                    owner=self.photo.owner,\n                    thing_type=\"hashtag_attribute\",\n                )\n                if (\n                    album_thing.photos.filter(image_hash=self.photo.image_hash).count()\n                    == 0\n                ):\n                    album_thing.photos.add(self.photo)\n                    album_thing.save()\n\n            for album_thing in api.models.album_thing.AlbumThing.objects.filter(\n                Q(photos__in=[self.photo])\n                & Q(thing_type=\"hashtag_attribute\")\n                & Q(owner=self.photo.owner)\n            ).all():\n                if album_thing.title not in caption:\n                    album_thing.photos.remove(self.photo)\n                    album_thing.save()\n            return True\n        except Exception:\n            util.logger.exception(f\"could not save captions for image {image_path}\")\n            return False\n\n    def recreate_search_captions(self):\n        \"\"\"Recreate search captions - directly access PhotoSearch model\"\"\"\n        from api.models.photo_search import PhotoSearch\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n        search_instance.recreate_search_captions()\n        search_instance.save()\n\n    def generate_tag_captions(self, commit=True):\n        \"\"\"Generate tag captions using the active tagging model (Places365 or SigLIP 2).\n\n        Tags are stored per-model in captions_json and are never deleted when\n        switching models -- only the active model's tags are generated / visible.\n        \"\"\"\n        from constance import config as site_config\n\n        tagging_model = site_config.TAGGING_MODEL\n\n        if not self.photo.thumbnail or not self.photo.thumbnail.thumbnail_big:\n            return\n\n        # Skip if this photo already has tags from the active model\n        if (\n            self.captions_json is not None\n            and self.captions_json.get(tagging_model) is not None\n        ):\n            return\n\n        try:\n            import requests\n\n            image_path = self.photo.thumbnail.thumbnail_big.path\n            confidence = self.photo.owner.confidence\n            json_data = {\n                \"image_path\": image_path,\n                \"confidence\": confidence,\n                \"tagging_model\": tagging_model,\n            }\n            response = requests.post(\n                \"http://localhost:8011/generate-tags\", json=json_data\n            )\n            tags_result = response.json()[\"tags\"]\n\n            if tags_result is None:\n                return\n            if self.captions_json is None:\n                self.captions_json = {}\n\n            # Store under the model-specific key\n            self.captions_json[tagging_model] = tags_result\n            self.recreate_search_captions()\n\n            if tagging_model == \"siglip2\":\n                self._update_siglip2_album_things(tags_result)\n            else:\n                self._update_places365_album_things(tags_result)\n\n            if commit:\n                self.save()\n            util.logger.info(\n                f\"generated {tagging_model} tags for image {image_path}.\"\n            )\n        except Exception as e:\n            util.logger.exception(\n                f\"could not generate tags for image \"\n                f\"{self.photo.main_file.path if self.photo.main_file else 'no main file'}\"\n            )\n            raise e\n\n    def _update_places365_album_things(self, res_places365):\n        \"\"\"Create/update AlbumThing entries for Places365 tags.\"\"\"\n        # Remove old album associations for this photo\n        for album_thing in api.models.album_thing.AlbumThing.objects.filter(\n            Q(photos__in=[self.photo])\n            & (\n                Q(thing_type=\"places365_attribute\")\n                | Q(thing_type=\"places365_category\")\n            )\n            & Q(owner=self.photo.owner)\n        ).all():\n            album_thing.photos.remove(self.photo)\n            album_thing.save()\n\n        if \"attributes\" in res_places365:\n            for attribute in res_places365[\"attributes\"]:\n                album_thing = api.models.album_thing.get_album_thing(\n                    title=attribute,\n                    owner=self.photo.owner,\n                    thing_type=\"places365_attribute\",\n                )\n                album_thing.photos.add(self.photo)\n                album_thing.save()\n\n        if \"categories\" in res_places365:\n            for category in res_places365[\"categories\"]:\n                album_thing = api.models.album_thing.get_album_thing(\n                    title=category,\n                    owner=self.photo.owner,\n                    thing_type=\"places365_category\",\n                )\n                album_thing.photos.add(self.photo)\n                album_thing.save()\n\n    def _update_siglip2_album_things(self, siglip2_result):\n        \"\"\"Create/update AlbumThing entries for SigLIP 2 tags.\"\"\"\n        tags = siglip2_result.get(\"tags\", [])\n\n        # Remove old siglip2 album associations for this photo\n        for album_thing in api.models.album_thing.AlbumThing.objects.filter(\n            Q(photos__in=[self.photo])\n            & Q(thing_type=\"siglip2_tag\")\n            & Q(owner=self.photo.owner)\n        ).all():\n            album_thing.photos.remove(self.photo)\n            album_thing.save()\n\n        for tag in tags:\n            album_thing = api.models.album_thing.get_album_thing(\n                title=tag,\n                owner=self.photo.owner,\n                thing_type=\"siglip2_tag\",\n            )\n            album_thing.photos.add(self.photo)\n            album_thing.save()\n\n    # Backward-compatible alias\n    def generate_places365_captions(self, commit=True):\n        return self.generate_tag_captions(commit=commit)\n"
  },
  {
    "path": "api/models/photo_metadata.py",
    "content": "\"\"\"\nPhotoMetadata and MetadataFile models for structured metadata handling.\n\nThis module provides proper models for:\n1. PhotoMetadata - Structured EXIF/metadata extracted from photos\n2. MetadataFile - XMP sidecars and other metadata files\n\nBenefits over current approach:\n- Typed fields instead of JSON blob (exif_json)\n- Proper foreign key relationships\n- Versioning for metadata changes\n- Support for multiple metadata sources (embedded, sidecar, user-edited)\n- Clear separation between camera metadata and derived metadata\n\"\"\"\n\nimport numbers\nimport uuid\nfrom fractions import Fraction\n\nfrom django.db import models\n\nfrom api.metadata.reader import get_metadata\nfrom api.metadata.tags import Tags\nfrom api.models.user import User, get_deleted_user\n\n\nclass MetadataFile(models.Model):\n    \"\"\"\n    Represents a metadata sidecar file (XMP, JSON, etc.) associated with a photo.\n\n    A photo can have multiple metadata files:\n    - XMP sidecar from camera/software\n    - Adobe Lightroom .xmp\n    - Darktable .xmp\n    - User-created metadata file\n    \"\"\"\n\n    class FileType(models.TextChoices):\n        XMP = \"xmp\", \"XMP Sidecar\"\n        JSON = \"json\", \"JSON Metadata\"\n        EXIF = \"exif\", \"EXIF Extract\"\n        OTHER = \"other\", \"Other\"\n\n    class Source(models.TextChoices):\n        # Sidecar that came with the photo (same directory)\n        ORIGINAL = \"original\", \"Original Sidecar\"\n        # Generated by photo editing software\n        SOFTWARE = \"software\", \"Software Generated\"\n        # Created by LibrePhotos\n        LIBREPHOTOS = \"librephotos\", \"LibrePhotos Generated\"\n        # User manually created/uploaded\n        USER = \"user\", \"User Created\"\n\n    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)\n\n    # Link to the File model for actual file storage\n    file = models.OneToOneField(\n        \"File\",\n        on_delete=models.CASCADE,\n        related_name=\"metadata_info\",\n    )\n\n    # The photo this metadata belongs to\n    photo = models.ForeignKey(\n        \"Photo\",\n        on_delete=models.CASCADE,\n        related_name=\"metadata_files\",\n    )\n\n    file_type = models.CharField(\n        max_length=10,\n        choices=FileType.choices,\n        default=FileType.XMP,\n    )\n\n    source = models.CharField(\n        max_length=20,\n        choices=Source.choices,\n        default=Source.ORIGINAL,\n    )\n\n    # Priority for metadata resolution (higher = more authoritative)\n    # User edits > Software sidecars > Original sidecars > Embedded\n    priority = models.IntegerField(default=0)\n\n    # Software that created this file (if known)\n    creator_software = models.CharField(max_length=100, blank=True, null=True)\n\n    created_at = models.DateTimeField(auto_now_add=True)\n    updated_at = models.DateTimeField(auto_now=True)\n\n    class Meta:\n        ordering = [\"-priority\", \"-updated_at\"]\n        verbose_name = \"Metadata File\"\n        verbose_name_plural = \"Metadata Files\"\n\n    def __str__(self):\n        return f\"{self.file_type} sidecar for {self.photo_id}\"\n\n\nclass PhotoMetadata(models.Model):\n    \"\"\"\n    Structured metadata for a photo.\n\n    This model stores extracted and computed metadata in typed fields,\n    making it queryable and more maintainable than a JSON blob.\n\n    Metadata Sources (in priority order):\n    1. User edits (highest priority)\n    2. XMP sidecar files\n    3. Embedded EXIF/IPTC/XMP\n    4. Computed/derived values (lowest priority)\n    \"\"\"\n\n    class Source(models.TextChoices):\n        # Embedded in the file (EXIF, IPTC, embedded XMP)\n        EMBEDDED = \"embedded\", \"Embedded in File\"\n        # From XMP sidecar file\n        SIDECAR = \"sidecar\", \"XMP Sidecar\"\n        # User manually edited\n        USER_EDIT = \"user_edit\", \"User Edit\"\n        # Computed by LibrePhotos (AI, geocoding, etc.)\n        COMPUTED = \"computed\", \"Computed\"\n\n    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)\n\n    photo = models.OneToOneField(\n        \"Photo\",\n        on_delete=models.CASCADE,\n        related_name=\"metadata\",\n    )\n\n    # ==================== Camera Settings ====================\n    # Aperture (f-stop), e.g., 2.8\n    aperture = models.FloatField(null=True, blank=True, db_index=True)\n\n    # Shutter speed as a string fraction, e.g., \"1/250\"\n    shutter_speed = models.CharField(max_length=20, null=True, blank=True)\n\n    # Shutter speed in seconds (for queries), e.g., 0.004\n    shutter_speed_seconds = models.FloatField(null=True, blank=True)\n\n    # ISO sensitivity\n    iso = models.IntegerField(null=True, blank=True, db_index=True)\n\n    # Focal length in mm\n    focal_length = models.FloatField(null=True, blank=True)\n\n    # 35mm equivalent focal length\n    focal_length_35mm = models.IntegerField(null=True, blank=True)\n\n    # Exposure compensation in EV\n    exposure_compensation = models.FloatField(null=True, blank=True)\n\n    # Flash fired\n    flash_fired = models.BooleanField(null=True, blank=True)\n\n    # Metering mode\n    metering_mode = models.CharField(max_length=50, null=True, blank=True)\n\n    # White balance\n    white_balance = models.CharField(max_length=50, null=True, blank=True)\n\n    # ==================== Camera/Lens Info ====================\n    camera_make = models.CharField(max_length=100, null=True, blank=True, db_index=True)\n    camera_model = models.CharField(\n        max_length=100, null=True, blank=True, db_index=True\n    )\n    lens_make = models.CharField(max_length=100, null=True, blank=True)\n    lens_model = models.CharField(max_length=100, null=True, blank=True, db_index=True)\n    serial_number = models.CharField(max_length=100, null=True, blank=True)\n\n    # ==================== Image Properties ====================\n    width = models.IntegerField(null=True, blank=True)\n    height = models.IntegerField(null=True, blank=True)\n    orientation = models.IntegerField(null=True, blank=True)\n    color_space = models.CharField(max_length=50, null=True, blank=True)\n    bit_depth = models.IntegerField(null=True, blank=True)\n\n    # ==================== Timestamps ====================\n    # Original capture time from EXIF\n    date_taken = models.DateTimeField(null=True, blank=True, db_index=True)\n\n    # Sub-second precision (from EXIF:SubSecTimeOriginal)\n    date_taken_subsec = models.CharField(max_length=10, null=True, blank=True)\n\n    # When the file was last modified\n    date_modified = models.DateTimeField(null=True, blank=True)\n\n    # Timezone offset if available\n    timezone_offset = models.CharField(max_length=10, null=True, blank=True)\n\n    # ==================== Location ====================\n    gps_latitude = models.FloatField(null=True, blank=True, db_index=True)\n    gps_longitude = models.FloatField(null=True, blank=True, db_index=True)\n    gps_altitude = models.FloatField(null=True, blank=True)\n\n    # Location from reverse geocoding\n    location_country = models.CharField(\n        max_length=100, null=True, blank=True, db_index=True\n    )\n    location_state = models.CharField(max_length=100, null=True, blank=True)\n    location_city = models.CharField(\n        max_length=100, null=True, blank=True, db_index=True\n    )\n    location_address = models.TextField(null=True, blank=True)\n\n    # ==================== Content Description ====================\n    title = models.CharField(max_length=500, null=True, blank=True)\n    caption = models.TextField(null=True, blank=True)\n    keywords = models.JSONField(null=True, blank=True)  # List of strings\n\n    # Rating (0-5 stars)\n    rating = models.IntegerField(null=True, blank=True, db_index=True)\n\n    # Copyright info\n    copyright = models.TextField(null=True, blank=True)\n    creator = models.CharField(max_length=200, null=True, blank=True)\n\n    # ==================== Tracking ====================\n    # Which source this metadata came from\n    source = models.CharField(\n        max_length=20,\n        choices=Source.choices,\n        default=Source.EMBEDDED,\n    )\n\n    # Raw EXIF/XMP data for fields we don't have explicit columns for\n    raw_exif = models.JSONField(null=True, blank=True)\n    raw_xmp = models.JSONField(null=True, blank=True)\n    raw_iptc = models.JSONField(null=True, blank=True)\n\n    # Version tracking\n    version = models.IntegerField(default=1)\n    created_at = models.DateTimeField(auto_now_add=True)\n    updated_at = models.DateTimeField(auto_now=True)\n\n    class Meta:\n        verbose_name = \"Photo Metadata\"\n        verbose_name_plural = \"Photo Metadata\"\n        indexes = [\n            models.Index(fields=[\"camera_make\", \"camera_model\"]),\n            models.Index(fields=[\"date_taken\"]),\n            models.Index(fields=[\"location_country\", \"location_city\"]),\n        ]\n\n    def __str__(self):\n        return f\"Metadata for {self.photo_id}\"\n\n    @property\n    def resolution(self):\n        \"\"\"Returns resolution as 'WxH' string.\"\"\"\n        if self.width and self.height:\n            return f\"{self.width}x{self.height}\"\n        return None\n\n    @property\n    def megapixels(self):\n        \"\"\"Returns megapixel count.\"\"\"\n        if self.width and self.height:\n            return round((self.width * self.height) / 1_000_000, 1)\n        return None\n\n    @property\n    def has_location(self):\n        \"\"\"Check if GPS coordinates are available.\"\"\"\n        return self.gps_latitude is not None and self.gps_longitude is not None\n\n    @property\n    def camera_display(self):\n        \"\"\"Returns a human-readable camera name.\"\"\"\n        if self.camera_make and self.camera_model:\n            # Avoid duplicating make in model\n            if self.camera_model.startswith(self.camera_make):\n                return self.camera_model\n            return f\"{self.camera_make} {self.camera_model}\"\n        return self.camera_model or self.camera_make\n\n    @property\n    def lens_display(self):\n        \"\"\"Returns a human-readable lens name.\"\"\"\n        if self.lens_make and self.lens_model:\n            if self.lens_model.startswith(self.lens_make):\n                return self.lens_model\n            return f\"{self.lens_make} {self.lens_model}\"\n        return self.lens_model or self.lens_make\n\n    @classmethod\n    def extract_exif_data(cls, photo, commit=True):\n        \"\"\"\n        Extract EXIF data from a photo's main file and update PhotoMetadata.\n\n        This method extracts metadata from the photo's main file and:\n        1. Updates Photo fields (size, video_length, rating, exif_timestamp_subsec, image_sequence_number)\n        2. Gets or creates PhotoMetadata and populates it with extracted data\n\n        Args:\n            photo: Photo instance to extract metadata from\n            commit: Whether to save Photo and PhotoMetadata after extraction\n\n        Returns:\n            PhotoMetadata instance\n        \"\"\"\n        if not photo.main_file:\n            return None\n\n        (\n            size,\n            fstop,\n            focal_length,\n            iso,\n            shutter_speed,\n            camera,\n            lens,\n            width,\n            height,\n            focalLength35Equivalent,\n            subjectDistance,\n            digitalZoomRatio,\n            video_length,\n            rating,\n            subsec_time_original,\n            image_number,\n        ) = get_metadata(  # noqa: E501\n            photo.main_file.path,\n            tags=[\n                Tags.FILE_SIZE,\n                Tags.FSTOP,\n                Tags.FOCAL_LENGTH,\n                Tags.ISO,\n                Tags.EXPOSURE_TIME,\n                Tags.CAMERA,\n                Tags.LENS,\n                Tags.IMAGE_WIDTH,\n                Tags.IMAGE_HEIGHT,\n                Tags.FOCAL_LENGTH_35MM,\n                Tags.SUBJECT_DISTANCE,\n                Tags.DIGITAL_ZOOM_RATIO,\n                Tags.QUICKTIME_DURATION,\n                Tags.RATING,\n                Tags.SUBSEC_TIME_ORIGINAL,\n                Tags.IMAGE_NUMBER,\n            ],\n            try_sidecar=True,\n        )\n\n        # Fields still on Photo model\n        if size and isinstance(size, numbers.Number):\n            photo.size = size\n        if video_length and isinstance(video_length, numbers.Number):\n            photo.video_length = video_length\n        if rating and isinstance(rating, numbers.Number):\n            photo.rating = rating\n\n        # Burst/sequence detection fields\n        if subsec_time_original:\n            # SubSecTimeOriginal is typically a string like \"123\" representing milliseconds\n            photo.exif_timestamp_subsec = str(subsec_time_original)[:10]\n        if image_number and isinstance(image_number, numbers.Number):\n            photo.image_sequence_number = int(image_number)\n\n        if commit:\n            photo.save()\n\n        # Store metadata in PhotoMetadata model\n        metadata, created = cls.objects.get_or_create(\n            photo=photo, defaults={\"source\": cls.Source.EMBEDDED}\n        )\n\n        if fstop and isinstance(fstop, numbers.Number):\n            metadata.aperture = fstop\n        if focal_length and isinstance(focal_length, numbers.Number):\n            metadata.focal_length = focal_length\n        if iso and isinstance(iso, numbers.Number):\n            metadata.iso = iso\n        if shutter_speed and isinstance(shutter_speed, numbers.Number):\n            metadata.shutter_speed = str(\n                Fraction(shutter_speed).limit_denominator(1000)\n            )\n        if camera and isinstance(camera, str):\n            metadata.camera_model = camera\n        if lens and isinstance(lens, str):\n            metadata.lens_model = lens\n        if width and isinstance(width, numbers.Number):\n            metadata.width = width\n        if height and isinstance(height, numbers.Number):\n            metadata.height = height\n        if focalLength35Equivalent and isinstance(\n            focalLength35Equivalent, numbers.Number\n        ):\n            metadata.focal_length_35mm = focalLength35Equivalent\n        if rating and isinstance(rating, numbers.Number):\n            metadata.rating = rating\n        if subsec_time_original:\n            metadata.date_taken_subsec = str(subsec_time_original)[:10]\n\n        if commit:\n            metadata.save()\n\n        return metadata\n\n\nclass MetadataEdit(models.Model):\n    \"\"\"\n    Tracks user edits to photo metadata for history/undo functionality.\n\n    Each edit records what field was changed, old value, new value,\n    and who made the change.\n    \"\"\"\n\n    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)\n\n    photo = models.ForeignKey(\n        \"Photo\",\n        on_delete=models.CASCADE,\n        related_name=\"metadata_edits\",\n    )\n\n    user = models.ForeignKey(\n        User,\n        on_delete=models.SET(get_deleted_user),\n        related_name=\"metadata_edits\",\n    )\n\n    # Which field was edited\n    field_name = models.CharField(max_length=100)\n\n    # JSON-serialized old and new values\n    old_value = models.JSONField(null=True, blank=True)\n    new_value = models.JSONField(null=True, blank=True)\n\n    # Whether this edit has been written back to the file\n    synced_to_file = models.BooleanField(default=False)\n    synced_at = models.DateTimeField(null=True, blank=True)\n\n    created_at = models.DateTimeField(auto_now_add=True)\n\n    class Meta:\n        ordering = [\"-created_at\"]\n        verbose_name = \"Metadata Edit\"\n        verbose_name_plural = \"Metadata Edits\"\n        indexes = [\n            models.Index(fields=[\"photo\", \"-created_at\"]),\n        ]\n\n    def __str__(self):\n        return f\"Edit {self.field_name} on {self.photo_id}\"\n"
  },
  {
    "path": "api/models/photo_search.py",
    "content": "from django.db import models\n\nimport api.models\nfrom api import util\n\n\nclass PhotoSearch(models.Model):\n    \"\"\"Model for handling photo search functionality\"\"\"\n\n    photo = models.OneToOneField(\n        \"Photo\",\n        on_delete=models.CASCADE,\n        related_name=\"search_instance\",\n        primary_key=True,\n    )\n    search_captions = models.TextField(blank=True, null=True, db_index=True)\n    search_location = models.TextField(blank=True, null=True, db_index=True)\n\n    created_at = models.DateTimeField(auto_now_add=True)\n    updated_at = models.DateTimeField(auto_now=True)\n\n    class Meta:\n        db_table = \"api_photo_search\"\n\n    def __str__(self):\n        return f\"Search data for {self.photo.image_hash}\"\n\n    def recreate_search_captions(self):\n        \"\"\"Recreate search captions from all caption sources.\n\n        Only tags from the active TAGGING_MODEL are indexed into search_captions.\n        This allows instant switching of tag visibility without re-inference.\n        \"\"\"\n        from constance import config as site_config\n\n        search_captions = \"\"\n\n        # Get captions from the PhotoCaption model\n        if hasattr(self.photo, \"caption_instance\") and self.photo.caption_instance:\n            captions_json = self.photo.caption_instance.captions_json\n            if captions_json:\n                # Index tags from the active tagging model only\n                tagging_model = site_config.TAGGING_MODEL\n\n                if tagging_model == \"siglip2\":\n                    siglip2_data = captions_json.get(\"siglip2\", {})\n                    siglip2_tags = siglip2_data.get(\"tags\", [])\n                    if siglip2_tags:\n                        search_captions += \" \".join(siglip2_tags) + \" \"\n                else:\n                    places365_captions = captions_json.get(\"places365\", {})\n                    attributes = places365_captions.get(\"attributes\", [])\n                    search_captions += \" \".join(attributes) + \" \"\n                    categories = places365_captions.get(\"categories\", [])\n                    search_captions += \" \".join(categories) + \" \"\n                    environment = places365_captions.get(\"environment\", \"\")\n                    search_captions += environment + \" \"\n\n                user_caption = captions_json.get(\"user_caption\", \"\")\n                if user_caption:\n                    search_captions += user_caption + \" \"\n\n                im2txt_caption = captions_json.get(\"im2txt\", \"\")\n                if im2txt_caption:\n                    search_captions += im2txt_caption + \" \"\n\n        # Add face/person names\n        for face in api.models.face.Face.objects.filter(photo=self.photo).all():\n            if face.person:\n                search_captions += face.person.name + \" \"\n\n        # Add file paths\n        if self.photo.main_file:\n            search_captions += self.photo.main_file.path + \" \"\n        for file in self.photo.files.all():\n            search_captions += file.path + \" \"\n\n        # Add media type\n        if self.photo.video:\n            search_captions += \"type: video \"\n\n        # Add camera and lens info from PhotoMetadata\n        try:\n            metadata = self.photo.metadata\n            if metadata.camera_display:\n                search_captions += metadata.camera_display + \" \"\n            if metadata.lens_display:\n                search_captions += metadata.lens_display + \" \"\n        except Exception:\n            # PhotoMetadata may not exist yet\n            pass\n\n        self.search_captions = search_captions.strip()\n\n        util.logger.debug(\n            f\"Recreated search captions for image {self.photo.image_hash}.\"\n        )\n\n    def update_search_location(self, geolocation_json):\n        \"\"\"Update search location from geolocation data\"\"\"\n        if geolocation_json and \"address\" in geolocation_json:\n            self.search_location = geolocation_json[\"address\"]\n        elif geolocation_json and \"features\" in geolocation_json:\n            # Handle features format used in tests\n            features = geolocation_json[\"features\"]\n            location_parts = [\n                feature.get(\"text\", \"\") for feature in features if feature.get(\"text\")\n            ]\n            self.search_location = \", \".join(location_parts) if location_parts else \"\"\n        else:\n            self.search_location = \"\"\n\n        util.logger.debug(\n            f\"Updated search location for image {self.photo.image_hash}: {self.search_location}\"\n        )\n"
  },
  {
    "path": "api/models/photo_stack.py",
    "content": "\"\"\"\nPhotoStack model for organizational photo grouping.\n\nStacks represent related photos that should be kept together for organization:\n- Burst sequences (rapid succession shots)\n- Exposure brackets (HDR sequences)\n- Manual user groupings\n\nNOTE: RAW+JPEG pairs and Live Photos are NO LONGER handled as stacks.\nInstead, they use the Photo.files ManyToMany field to store multiple file\nvariants of the same capture (PhotoPrism-like model). This allows:\n- A single Photo entity for RAW+JPEG (same capture, different formats)\n- A single Photo entity for Live Photos (image + video variant)\n- Photo stacks for DIFFERENT captures that are logically related\n\nLegacy RAW_JPEG_PAIR and LIVE_PHOTO stack types are kept for migration\ncompatibility but are deprecated and will be converted to file variants.\n\nInspired by PhotoPrism's file variant model and Immich's stacking system.\n\"\"\"\n\nimport uuid\n\nfrom django.db import models\n\nfrom api.models.user import User, get_deleted_user\n\n\nclass PhotoStack(models.Model):\n    \"\"\"\n    Represents a group of related but DISTINCT photos that should be kept together.\n    Only the primary photo is shown in the timeline, with others accessible via expansion.\n    \n    NOTE: This is for grouping DIFFERENT captures (bursts, brackets, manual).\n    For same-capture file variants (RAW+JPEG, Live Photos), use Photo.files instead.\n    \n    Stacks are informational groupings - they help organize related photos\n    but don't imply that any should be deleted (unlike Duplicates).\n    \"\"\"\n\n    class StackType(models.TextChoices):\n        # === ACTIVE STACK TYPES (for different captures) ===\n        \n        # Photos taken in rapid succession (burst/continuous mode)\n        # User may want to browse all or pick the best\n        BURST_SEQUENCE = \"burst\", \"Burst Sequence\"\n        # Exposure bracketed shots (for HDR)\n        # HDR processing may need all exposures\n        EXPOSURE_BRACKET = \"bracket\", \"Exposure Bracket\"\n        # User manually grouped photos\n        # User explicitly created the grouping\n        MANUAL = \"manual\", \"Manual Stack\"\n        \n        # === DEPRECATED STACK TYPES (migrated to Photo.files) ===\n        # Kept for backwards compatibility during migration\n        \n        # DEPRECATED: Use Photo.files for RAW+JPEG variants\n        RAW_JPEG_PAIR = \"raw_jpeg\", \"RAW + JPEG Pair (Deprecated)\"\n        # DEPRECATED: Use Photo.files for Live Photo variants\n        LIVE_PHOTO = \"live_photo\", \"Live Photo (Deprecated)\"\n\n    # Valid stack types for new stacks (excludes deprecated types)\n    VALID_STACK_TYPES = [\n        StackType.BURST_SEQUENCE,\n        StackType.EXPOSURE_BRACKET,\n        StackType.MANUAL,\n    ]\n\n    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)\n\n    owner = models.ForeignKey(\n        User,\n        on_delete=models.SET(get_deleted_user),\n        related_name=\"photo_stacks\",\n    )\n\n    stack_type = models.CharField(\n        max_length=20,\n        choices=StackType.choices,\n        default=StackType.MANUAL,\n        db_index=True,\n    )\n\n    # The photo shown in the timeline (cover photo)\n    primary_photo = models.ForeignKey(\n        \"Photo\",\n        on_delete=models.SET_NULL,\n        null=True,\n        blank=True,\n        related_name=\"primary_in_stack\",\n    )\n\n    # Detection metadata\n    created_at = models.DateTimeField(auto_now_add=True)\n    updated_at = models.DateTimeField(auto_now=True)\n\n    # For bursts: time span of the sequence\n    sequence_start = models.DateTimeField(null=True, blank=True)\n    sequence_end = models.DateTimeField(null=True, blank=True)\n\n    class Meta:\n        ordering = [\"-created_at\"]\n        verbose_name = \"Photo Stack\"\n        verbose_name_plural = \"Photo Stacks\"\n        indexes = [\n            models.Index(fields=[\"owner\", \"stack_type\"]),\n        ]\n\n    def __str__(self):\n        return f\"PhotoStack {self.id} - {self.stack_type} - {self.owner.username}\"\n\n    @property\n    def photo_count(self):\n        \"\"\"Number of photos in this stack.\"\"\"\n        return self.photos.count()\n\n    def get_photos_ordered_by_quality(self):\n        \"\"\"\n        Returns photos in the stack ordered by quality metrics.\n        Higher resolution and larger file size are considered better quality.\n        \"\"\"\n        return self.photos.order_by(\"-metadata__width\", \"-metadata__height\", \"-size\")\n\n    def auto_select_primary(self):\n        \"\"\"\n        Automatically selects the best photo as primary based on stack type.\n\n        For BURST_SEQUENCE: Middle of sequence by timestamp\n        For EXPOSURE_BRACKET: Middle exposure\n        For MANUAL: Highest resolution\n        \n        For deprecated types (RAW_JPEG_PAIR, LIVE_PHOTO):\n        These should be migrated to Photo.files, but for compatibility:\n        - RAW_JPEG_PAIR: Prefer JPEG (non-RAW)\n        - LIVE_PHOTO: Prefer still image (non-video)\n        \"\"\"\n        photos = self.photos.all()\n        if not photos.exists():\n            return None\n\n        if self.stack_type == self.StackType.BURST_SEQUENCE:\n            # Pick middle of sequence by timestamp\n            ordered = photos.order_by(\"exif_timestamp\")\n            count = ordered.count()\n            best = ordered[count // 2] if count > 0 else None\n        elif self.stack_type == self.StackType.EXPOSURE_BRACKET:\n            # Pick middle exposure (usually the \"correct\" exposure)\n            ordered = photos.order_by(\"exif_timestamp\")\n            count = ordered.count()\n            best = ordered[count // 2] if count > 0 else None\n        elif self.stack_type == self.StackType.RAW_JPEG_PAIR:\n            # DEPRECATED: Prefer JPEG for display (RAW files have type=4)\n            jpeg_photos = photos.exclude(main_file__type=4)\n            best = jpeg_photos.first() if jpeg_photos.exists() else photos.first()\n        elif self.stack_type == self.StackType.LIVE_PHOTO:\n            # DEPRECATED: Prefer still image over video (VIDEO = 2)\n            still_photos = photos.exclude(main_file__type=2)\n            best = still_photos.first() if still_photos.exists() else photos.first()\n        else:\n            # MANUAL and default: highest resolution\n            # Use metadata__width and metadata__height since these fields moved to PhotoMetadata\n            best = photos.order_by(\n                models.F(\"metadata__width\") * models.F(\"metadata__height\")\n            ).last()\n\n        if best:\n            self.primary_photo = best\n            self.save(update_fields=[\"primary_photo\", \"updated_at\"])\n\n        return best\n\n    def merge_with(self, other_stack: \"PhotoStack\"):\n        \"\"\"\n        Merge another stack into this one.\n        All photos from the other stack are moved to this stack,\n        and the other stack is deleted.\n        \"\"\"\n        if other_stack.pk == self.pk:\n            return\n\n        # Move all photos from other stack to this one (ManyToMany)\n        # Convert to list first to avoid modifying queryset while iterating\n        photos_to_move = list(other_stack.photos.all())\n        for photo in photos_to_move:\n            photo.stacks.add(self)\n            photo.stacks.remove(other_stack)\n\n        # Recalculate primary if needed\n        if not self.primary_photo:\n            self.auto_select_primary()\n\n        # Delete the now-empty stack\n        other_stack.delete()\n\n    @classmethod\n    def create_or_merge(cls, owner, stack_type, photos, sequence_start=None, sequence_end=None):\n        \"\"\"\n        Create a new stack or merge into existing if any photo is already stacked.\n\n        Args:\n            owner: User who owns the photos\n            stack_type: Type of stack to create\n            photos: Queryset or list of Photo objects to group\n            sequence_start: Optional start timestamp for burst/bracket sequences\n            sequence_end: Optional end timestamp for burst/bracket sequences\n\n        Returns:\n            The PhotoStack instance (new or existing)\n        \"\"\"\n        photo_list = list(photos)\n        if len(photo_list) < 2:\n            return None\n\n        # Check if any photo is already in a stack of the same type\n        existing_stacks = cls.objects.filter(\n            photos__in=photo_list,\n            stack_type=stack_type,\n            owner=owner,\n        ).distinct()\n\n        if existing_stacks.exists():\n            # Merge all into the first existing stack\n            target_stack = existing_stacks.first()\n            for stack in existing_stacks[1:]:\n                target_stack.merge_with(stack)\n\n            # Add any new photos to the stack (ManyToMany)\n            for photo in photo_list:\n                if not photo.stacks.filter(pk=target_stack.pk).exists():\n                    photo.stacks.add(target_stack)\n\n            # Update sequence timestamps if provided and this is a burst/bracket stack\n            if sequence_start is not None and sequence_end is not None:\n                if target_stack.sequence_start is None or target_stack.sequence_start > sequence_start:\n                    target_stack.sequence_start = sequence_start\n                if target_stack.sequence_end is None or target_stack.sequence_end < sequence_end:\n                    target_stack.sequence_end = sequence_end\n                target_stack.save(update_fields=['sequence_start', 'sequence_end', 'updated_at'])\n\n            target_stack.auto_select_primary()\n            return target_stack\n        else:\n            # Create new stack\n            stack = cls.objects.create(\n                owner=owner,\n                stack_type=stack_type,\n                sequence_start=sequence_start,\n                sequence_end=sequence_end,\n            )\n\n            # Associate photos (ManyToMany - add each photo to the stack)\n            for photo in photo_list:\n                photo.stacks.add(stack)\n\n            stack.auto_select_primary()\n            return stack\n"
  },
  {
    "path": "api/models/stack_review.py",
    "content": "\"\"\"\nStackReview model for tracking user review decisions on reviewable stacks.\n\nThis model is separate from PhotoStack because:\n1. Only certain stack types need review (exact_copy, visual_duplicate)\n2. Other stack types (raw_jpeg, burst, bracket, live_photo) are informational\n3. Manual stacks are always \"reviewed\" by definition (user created them)\n\nThis separation allows:\n- Clean data model with clear semantics\n- Different workflows for different stack types\n- Historical tracking of review decisions\n\"\"\"\n\nimport uuid\n\nfrom django.db import models\n\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.user import User, get_deleted_user\n\n\nclass StackReview(models.Model):\n    \"\"\"\n    Records a user's review decision for a reviewable stack.\n    \n    Reviewable stack types:\n    - exact_copy: User decides which copy to keep\n    - visual_duplicate: User decides if photos are truly duplicates\n    \n    Non-reviewable stack types (informational only):\n    - raw_jpeg: Both RAW and JPEG are kept (different purposes)\n    - burst: User may want to browse all burst photos\n    - bracket: HDR processing may need all exposures\n    - live_photo: Photo and video are intrinsically linked\n    - manual: User explicitly created the grouping\n    \"\"\"\n\n    class Decision(models.TextChoices):\n        # User hasn't reviewed yet\n        PENDING = \"pending\", \"Pending Review\"\n        # User selected a primary and trashed others\n        RESOLVED = \"resolved\", \"Resolved\"\n        # User marked as \"not actually duplicates\"\n        DISMISSED = \"dismissed\", \"Dismissed\"\n\n    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)\n\n    stack = models.OneToOneField(\n        PhotoStack,\n        on_delete=models.CASCADE,\n        related_name=\"review\",\n    )\n\n    reviewer = models.ForeignKey(\n        User,\n        on_delete=models.SET(get_deleted_user),\n        related_name=\"stack_reviews\",\n    )\n\n    decision = models.CharField(\n        max_length=20,\n        choices=Decision.choices,\n        default=Decision.PENDING,\n        db_index=True,\n    )\n\n    # The photo the user chose to keep (only set when decision=RESOLVED)\n    kept_photo = models.ForeignKey(\n        \"Photo\",\n        on_delete=models.SET_NULL,\n        null=True,\n        blank=True,\n        related_name=\"kept_in_reviews\",\n    )\n\n    # Number of photos trashed when resolved\n    trashed_count = models.IntegerField(default=0)\n\n    # Timestamps\n    created_at = models.DateTimeField(auto_now_add=True)\n    reviewed_at = models.DateTimeField(null=True, blank=True)\n\n    # Optional note from user\n    note = models.TextField(blank=True, null=True)\n\n    class Meta:\n        ordering = [\"-created_at\"]\n        verbose_name = \"Stack Review\"\n        verbose_name_plural = \"Stack Reviews\"\n        indexes = [\n            models.Index(fields=[\"reviewer\", \"decision\"]),\n        ]\n\n    def __str__(self):\n        return f\"Review for {self.stack.id} - {self.decision}\"\n\n    @classmethod\n    def is_reviewable_type(cls, stack_type: str) -> bool:\n        \"\"\"\n        Check if a stack type requires user review.\n        \n        Currently no stack types are reviewable because:\n        - exact_copy and visual_duplicate are now handled by Duplicate model\n        - BURST_SEQUENCE, EXPOSURE_BRACKET, MANUAL are informational\n        - RAW_JPEG_PAIR and LIVE_PHOTO are deprecated (use Photo.files)\n        \n        Returns:\n            False for all current stack types\n        \"\"\"\n        # No current stack types require review\n        # Duplicates are handled by the separate Duplicate model\n        return False\n\n    @classmethod\n    def create_for_stack(cls, stack: PhotoStack) -> \"StackReview | None\":\n        \"\"\"\n        Create a review record for a stack if it's a reviewable type.\n        Returns None for non-reviewable stack types.\n        \"\"\"\n        if not cls.is_reviewable_type(stack.stack_type):\n            return None\n\n        review, created = cls.objects.get_or_create(\n            stack=stack,\n            defaults={\n                \"reviewer\": stack.owner,\n                \"decision\": cls.Decision.PENDING,\n            }\n        )\n        return review\n\n    def resolve(self, kept_photo, trash_others: bool = True):\n        \"\"\"\n        Mark the review as resolved by selecting a photo to keep.\n        \n        Args:\n            kept_photo: The Photo instance to keep as primary\n            trash_others: Whether to move other photos to trash\n        \"\"\"\n        from django.utils import timezone\n\n        # Set the kept photo\n        self.kept_photo = kept_photo\n        self.decision = self.Decision.RESOLVED\n        self.reviewed_at = timezone.now()\n\n        # Also set as stack's primary photo\n        self.stack.primary_photo = kept_photo\n        self.stack.save(update_fields=[\"primary_photo\", \"updated_at\"])\n\n        # Trash others if requested\n        if trash_others:\n            other_photos = self.stack.photos.exclude(pk=kept_photo.pk)\n            self.trashed_count = other_photos.update(in_trashcan=True)\n        \n        self.save()\n        return self\n\n    def dismiss(self):\n        \"\"\"Mark as 'not actually duplicates' and unlink photos from stack.\"\"\"\n        from django.utils import timezone\n\n        self.decision = self.Decision.DISMISSED\n        self.reviewed_at = timezone.now()\n        \n        # Unlink photos from stack (ManyToMany)\n        for photo in self.stack.photos.all():\n            photo.stacks.remove(self.stack)\n        \n        self.save()\n        return self\n\n    def revert(self):\n        \"\"\"Revert a resolved review, restoring trashed photos.\"\"\"\n        if self.decision != self.Decision.RESOLVED:\n            return self\n\n        # Restore trashed photos in this stack (using ManyToMany relationship)\n        restored_count = self.stack.photos.filter(\n            in_trashcan=True\n        ).update(in_trashcan=False)\n\n        # Reset to pending\n        self.decision = self.Decision.PENDING\n        self.kept_photo = None\n        self.trashed_count = 0\n        self.reviewed_at = None\n        \n        # Clear stack's primary\n        self.stack.primary_photo = None\n        self.stack.save(update_fields=[\"primary_photo\", \"updated_at\"])\n\n        self.save()\n        return restored_count\n"
  },
  {
    "path": "api/models/thumbnail.py",
    "content": "import os\n\nfrom django.db import models\nfrom PIL import Image\n\nfrom api.metadata.reader import get_metadata\nfrom api.metadata.tags import Tags\nfrom api.models.photo import Photo\nfrom api.thumbnails import (\n    create_animated_thumbnail,\n    create_thumbnail,\n    create_thumbnail_for_video,\n    does_static_thumbnail_exist,\n    does_video_thumbnail_exist,\n)\nfrom api.util import logger\n\n\nclass Thumbnail(models.Model):\n    photo = models.OneToOneField(\n        Photo, on_delete=models.CASCADE, related_name=\"thumbnail\", primary_key=True\n    )\n    thumbnail_big = models.ImageField(upload_to=\"thumbnails_big\")\n    square_thumbnail = models.ImageField(upload_to=\"square_thumbnails\")\n    square_thumbnail_small = models.ImageField(upload_to=\"square_thumbnails_small\")\n    aspect_ratio = models.FloatField(blank=True, null=True)\n    dominant_color = models.TextField(blank=True, null=True)\n\n    def _generate_thumbnail(self):\n        try:\n            # Use photo.image_hash for thumbnail paths for frontend compatibility\n            photo_hash = self.photo.image_hash\n            if not does_static_thumbnail_exist(\"thumbnails_big\", photo_hash):\n                if not self.photo.video:\n                    create_thumbnail(\n                        input_path=self.photo.main_file.path,\n                        output_height=1080,\n                        output_path=\"thumbnails_big\",\n                        hash=photo_hash,\n                        file_type=\".webp\",\n                    )\n                else:\n                    create_thumbnail_for_video(\n                        input_path=self.photo.main_file.path,\n                        output_path=\"thumbnails_big\",\n                        hash=photo_hash,\n                        file_type=\".webp\",\n                    )\n\n            if not self.photo.video and not does_static_thumbnail_exist(\n                \"square_thumbnails\", photo_hash\n            ):\n                create_thumbnail(\n                    input_path=self.photo.main_file.path,\n                    output_height=500,\n                    output_path=\"square_thumbnails\",\n                    hash=photo_hash,\n                    file_type=\".webp\",\n                )\n            if self.photo.video and not does_video_thumbnail_exist(\n                \"square_thumbnails\", photo_hash\n            ):\n                create_animated_thumbnail(\n                    input_path=self.photo.main_file.path,\n                    output_height=500,\n                    output_path=\"square_thumbnails\",\n                    hash=photo_hash,\n                    file_type=\".mp4\",\n                )\n\n            if not self.photo.video and not does_static_thumbnail_exist(\n                \"square_thumbnails_small\", photo_hash\n            ):\n                create_thumbnail(\n                    input_path=self.photo.main_file.path,\n                    output_height=250,\n                    output_path=\"square_thumbnails_small\",\n                    hash=photo_hash,\n                    file_type=\".webp\",\n                )\n            if self.photo.video and not does_video_thumbnail_exist(\n                \"square_thumbnails_small\", photo_hash\n            ):\n                create_animated_thumbnail(\n                    input_path=self.photo.main_file.path,\n                    output_height=250,\n                    output_path=\"square_thumbnails_small\",\n                    hash=photo_hash,\n                    file_type=\".mp4\",\n                )\n            filetype = \".webp\"\n            if self.photo.video:\n                filetype = \".mp4\"\n            self.thumbnail_big.name = os.path.join(\n                \"thumbnails_big\", photo_hash + \".webp\"\n            )\n            self.square_thumbnail.name = os.path.join(\n                \"square_thumbnails\", photo_hash + filetype\n            )\n            self.square_thumbnail_small.name = os.path.join(\n                \"square_thumbnails_small\", photo_hash + filetype\n            )\n            self.save()\n        except Exception as e:\n            logger.exception(\n                f\"could not generate thumbnail for image {self.photo.main_file.path}\"\n            )\n            raise e\n\n    def _calculate_aspect_ratio(self):\n        try:\n            # Relies on big thumbnail for correct aspect ratio\n            height, width = get_metadata(\n                self.thumbnail_big.path,\n                tags=[Tags.IMAGE_HEIGHT, Tags.IMAGE_WIDTH],\n                try_sidecar=False,\n            )\n            self.aspect_ratio = round(width / height, 2)\n\n            self.save()\n        except Exception as e:\n            logger.exception(\n                f\"could not calculate aspect ratio for image {self.thumbnail_big.path}\"\n            )\n            raise e\n\n    def _get_dominant_color(self, palette_size=16):\n        # Skip if it's already calculated\n        if self.dominant_color:\n            return\n        try:\n            # Resize image to speed up processing\n            img = Image.open(self.square_thumbnail_small.path)\n            img.thumbnail((100, 100))\n\n            # Reduce colors (uses k-means internally)\n            paletted = img.convert(\"P\", palette=Image.ADAPTIVE, colors=palette_size)\n\n            # Find the color that occurs most often\n            palette = paletted.getpalette()\n            color_counts = sorted(paletted.getcolors(), reverse=True)\n            palette_index = color_counts[0][1]\n            dominant_color = palette[palette_index * 3 : palette_index * 3 + 3]\n            self.dominant_color = dominant_color\n            self.save()\n        except Exception:\n            logger.info(f\"Cannot calculate dominant color {self} object\")\n"
  },
  {
    "path": "api/models/user.py",
    "content": "import pytz\nfrom django.conf import settings\nfrom django.contrib.auth.models import AbstractUser\nfrom django.db import models\nfrom django_cryptography.fields import encrypt\n\nfrom api.date_time_extractor import DEFAULT_RULES_JSON\nfrom api.burst_detection_rules import get_default_burst_detection_rules\n\n\ndef get_default_config_datetime_rules():  # This is a callable\n    return DEFAULT_RULES_JSON\n\n\ndef get_default_config_burst_detection_rules():  # This is a callable\n    return get_default_burst_detection_rules()\n\n\ndef get_default_llm_settings():\n    return {\n        \"enabled\": False,\n        \"add_person\": False,\n        \"add_location\": False,\n        \"add_keywords\": False,\n        \"add_camera\": False,\n        \"add_lens\": False,\n        \"add_album\": False,\n        \"sentiment\": 0,\n        \"custom_prompt\": \"\",\n        \"custom_prompt_enabled\": False,\n    }\n\n\ndef get_default_public_sharing_settings():\n    \"\"\"Default settings for what metadata to share in public albums.\n\n    All options default to False (opt-in) for privacy-first approach.\n    Users can change their defaults, and per-album overrides take precedence.\n    \"\"\"\n    return {\n        \"share_location\": False,  # GPS coordinates and location names\n        \"share_camera_info\": False,  # Camera make/model, lens, settings\n        \"share_timestamps\": False,  # Date/time when photo was taken\n        \"share_captions\": False,  # AI-generated or user captions\n        \"share_faces\": False,  # Detected faces (always recommend False)\n    }\n\n\nclass User(AbstractUser):\n    scan_directory = models.CharField(\n        max_length=512, db_index=True, blank=True, default=\"\"\n    )\n    confidence = models.FloatField(default=0.1, db_index=True)\n    confidence_person = models.FloatField(default=0.9)\n    image_scale = models.FloatField(default=1)\n    semantic_search_topk = models.IntegerField(default=0)\n    avatar = models.ImageField(upload_to=\"avatars\", null=True, blank=True)\n    transcode_videos = models.BooleanField(default=False)\n    nextcloud_server_address = models.CharField(max_length=200, default=\"\", blank=True)\n    nextcloud_username = models.CharField(max_length=64, default=\"\", blank=True)\n    nextcloud_app_password = encrypt(\n        models.CharField(max_length=64, default=\"\", blank=True)\n    )\n    nextcloud_scan_directory = models.CharField(\n        max_length=512, db_index=True, default=\"\", blank=True\n    )\n\n    favorite_min_rating = models.IntegerField(\n        default=settings.DEFAULT_FAVORITE_MIN_RATING, db_index=True\n    )\n\n    class SaveMetadata(models.TextChoices):\n        OFF = \"OFF\"\n        MEDIA_FILE = \"MEDIA_FILE\"\n        SIDECAR_FILE = \"SIDECAR_FILE\"\n\n    save_metadata_to_disk = models.TextField(\n        choices=SaveMetadata.choices, default=SaveMetadata.OFF\n    )\n    save_face_tags_to_disk = models.BooleanField(default=False)\n    llm_settings = models.JSONField(default=get_default_llm_settings)\n    datetime_rules = models.JSONField(default=get_default_config_datetime_rules)\n    burst_detection_rules = models.JSONField(\n        default=get_default_config_burst_detection_rules\n    )\n    default_timezone = models.TextField(\n        choices=[(x, x) for x in pytz.all_timezones],\n        default=\"UTC\",\n    )\n    public_sharing = models.BooleanField(default=False)\n    public_sharing_defaults = models.JSONField(\n        default=get_default_public_sharing_settings\n    )\n\n    class FaceRecogniton(models.TextChoices):\n        HOG = \"HOG\"\n        CNN = \"CNN\"\n\n    face_recognition_model = models.TextField(\n        choices=FaceRecogniton.choices, default=FaceRecogniton.HOG\n    )\n    min_cluster_size = models.IntegerField(default=0)\n    confidence_unknown_face = models.FloatField(default=0.5)\n    min_samples = models.IntegerField(default=1)\n    cluster_selection_epsilon = models.FloatField(default=0.05)\n\n    class TextAlignment(models.TextChoices):\n        LEFT = \"left\"\n        RIGHT = \"right\"\n\n    text_alignment = models.TextField(\n        choices=TextAlignment.choices, default=TextAlignment.RIGHT\n    )\n\n    class HeaderSize(models.TextChoices):\n        LARGE = \"large\"\n        NORMAL = \"normal\"\n        SMALL = \"small\"\n\n    header_size = models.TextField(choices=HeaderSize.choices, default=HeaderSize.LARGE)\n    skip_raw_files = models.BooleanField(\n        default=False\n    )  # Deprecated: kept for migration compatibility\n    stack_raw_jpeg = models.BooleanField(default=True)\n    slideshow_interval = models.IntegerField(default=5)\n\n    # Duplicate detection settings\n    class DuplicateSensitivity(models.TextChoices):\n        STRICT = \"strict\"\n        NORMAL = \"normal\"\n        LOOSE = \"loose\"\n\n    duplicate_sensitivity = models.TextField(\n        choices=DuplicateSensitivity.choices, default=DuplicateSensitivity.NORMAL\n    )\n    duplicate_clear_existing = models.BooleanField(default=False)\n\n\ndef get_admin_user():\n    return User.objects.get(is_superuser=True)\n\n\ndef get_deleted_user():\n    deleted_user: User = User.objects.get_or_create(username=\"deleted\")[0]\n    if deleted_user.is_active is not False:\n        deleted_user.is_active = False\n        deleted_user.save()\n    return deleted_user\n"
  },
  {
    "path": "api/nextcloud.py",
    "content": "import os\n\nimport owncloud as nextcloud\n\n\ndef login(user):\n    nc = nextcloud.Client(user.nextcloud_server_address)\n    nc.login(user.nextcloud_username, user.nextcloud_app_password)\n\n    def path_to_dict(path):\n        d = {\"title\": os.path.basename(path), \"absolute_path\": path}\n        try:\n            d[\"children\"] = [\n                path_to_dict(os.path.join(path, x.path))\n                for x in nc.list(path)\n                if x.is_dir()\n            ]\n        except Exception:\n            pass\n\n        return d\n\n\ndef list_dir(user, path):\n    nc = nextcloud.Client(user.nextcloud_server_address)\n    nc.login(user.nextcloud_username, user.nextcloud_app_password)\n    return [p.path for p in nc.list(path) if p.is_dir()]\n"
  },
  {
    "path": "api/perceptual_hash.py",
    "content": "\"\"\"\nPerceptual hashing module for duplicate image detection.\n\nUses pHash (perceptual hash) algorithm which is robust to:\n- Image resizing/scaling\n- Compression artifacts (JPEG quality differences)\n- Minor color adjustments\n- Small crops (up to ~15% border removal)\n\"\"\"\n\nimport imagehash\nfrom PIL import Image\n\nfrom api.util import logger\n\n# Threshold for considering two images as duplicates\n# pHash produces 64-bit hashes, Hamming distance <= 10 indicates high similarity\nDEFAULT_HAMMING_THRESHOLD = 10\n\n\ndef calculate_perceptual_hash(image_path: str, hash_size: int = 8) -> str | None:\n    \"\"\"\n    Calculate the perceptual hash (pHash) of an image.\n\n    Args:\n        image_path: Path to the image file\n        hash_size: Size of the hash (default 8 produces 64-bit hash)\n\n    Returns:\n        Hex string representation of the perceptual hash, or None if failed\n    \"\"\"\n    try:\n        with Image.open(image_path) as img:\n            # Convert to RGB if necessary (handles RGBA, palette images, etc.)\n            if img.mode not in (\"RGB\", \"L\"):\n                img = img.convert(\"RGB\")\n\n            # Calculate pHash - uses DCT (Discrete Cosine Transform)\n            # More robust than average hash or difference hash\n            phash = imagehash.phash(img, hash_size=hash_size)\n            return str(phash)\n    except Exception as e:\n        logger.error(f\"Failed to calculate perceptual hash for {image_path}: {e}\")\n        return None\n\n\ndef calculate_hash_from_thumbnail(thumbnail_path: str) -> str | None:\n    \"\"\"\n    Calculate perceptual hash from a thumbnail image.\n    Using thumbnails is faster and still produces reliable hashes.\n\n    Args:\n        thumbnail_path: Path to the thumbnail file\n\n    Returns:\n        Hex string representation of the perceptual hash, or None if failed\n    \"\"\"\n    return calculate_perceptual_hash(thumbnail_path)\n\n\ndef hamming_distance(hash1: str, hash2: str) -> int:\n    \"\"\"\n    Calculate the Hamming distance between two perceptual hashes.\n\n    Args:\n        hash1: First hash as hex string\n        hash2: Second hash as hex string\n\n    Returns:\n        Number of differing bits (0 = identical, higher = more different)\n    \"\"\"\n    try:\n        h1 = imagehash.hex_to_hash(hash1)\n        h2 = imagehash.hex_to_hash(hash2)\n        return h1 - h2  # imagehash overloads subtraction to return Hamming distance\n    except Exception as e:\n        logger.error(f\"Failed to calculate Hamming distance: {e}\")\n        return 64  # Maximum distance (completely different)\n\n\ndef are_duplicates(hash1: str, hash2: str, threshold: int = DEFAULT_HAMMING_THRESHOLD) -> bool:\n    \"\"\"\n    Determine if two images are duplicates based on their perceptual hashes.\n\n    Args:\n        hash1: First perceptual hash\n        hash2: Second perceptual hash\n        threshold: Maximum Hamming distance to consider as duplicates (default 10)\n\n    Returns:\n        True if images are likely duplicates, False otherwise\n    \"\"\"\n    if not hash1 or not hash2:\n        return False\n    return hamming_distance(hash1, hash2) <= threshold\n\n\ndef find_similar_hashes(\n    target_hash: str,\n    hash_list: list[tuple[str, str]],\n    threshold: int = DEFAULT_HAMMING_THRESHOLD,\n) -> list[tuple[str, int]]:\n    \"\"\"\n    Find all hashes similar to the target hash.\n\n    Args:\n        target_hash: The hash to compare against\n        hash_list: List of (image_id, hash) tuples to search\n        threshold: Maximum Hamming distance for similarity\n\n    Returns:\n        List of (image_id, distance) tuples for similar images, sorted by distance\n    \"\"\"\n    if not target_hash:\n        return []\n\n    similar = []\n    for image_id, hash_value in hash_list:\n        if hash_value and hash_value != target_hash:\n            distance = hamming_distance(target_hash, hash_value)\n            if distance <= threshold:\n                similar.append((image_id, distance))\n\n    return sorted(similar, key=lambda x: x[1])\n"
  },
  {
    "path": "api/permissions.py",
    "content": "from constance import config as site_config\nfrom rest_framework import permissions\n\nfrom api.models import User\n\n\nclass IsAdminOrSelf(permissions.BasePermission):\n    def has_object_permission(self, request, view, obj):\n        if request.method in permissions.SAFE_METHODS:\n            return True\n\n        return request.user and request.user.is_staff or obj == request.user\n\n\nclass IsAdminOrFirstTimeSetupOrRegistrationAllowed(permissions.BasePermission):\n    def has_permission(self, request, view):\n        if request.method in permissions.SAFE_METHODS:\n            return True\n\n        is_admin = request.user and request.user.is_staff\n        is_first_time_setup = not User.objects.filter(is_superuser=True).exists()\n        is_registration_allowed = bool(site_config.ALLOW_REGISTRATION)\n\n        return is_admin or is_first_time_setup or is_registration_allowed\n\n\nclass IsOwnerOrReadOnly(permissions.BasePermission):\n    \"\"\"Custom permission to only allow owners of an object to edit it.\"\"\"\n\n    def has_object_permission(self, request, view, obj):\n        # Read permissions are allowed to any request,\n        # so we'll always allow GET, HEAD or OPTIONS requests.\n        if request.method in permissions.SAFE_METHODS:\n            return True\n\n        # Write permissions are only allowed to the owner of the snippet.\n        return obj.owner == request.user\n\n\nclass IsUserOrReadOnly(permissions.BasePermission):\n    \"\"\"Custom permission to only allow owners of an object to edit it.\"\"\"\n\n    def has_object_permission(self, request, view, obj):\n        # Read permissions are allowed to any request,\n        # so we'll always allow GET, HEAD or OPTIONS requests.\n        if request.method in permissions.SAFE_METHODS:\n            return True\n\n        # Write permissions are only allowed to the owner of the snippet.\n        return obj == request.user\n\n\nclass IsPhotoOrAlbumSharedTo(permissions.BasePermission):\n    \"\"\"Custom permission to only allow owners of an object to edit it.\"\"\"\n\n    def has_object_permission(self, request, view, obj):\n        if obj.public:\n            return True\n\n        if obj.owner == request.user or request.user in obj.shared_to.all():\n            return True\n\n        for album in obj.albumuser_set.only(\"shared_to\"):\n            if request.user in album.shared_to.all():\n                return True\n\n        return False\n\n\nclass IsRegistrationAllowed(permissions.BasePermission):\n    \"\"\"Custom permission to only allow if registration is allowed globally.\"\"\"\n\n    def has_permission(self, request, view):\n        return bool(site_config.ALLOW_REGISTRATION)\n"
  },
  {
    "path": "api/schemas/site_settings.py",
    "content": "site_settings_schema = {\n    \"type\": \"object\",\n    \"anyOf\": [\n        {\"required\": [\"allow_registration\"]},\n        {\"required\": [\"allow_upload\"]},\n        {\"required\": [\"skip_patterns\"]},\n        {\"required\": [\"map_api_provider\"]},\n        {\"required\": [\"map_api_key\"]},\n        {\"required\": [\"captioning_model\"]},\n        {\"required\": [\"llm_model\"]},\n        {\"required\": [\"tagging_model\"]},\n    ],\n    \"properties\": {\n        \"allow_registration\": {\"type\": \"boolean\"},\n        \"allow_upload\": {\"type\": \"boolean\"},\n        \"skip_patterns\": {\"type\": \"string\"},\n        \"map_api_provider\": {\"type\": \"string\"},\n        \"map_api_key\": {\"type\": \"string\"},\n        \"captioning_model\": {\"type\": \"string\"},\n        \"llm_model\": {\"type\": \"string\"},\n        \"tagging_model\": {\"type\": \"string\"},\n    },\n}\n"
  },
  {
    "path": "api/semantic_search.py",
    "content": "import numpy as np\nimport requests\nfrom django.conf import settings\n\ndir_clip_ViT_B_32_model = settings.CLIP_ROOT\n\n\ndef create_clip_embeddings(imgs):\n    json = {\n        \"imgs\": imgs,\n        \"model\": dir_clip_ViT_B_32_model,\n    }\n    clip_embeddings = requests.post(\n        \"http://localhost:8006/clip-embeddings\", json=json\n    ).json()\n\n    imgs_emb = clip_embeddings[\"imgs_emb\"]\n    magnitudes = clip_embeddings[\"magnitudes\"]\n\n    # Convert Python lists to NumPy arrays\n    imgs_emb = [np.array(enc) for enc in imgs_emb]\n\n    return imgs_emb, magnitudes\n\n\ndef calculate_query_embeddings(query):\n    json = {\n        \"query\": query,\n        \"model\": dir_clip_ViT_B_32_model,\n    }\n    query_embedding = requests.post(\n        \"http://localhost:8006/query-embeddings\", json=json\n    ).json()\n\n    emb = query_embedding[\"emb\"]\n    magnitude = query_embedding[\"magnitude\"]\n    return emb, magnitude\n"
  },
  {
    "path": "api/serializers/PhotosGroupedByDate.py",
    "content": "import pytz\nfrom itertools import groupby\n\nutc = pytz.UTC\n\n\nclass PhotosGroupedByDate:\n    def __init__(self, location, date, photos):\n        self.photos = photos\n        self.date = date\n        self.location = location\n\n\ndef get_photos_ordered_by_date(photos):\n    \"\"\"\n    Efficiently group photos by date using itertools.groupby.\n    Assumes photos are already ordered by exif_timestamp.\n    \"\"\"\n    # Convert to list once if it's a queryset\n    if hasattr(photos, \"_result_cache\") and photos._result_cache is None:\n        photos = list(photos)\n\n    result = []\n    no_timestamp_photos = []\n\n    def date_key(photo):\n        \"\"\"Key function for grouping photos by date\"\"\"\n        if photo.exif_timestamp:\n            return photo.exif_timestamp.date().strftime(\"%Y-%m-%d\")\n        return None\n\n    # Group consecutive photos by their date\n    for date_str, group_photos in groupby(photos, key=date_key):\n        group_list = list(group_photos)\n        location = \"\"\n\n        if date_str is not None:\n            # Use the first photo's timestamp as the group date\n            date = group_list[0].exif_timestamp\n            result.append(PhotosGroupedByDate(location, date, group_list))\n        else:\n            # Collect photos without timestamps\n            no_timestamp_photos.extend(group_list)\n\n    # Add no timestamp photos as a single group at the end\n    if no_timestamp_photos:\n        result.append(PhotosGroupedByDate(\"\", \"No timestamp\", no_timestamp_photos))\n\n    return result\n"
  },
  {
    "path": "api/serializers/__init__.py",
    "content": ""
  },
  {
    "path": "api/serializers/album_auto.py",
    "content": "from rest_framework import serializers\n\nfrom api.models import AlbumAuto\nfrom api.serializers.person import PersonSerializer\nfrom api.serializers.photos import PhotoHashListSerializer\nfrom api.serializers.simple import PhotoSimpleSerializer\n\n\nclass AlbumAutoSerializer(serializers.ModelSerializer):\n    photos = PhotoSimpleSerializer(many=True, read_only=False)\n    people = serializers.SerializerMethodField()\n\n    class Meta:\n        model = AlbumAuto\n        fields = (\n            \"id\",\n            \"title\",\n            \"favorited\",\n            \"timestamp\",\n            \"created_on\",\n            \"gps_lat\",\n            \"people\",\n            \"gps_lon\",\n            \"photos\",\n        )\n\n    def get_people(self, obj) -> PersonSerializer(many=True):\n        res = []\n        for photo in obj.photos.all():\n            faces = photo.faces.all()\n            for face in faces:\n                serialized_person = PersonSerializer(face.person).data\n                if serialized_person not in res:\n                    res.append(serialized_person)\n        return res\n\n    def delete(self, validated_data, id):\n        album = AlbumAuto.objects.filter(id=id).get()\n        album.delete()\n\n\nclass AlbumAutoListSerializer(serializers.ModelSerializer):\n    photos = serializers.SerializerMethodField()\n    photo_count = serializers.SerializerMethodField()\n\n    class Meta:\n        model = AlbumAuto\n        fields = (\n            \"id\",\n            \"title\",\n            \"timestamp\",\n            \"photos\",\n            \"photo_count\",\n            \"favorited\",\n        )\n\n    def get_photo_count(self, obj) -> int:\n        try:\n            return obj.photo_count\n        except Exception:\n            return obj.photos.count()\n\n    def get_photos(self, obj) -> PhotoHashListSerializer:\n        try:\n            return PhotoHashListSerializer(obj.cover_photo[0]).data\n        except Exception:\n            return \"\"\n"
  },
  {
    "path": "api/serializers/album_date.py",
    "content": "from rest_framework import serializers\n\nfrom api.models import AlbumDate\n\n\nclass IncompleteAlbumDateSerializer(serializers.ModelSerializer):\n    id = serializers.SerializerMethodField()\n    date = serializers.SerializerMethodField()\n    location = serializers.SerializerMethodField()\n    incomplete = serializers.SerializerMethodField()\n    numberOfItems = serializers.SerializerMethodField(\"get_number_of_items\")\n    items = serializers.SerializerMethodField()\n\n    class Meta:\n        model = AlbumDate\n        fields = (\"id\", \"date\", \"location\", \"incomplete\", \"numberOfItems\", \"items\")\n\n    def get_id(self, obj) -> str:\n        return str(obj.id)\n\n    def get_date(self, obj) -> str:\n        if obj.date:\n            return obj.date.isoformat()\n        else:\n            return None\n\n    def get_items(self, obj) -> list:\n        return []\n\n    def get_incomplete(self, obj) -> bool:\n        return True\n\n    def get_number_of_items(self, obj) -> int:\n        if obj and obj.photo_count:\n            return obj.photo_count\n        else:\n            return 0\n\n    def get_location(self, obj) -> str:\n        if obj and obj.location:\n            return obj.location[\"places\"][0]\n        else:\n            return \"\"\n\n\nclass AlbumDateSerializer(serializers.ModelSerializer):\n    id = serializers.SerializerMethodField()\n    date = serializers.SerializerMethodField()\n    location = serializers.SerializerMethodField()\n    incomplete = serializers.SerializerMethodField()\n    numberOfItems = serializers.SerializerMethodField(\"get_number_of_items\")\n    items = serializers.SerializerMethodField()\n\n    class Meta:\n        model = AlbumDate\n        fields = (\"id\", \"date\", \"location\", \"incomplete\", \"numberOfItems\", \"items\")\n\n    def get_id(self, obj) -> str:\n        return str(obj.id)\n\n    def get_date(self, obj) -> str:\n        if obj.date:\n            return obj.date.isoformat()\n        else:\n            return None\n\n    def get_items(self, obj) -> dict:\n        # This method is removed as we're directly including paginated photos in the response.\n        pass\n\n    def get_incomplete(self, obj) -> bool:\n        return False\n\n    def get_number_of_items(self, obj) -> int:\n        # this will also get added in the response\n        pass\n\n    def get_location(self, obj) -> str:\n        if obj and obj.location:\n            return obj.location[\"places\"][0]\n        else:\n            return \"\"\n"
  },
  {
    "path": "api/serializers/album_place.py",
    "content": "from rest_framework import serializers\n\nfrom api.models import AlbumPlace\nfrom api.serializers.photos import GroupedPhotosSerializer, PhotoHashListSerializer\nfrom api.serializers.PhotosGroupedByDate import get_photos_ordered_by_date\nfrom api.serializers.simple import PhotoSuperSimpleSerializer\n\n\nclass GroupedPlacePhotosSerializer(serializers.ModelSerializer):\n    id = serializers.SerializerMethodField()\n    grouped_photos = serializers.SerializerMethodField()\n\n    class Meta:\n        model = AlbumPlace\n        fields = (\n            \"id\",\n            \"title\",\n            \"grouped_photos\",\n        )\n\n    # To-Do: Remove legacy stuff\n    def get_id(self, obj) -> str:\n        return str(obj.id)\n\n    def get_grouped_photos(self, obj) -> GroupedPhotosSerializer(many=True):\n        grouped_photos = get_photos_ordered_by_date(obj.photos.all())\n        res = GroupedPhotosSerializer(grouped_photos, many=True).data\n        return res\n\n\nclass AlbumPlaceSerializer(serializers.ModelSerializer):\n    photos = PhotoSuperSimpleSerializer(many=True, read_only=True)\n\n    class Meta:\n        model = AlbumPlace\n        fields = (\"id\", \"title\", \"photos\")\n\n\nclass AlbumPlaceListSerializer(serializers.ModelSerializer):\n    cover_photos = PhotoHashListSerializer(many=True, read_only=True)\n    photo_count = serializers.SerializerMethodField()\n\n    class Meta:\n        model = AlbumPlace\n        fields = (\"id\", \"geolocation_level\", \"cover_photos\", \"title\", \"photo_count\")\n\n    def get_photo_count(self, obj) -> int:\n        return obj.photo_count\n"
  },
  {
    "path": "api/serializers/album_thing.py",
    "content": "from rest_framework import serializers\n\nfrom api.models import AlbumThing\nfrom api.serializers.photos import GroupedPhotosSerializer, PhotoHashListSerializer\nfrom api.serializers.PhotosGroupedByDate import get_photos_ordered_by_date\nfrom api.serializers.simple import PhotoSuperSimpleSerializer\n\n\nclass GroupedThingPhotosSerializer(serializers.ModelSerializer):\n    id = serializers.SerializerMethodField()\n    grouped_photos = serializers.SerializerMethodField()\n\n    class Meta:\n        model = AlbumThing\n        fields = (\n            \"id\",\n            \"title\",\n            \"grouped_photos\",\n        )\n\n    def get_id(self, obj) -> str:\n        return str(obj.id)\n\n    def get_grouped_photos(self, obj) -> GroupedPhotosSerializer(many=True):\n        grouped_photos = get_photos_ordered_by_date(obj.photos.all())\n        res = GroupedPhotosSerializer(grouped_photos, many=True).data\n        return res\n\n\nclass AlbumThingSerializer(serializers.ModelSerializer):\n    photos = PhotoSuperSimpleSerializer(many=True, read_only=True)\n\n    class Meta:\n        model = AlbumThing\n        fields = (\"id\", \"title\", \"photos\")\n\n\nclass AlbumThingListSerializer(serializers.ModelSerializer):\n    cover_photos = PhotoHashListSerializer(many=True, read_only=True)\n    photo_count = serializers.SerializerMethodField()\n\n    class Meta:\n        model = AlbumThing\n        fields = (\n            \"id\",\n            \"cover_photos\",\n            \"title\",\n            \"photo_count\",\n            \"thing_type\",\n            \"cover_photos\",\n        )\n\n    def get_photo_count(self, obj) -> int:\n        return obj.photo_count\n"
  },
  {
    "path": "api/serializers/album_user.py",
    "content": "from rest_framework import serializers\n\nfrom api.models import AlbumUser, Photo\nfrom api.serializers.photos import GroupedPhotosSerializer\nfrom api.serializers.PhotosGroupedByDate import get_photos_ordered_by_date\nfrom api.serializers.simple import PhotoSuperSimpleSerializer, SimpleUserSerializer\nfrom api.util import logger\n\n\nclass AlbumUserSerializer(serializers.ModelSerializer):\n    id = serializers.SerializerMethodField()\n    owner = SimpleUserSerializer(many=False, read_only=True)\n    shared_to = SimpleUserSerializer(many=True, read_only=True)\n    date = serializers.SerializerMethodField()\n    location = serializers.SerializerMethodField()\n    grouped_photos = serializers.SerializerMethodField()\n    public = serializers.SerializerMethodField()\n    public_slug = serializers.SerializerMethodField()\n    public_expires_at = serializers.SerializerMethodField()\n    public_sharing_options = serializers.SerializerMethodField()\n\n    class Meta:\n        model = AlbumUser\n        fields = (\n            \"id\",\n            \"title\",\n            \"owner\",\n            \"shared_to\",\n            \"date\",\n            \"location\",\n            \"grouped_photos\",\n            \"public\",\n            \"public_slug\",\n            \"public_expires_at\",\n            \"public_sharing_options\",\n        )\n\n    # To-Do: Legacy definition, should be a number instead\n    def get_id(self, obj) -> str:\n        return str(obj.id)\n\n    def get_grouped_photos(self, obj) -> GroupedPhotosSerializer(many=True):\n        grouped_photos = get_photos_ordered_by_date(\n            obj.photos.all().order_by(\"-exif_timestamp\")\n        )\n        res = GroupedPhotosSerializer(grouped_photos, many=True).data\n        return res\n\n    def get_location(self, obj) -> str:\n        for photo in obj.photos.all():\n            if (\n                photo\n                and hasattr(photo, \"search_instance\")\n                and photo.search_instance\n                and photo.search_instance.search_location\n            ):\n                return photo.search_instance.search_location\n        return \"\"\n\n    def get_date(self, obj) -> str:\n        for photo in obj.photos.all():\n            if photo and photo.exif_timestamp:\n                return photo.exif_timestamp\n        return \"\"\n\n    def get_public(self, obj) -> bool:\n        return bool(getattr(obj, \"share\", None) and obj.share.enabled)\n\n    def get_public_slug(self, obj) -> str:\n        return getattr(getattr(obj, \"share\", None), \"slug\", \"\") or \"\"\n\n    def get_public_expires_at(self, obj):\n        return getattr(getattr(obj, \"share\", None), \"expires_at\", None)\n\n    def get_public_sharing_options(self, obj) -> dict | None:\n        \"\"\"Return the per-album sharing option overrides (None values = use default).\"\"\"\n        share = getattr(obj, \"share\", None)\n        if not share:\n            return None\n        return {\n            \"share_location\": share.share_location,\n            \"share_camera_info\": share.share_camera_info,\n            \"share_timestamps\": share.share_timestamps,\n            \"share_captions\": share.share_captions,\n            \"share_faces\": share.share_faces,\n        }\n\n\nclass AlbumUserEditSerializer(serializers.ModelSerializer):\n    photos = serializers.PrimaryKeyRelatedField(\n        many=True, read_only=False, queryset=Photo.objects.all()\n    )\n    removedPhotos = serializers.ListField(\n        child=serializers.CharField(max_length=100, default=\"\"),\n        write_only=True,\n        required=False,\n    )\n\n    class Meta:\n        model = AlbumUser\n        fields = (\n            \"id\",\n            \"title\",\n            \"photos\",\n            \"created_on\",\n            \"favorited\",\n            \"removedPhotos\",\n            \"cover_photo\",\n        )\n\n    def create(self, validated_data):\n        title = validated_data[\"title\"]\n        photos = validated_data[\"photos\"]\n\n        user = None\n        request = self.context.get(\"request\")\n        if request and hasattr(request, \"user\"):\n            user = request.user\n\n        # check if an album exists with the given title and call the update method if it does\n        instance, created = AlbumUser.objects.get_or_create(title=title, owner=user)\n        if not created:\n            return self.update(instance, validated_data)\n\n        for photo in photos:\n            instance.photos.add(photo)\n        instance.save()\n        logger.info(f\"Created user album {instance.id} with {len(photos)} photos\")\n        return instance\n\n    def update(self, instance, validated_data):\n        if \"title\" in validated_data.keys():\n            title = validated_data[\"title\"]\n            instance.title = title\n            logger.info(f\"Renamed user album to {title}\")\n\n        if \"removedPhotos\" in validated_data.keys():\n            image_hashes = validated_data[\"removedPhotos\"]\n            photos_already_in_album = instance.photos.all()\n            cnt = 0\n            for obj in photos_already_in_album:\n                if obj.image_hash in image_hashes:\n                    cnt += 1\n                    instance.photos.remove(obj)\n\n            logger.info(f\"Removed {cnt} photos to user album {instance.id}\")\n\n        if \"cover_photo\" in validated_data.keys():\n            cover_photo = validated_data[\"cover_photo\"]\n            instance.cover_photo = cover_photo\n            logger.info(f\"Changed cover photo to {cover_photo}\")\n\n        if \"photos\" in validated_data.keys():\n            photos = validated_data[\"photos\"]\n            photos_already_in_album = instance.photos.all()\n            cnt = 0\n            for photo in photos:\n                if photo not in photos_already_in_album:\n                    cnt += 1\n                    instance.photos.add(photo)\n\n            logger.info(f\"Added {cnt} photos to user album {instance.id}\")\n\n        instance.save()\n        return instance\n\n\nclass AlbumUserListSerializer(serializers.ModelSerializer):\n    cover_photo = serializers.SerializerMethodField()\n    photo_count = serializers.SerializerMethodField()\n    shared_to = SimpleUserSerializer(many=True, read_only=True)\n    owner = SimpleUserSerializer(many=False, read_only=True)\n    public = serializers.SerializerMethodField()\n    public_slug = serializers.SerializerMethodField()\n    public_expires_at = serializers.SerializerMethodField()\n    public_sharing_options = serializers.SerializerMethodField()\n\n    class Meta:\n        model = AlbumUser\n        fields = (\n            \"id\",\n            \"cover_photo\",\n            \"created_on\",\n            \"favorited\",\n            \"title\",\n            \"shared_to\",\n            \"owner\",\n            \"photo_count\",\n            \"public\",\n            \"public_slug\",\n            \"public_expires_at\",\n            \"public_sharing_options\",\n        )\n\n    def get_cover_photo(self, obj) -> PhotoSuperSimpleSerializer:\n        if obj.cover_photo:\n            return PhotoSuperSimpleSerializer(obj.cover_photo).data\n        return PhotoSuperSimpleSerializer(obj.photos.first()).data\n\n    def get_photo_count(self, obj) -> int:\n        try:\n            return obj.photo_count\n        except Exception:  # for when calling AlbumUserListSerializer(obj).data directly\n            return obj.photos.count()\n\n    def get_public(self, obj) -> bool:\n        return bool(getattr(obj, \"share\", None) and obj.share.enabled)\n\n    def get_public_slug(self, obj) -> str:\n        return getattr(getattr(obj, \"share\", None), \"slug\", \"\") or \"\"\n\n    def get_public_expires_at(self, obj):\n        return getattr(getattr(obj, \"share\", None), \"expires_at\", None)\n\n    def get_public_sharing_options(self, obj) -> dict | None:\n        \"\"\"Return the per-album sharing option overrides (None values = use default).\"\"\"\n        share = getattr(obj, \"share\", None)\n        if not share:\n            return None\n        return {\n            \"share_location\": share.share_location,\n            \"share_camera_info\": share.share_camera_info,\n            \"share_timestamps\": share.share_timestamps,\n            \"share_captions\": share.share_captions,\n            \"share_faces\": share.share_faces,\n        }\n\n\nclass AlbumUserPublicSerializer(serializers.ModelSerializer):\n    \"\"\"Serializer for publicly shared user albums.\n\n    Ensures photos are grouped from a filtered subset (not hidden, not in trashcan).\n    \"\"\"\n\n    id = serializers.SerializerMethodField()\n    owner = SimpleUserSerializer(many=False, read_only=True)\n    date = serializers.SerializerMethodField()\n    location = serializers.SerializerMethodField()\n    grouped_photos = serializers.SerializerMethodField()\n    public_slug = serializers.CharField(read_only=True)\n    public_expires_at = serializers.DateTimeField(read_only=True)\n\n    class Meta:\n        model = AlbumUser\n        fields = (\n            \"id\",\n            \"title\",\n            \"owner\",\n            \"date\",\n            \"location\",\n            \"grouped_photos\",\n            \"public_slug\",\n            \"public_expires_at\",\n        )\n\n    def get_id(self, obj) -> str:\n        return str(obj.id)\n\n    def _filtered_photos(self, obj):\n        # Public albums should not expose hidden or trash photos\n        return obj.photos.filter(hidden=False, in_trashcan=False).order_by(\n            \"-exif_timestamp\"\n        )\n\n    def get_grouped_photos(self, obj) -> GroupedPhotosSerializer(many=True):\n        grouped_photos = get_photos_ordered_by_date(self._filtered_photos(obj))\n        return GroupedPhotosSerializer(grouped_photos, many=True).data\n\n    def get_location(self, obj) -> str:\n        for photo in self._filtered_photos(obj):\n            if (\n                photo\n                and hasattr(photo, \"search_instance\")\n                and photo.search_instance\n                and photo.search_instance.search_location\n            ):\n                return photo.search_instance.search_location\n        return \"\"\n\n    def get_date(self, obj) -> str:\n        for photo in self._filtered_photos(obj):\n            if photo and photo.exif_timestamp:\n                return photo.exif_timestamp\n        return \"\"\n"
  },
  {
    "path": "api/serializers/face.py",
    "content": "from rest_framework import serializers\n\nfrom api.models import Face, Person\n\n\nclass PersonFaceListSerializer(serializers.ModelSerializer):\n    face_url = serializers.SerializerMethodField()\n    person_label_probability = serializers.SerializerMethodField()\n    photo_image_hash = serializers.SerializerMethodField()\n\n    class Meta:\n        model = Face\n        fields = [\n            \"id\",\n            \"image\",\n            \"face_url\",\n            \"photo\",\n            \"photo_image_hash\",\n            \"timestamp\",\n            \"person_label_probability\",\n        ]\n\n    def get_person_label_probability(self, obj):\n        if obj.analysis_method == \"clustering\":\n            return obj.cluster_probability\n        else:\n            return obj.classification_probability\n\n    def get_face_url(self, obj):\n        return obj.image.url\n\n    def get_photo_image_hash(self, obj):\n        return obj.photo.image_hash if obj.photo else None\n\n\nclass IncompletePersonFaceListSerializer(serializers.ModelSerializer):\n    face_count = serializers.SerializerMethodField()\n\n    class Meta:\n        model = Person\n        fields = [\"id\", \"name\", \"kind\", \"face_count\"]\n\n    def get_face_count(self, obj) -> int:\n        if obj and obj.viewable_face_count:\n            return obj.viewable_face_count\n        else:\n            return 0\n\n\nclass FaceListSerializer(serializers.ModelSerializer):\n    person_name = serializers.SerializerMethodField()\n    face_url = serializers.SerializerMethodField()\n    person_label_probability = serializers.SerializerMethodField()\n\n    class Meta:\n        model = Face\n        fields = (\n            \"id\",\n            \"image\",\n            \"face_url\",\n            \"photo\",\n            \"timestamp\",\n            \"person\",\n            \"person_label_probability\",\n            \"person_name\",\n        )\n\n    def get_person_label_probability(self, obj) -> float:\n        return obj.cluster_probability\n\n    def get_face_url(self, obj) -> str:\n        return obj.image.url\n\n    def get_person_name(self, obj) -> str:\n        if obj.person:\n            return obj.person.name\n        else:\n            return \"Unknown - Other\"\n"
  },
  {
    "path": "api/serializers/job.py",
    "content": "from rest_framework import serializers\n\nfrom api.models import LongRunningJob\nfrom api.serializers.simple import SimpleUserSerializer\n\n\nclass LongRunningJobSerializer(serializers.ModelSerializer):\n    job_type_str = serializers.SerializerMethodField()\n    started_by = SimpleUserSerializer(read_only=True)\n\n    class Meta:\n        model = LongRunningJob\n        fields = (\n            \"job_id\",\n            \"queued_at\",\n            \"finished\",\n            \"finished_at\",\n            \"started_at\",\n            \"failed\",\n            \"job_type_str\",\n            \"job_type\",\n            \"started_by\",\n            \"progress_current\",\n            \"progress_target\",\n            \"progress_step\",\n            \"result\",\n            \"id\",\n        )\n\n    def get_job_type_str(self, obj) -> str:\n        return dict(LongRunningJob.JOB_TYPES)[obj.job_type]\n"
  },
  {
    "path": "api/serializers/person.py",
    "content": "from django.db.models import Q\nfrom rest_framework import serializers\n\nfrom api.models import Person, Photo\nfrom api.serializers.photos import GroupedPhotosSerializer\nfrom api.serializers.PhotosGroupedByDate import get_photos_ordered_by_date\nfrom api.util import logger\n\n\nclass GroupedPersonPhotosSerializer(serializers.ModelSerializer):\n    id = serializers.SerializerMethodField()\n    grouped_photos = serializers.SerializerMethodField()\n\n    class Meta:\n        model = Person\n        fields = (\n            \"id\",\n            \"name\",\n            \"grouped_photos\",\n        )\n\n    def get_id(self, obj) -> str:\n        return str(obj.id)\n\n    def get_grouped_photos(self, obj) -> GroupedPhotosSerializer(many=True):\n        user = None\n        request = self.context.get(\"request\")\n        if request and hasattr(request, \"user\"):\n            user = request.user\n        grouped_photos = get_photos_ordered_by_date(obj.get_photos(user))\n        res = GroupedPhotosSerializer(grouped_photos, many=True).data\n        return res\n\n\nclass PersonSerializer(serializers.ModelSerializer):\n    face_url = serializers.SerializerMethodField()\n    face_photo_url = serializers.SerializerMethodField()\n    video = serializers.SerializerMethodField()\n    newPersonName = serializers.CharField(max_length=100, default=\"\", write_only=True)\n    cover_photo = serializers.CharField(max_length=100, default=\"\", write_only=True)\n\n    class Meta:\n        model = Person\n        fields = (\n            \"name\",\n            \"face_url\",\n            \"face_count\",\n            \"face_photo_url\",\n            \"video\",\n            \"id\",\n            \"newPersonName\",\n            \"cover_photo\",\n        )\n\n    def get_face_url(self, obj) -> str:\n        if obj.cover_face:\n            return \"/media/\" + obj.cover_face.image.name\n        if obj.faces.count() == 0:\n            return \"\"\n        return \"/media/\" + obj.faces.first().image.name\n\n    def get_face_photo_url(self, obj) -> str:\n        if obj.cover_photo:\n            return obj.cover_photo.image_hash\n        if obj.faces.count() == 0:\n            return \"\"\n        return obj.faces.first().photo.image_hash\n\n    def get_video(self, obj) -> str:\n        if obj.cover_photo:\n            return obj.cover_photo.video\n        if obj.faces.count() == 0:\n            return \"False\"\n        return obj.faces.first().photo.video\n\n    def create(self, validated_data):\n        name = validated_data.pop(\"name\")\n        if len(name.strip()) == 0:\n            raise serializers.ValidationError(\"Name cannot be empty\")\n        qs = Person.objects.filter(name=name)\n        if qs.count() > 0:\n            return qs[0]\n        else:\n            new_person = Person()\n            new_person.name = name\n            new_person.save()\n            logger.info(f\"created person {new_person.id}\")\n            return new_person\n\n    def update(self, instance, validated_data):\n        if \"newPersonName\" in validated_data.keys():\n            new_name = validated_data.pop(\"newPersonName\")\n            instance.name = new_name\n            instance.save()\n            return instance\n        if \"cover_photo\" in validated_data.keys():\n            image_hash = validated_data.pop(\"cover_photo\")\n            photo = Photo.objects.filter(image_hash=image_hash).first()\n            instance.cover_photo = photo\n            instance.cover_face = photo.faces.filter(person__name=instance.name).first()\n            instance.save()\n            return instance\n        return instance\n\n    def delete(self, validated_data, id):\n        person = Person.objects.filter(id=id).get()\n        person.delete()\n\n\nclass AlbumPersonListSerializer(serializers.ModelSerializer):\n    photo_count = serializers.SerializerMethodField()\n    cover_photo_url = serializers.SerializerMethodField()\n\n    class Meta:\n        model = Person\n        fields = (\n            \"name\",\n            \"photo_count\",\n            \"cover_photo_url\",\n            \"id\",\n        )\n\n    def get_photo_count(self, obj) -> int:\n        return obj.filter(Q(person__is_null=False)).faces.count()\n\n    def get_cover_photo_url(self, obj) -> str:\n        first_face = obj.faces.filter(Q(person__is_null=False)).first()\n        if first_face:\n            return first_face.photo.thumbnail.square_thumbnail.url\n        else:\n            return None\n\n    def get_face_photo_url(self, obj) -> str:\n        first_face = obj.faces.filter(Q(person__is_null=False)).first()\n        if first_face:\n            return first_face.photo.image.url\n        else:\n            return None\n"
  },
  {
    "path": "api/serializers/photo_metadata.py",
    "content": "\"\"\"\nSerializers for PhotoMetadata, MetadataFile, and MetadataEdit models.\n\nThese serializers provide:\n- Structured metadata access (replacing exif_json blob)\n- Edit history tracking\n- Backwards-compatible field names for existing API consumers\n\"\"\"\n\nfrom rest_framework import serializers\n\nfrom api.models import Photo\nfrom api.models.photo_metadata import MetadataEdit, MetadataFile, PhotoMetadata\n\n\nclass MetadataFileSerializer(serializers.ModelSerializer):\n    \"\"\"Serializer for XMP sidecars and other metadata files.\"\"\"\n\n    class Meta:\n        model = MetadataFile\n        fields = (\n            \"id\",\n            \"file_type\",\n            \"source\",\n            \"priority\",\n            \"creator_software\",\n            \"created_at\",\n            \"updated_at\",\n        )\n        read_only_fields = (\"id\", \"created_at\", \"updated_at\")\n\n\nclass MetadataEditSerializer(serializers.ModelSerializer):\n    \"\"\"Serializer for metadata edit history.\"\"\"\n\n    user_name = serializers.SerializerMethodField()\n\n    class Meta:\n        model = MetadataEdit\n        fields = (\n            \"id\",\n            \"field_name\",\n            \"old_value\",\n            \"new_value\",\n            \"user\",\n            \"user_name\",\n            \"synced_to_file\",\n            \"synced_at\",\n            \"created_at\",\n        )\n        read_only_fields = fields\n\n    def get_user_name(self, obj) -> str:\n        if obj.user:\n            return obj.user.username\n        return \"Unknown\"\n\n\nclass PhotoMetadataSerializer(serializers.ModelSerializer):\n    \"\"\"\n    Full metadata serializer with all structured fields.\n    \n    Used for the detailed metadata view and editing.\n    \"\"\"\n\n    # Computed properties\n    resolution = serializers.ReadOnlyField()\n    megapixels = serializers.ReadOnlyField()\n    has_location = serializers.ReadOnlyField()\n    camera_display = serializers.ReadOnlyField()\n    lens_display = serializers.ReadOnlyField()\n\n    # Related data\n    edit_history = serializers.SerializerMethodField()\n    sidecar_files = serializers.SerializerMethodField()\n\n    class Meta:\n        model = PhotoMetadata\n        fields = (\n            \"id\",\n            # Camera settings\n            \"aperture\",\n            \"shutter_speed\",\n            \"shutter_speed_seconds\",\n            \"iso\",\n            \"focal_length\",\n            \"focal_length_35mm\",\n            \"exposure_compensation\",\n            \"flash_fired\",\n            \"metering_mode\",\n            \"white_balance\",\n            # Camera/lens info\n            \"camera_make\",\n            \"camera_model\",\n            \"lens_make\",\n            \"lens_model\",\n            \"serial_number\",\n            \"camera_display\",\n            \"lens_display\",\n            # Image properties\n            \"width\",\n            \"height\",\n            \"orientation\",\n            \"color_space\",\n            \"bit_depth\",\n            \"resolution\",\n            \"megapixels\",\n            # Timestamps\n            \"date_taken\",\n            \"date_taken_subsec\",\n            \"date_modified\",\n            \"timezone_offset\",\n            # Location\n            \"gps_latitude\",\n            \"gps_longitude\",\n            \"gps_altitude\",\n            \"location_country\",\n            \"location_state\",\n            \"location_city\",\n            \"location_address\",\n            \"has_location\",\n            # Content\n            \"title\",\n            \"caption\",\n            \"keywords\",\n            \"rating\",\n            \"copyright\",\n            \"creator\",\n            # Tracking\n            \"source\",\n            \"version\",\n            \"created_at\",\n            \"updated_at\",\n            # Related\n            \"edit_history\",\n            \"sidecar_files\",\n        )\n        read_only_fields = (\n            \"id\",\n            \"resolution\",\n            \"megapixels\",\n            \"has_location\",\n            \"camera_display\",\n            \"lens_display\",\n            \"version\",\n            \"created_at\",\n            \"updated_at\",\n        )\n\n    def get_edit_history(self, obj) -> list:\n        \"\"\"Get recent edit history for this photo.\"\"\"\n        edits = MetadataEdit.objects.filter(photo=obj.photo).order_by(\"-created_at\")[:10]\n        return MetadataEditSerializer(edits, many=True).data\n\n    def get_sidecar_files(self, obj) -> list:\n        \"\"\"Get sidecar files for this photo.\"\"\"\n        files = MetadataFile.objects.filter(photo=obj.photo)\n        return MetadataFileSerializer(files, many=True).data\n\n\nclass PhotoMetadataUpdateSerializer(serializers.ModelSerializer):\n    \"\"\"\n    Serializer for updating metadata with change tracking.\n    \n    Only allows editing specific fields and automatically\n    creates MetadataEdit records for history.\n    \"\"\"\n\n    class Meta:\n        model = PhotoMetadata\n        fields = (\n            # Editable fields\n            \"title\",\n            \"caption\",\n            \"keywords\",\n            \"rating\",\n            \"copyright\",\n            \"creator\",\n            # Location (can be user-corrected)\n            \"gps_latitude\",\n            \"gps_longitude\",\n            \"location_country\",\n            \"location_state\",\n            \"location_city\",\n            \"location_address\",\n            # Timestamp (can be user-corrected)\n            \"date_taken\",\n            \"timezone_offset\",\n        )\n\n    def update(self, instance, validated_data):\n        \"\"\"Update metadata and create edit history records.\"\"\"\n        user = self.context.get(\"request\").user if self.context.get(\"request\") else None\n        \n        for field_name, new_value in validated_data.items():\n            old_value = getattr(instance, field_name)\n            \n            # Only track actual changes\n            if old_value != new_value:\n                # Create edit history record\n                MetadataEdit.objects.create(\n                    photo=instance.photo,\n                    user=user,\n                    field_name=field_name,\n                    old_value=old_value,\n                    new_value=new_value,\n                )\n                \n                # Update the field\n                setattr(instance, field_name, new_value)\n        \n        # Update source to user_edit and increment version\n        instance.source = PhotoMetadata.Source.USER_EDIT\n        instance.version += 1\n        instance.save()\n        \n        return instance\n\n\nclass PhotoMetadataSummarySerializer(serializers.Serializer):\n    \"\"\"\n    Lightweight metadata summary for photo lists.\n\n    Returns key metadata fields without the full detail.\n    \"\"\"\n\n    # Camera info\n    camera_display = serializers.CharField(allow_null=True)\n    lens_display = serializers.CharField(allow_null=True)\n    # Capture settings\n    aperture = serializers.FloatField(allow_null=True)\n    shutter_speed = serializers.CharField(allow_null=True)\n    iso = serializers.IntegerField(allow_null=True)\n    focal_length = serializers.FloatField(allow_null=True)\n    focal_length_35mm = serializers.IntegerField(allow_null=True)\n    # Image info\n    resolution = serializers.CharField(allow_null=True)\n    megapixels = serializers.FloatField(allow_null=True)\n    # Date/location\n    date_taken = serializers.DateTimeField(allow_null=True)\n    has_location = serializers.BooleanField()\n    # Content\n    rating = serializers.IntegerField(allow_null=True)\n    # Edit tracking\n    source = serializers.CharField()\n    version = serializers.IntegerField()\n    has_edits = serializers.SerializerMethodField()\n\n    def get_has_edits(self, obj) -> bool:\n        \"\"\"Check if this photo has any metadata edits.\"\"\"\n        return MetadataEdit.objects.filter(photo=obj.photo).exists()\n\n\ndef get_backwards_compatible_metadata(photo: Photo) -> dict:\n    \"\"\"\n    Generate backwards-compatible metadata dict from PhotoMetadata.\n    \n    This function returns metadata in the same format as the original\n    Photo model fields for API backwards compatibility.\n    \n    Note: Metadata fields have been fully migrated to PhotoMetadata model.\n    If no PhotoMetadata exists, return None/empty values.\n    \"\"\"\n    try:\n        metadata = photo.metadata\n        return {\n            \"camera\": metadata.camera_display,\n            \"lens\": metadata.lens_display,\n            \"fstop\": metadata.aperture,\n            \"focal_length\": metadata.focal_length,\n            \"iso\": metadata.iso,\n            \"shutter_speed\": metadata.shutter_speed,\n            \"width\": metadata.width,\n            \"height\": metadata.height,\n            \"focalLength35Equivalent\": metadata.focal_length_35mm,\n            \"digitalZoomRatio\": None,  # Not stored in PhotoMetadata\n            \"subjectDistance\": None,   # Not stored in PhotoMetadata\n        }\n    except PhotoMetadata.DoesNotExist:\n        # No PhotoMetadata exists - return None/empty values\n        # Metadata will be populated on next photo scan\n        return {\n            \"camera\": None,\n            \"lens\": None,\n            \"fstop\": None,\n            \"focal_length\": None,\n            \"iso\": None,\n            \"shutter_speed\": None,\n            \"width\": 0,\n            \"height\": 0,\n            \"focalLength35Equivalent\": None,\n            \"digitalZoomRatio\": None,\n            \"subjectDistance\": None,\n        }\n"
  },
  {
    "path": "api/serializers/photos.py",
    "content": "import json\n\nfrom rest_framework import serializers\n\nfrom api.geocode.geocode import reverse_geocode\nfrom api.geocode import GEOCODE_VERSION\nfrom api import util\n\nfrom api.image_similarity import search_similar_image\nfrom api.models import AlbumDate, File, Photo\nfrom api.models.photo_metadata import PhotoMetadata\nfrom api.serializers.photo_metadata import PhotoMetadataSummarySerializer\nfrom api.serializers.simple import SimpleUserSerializer\n\n\nclass PhotoSummarySerializer(serializers.ModelSerializer):\n    # UUID primary key\n    id = serializers.UUIDField(read_only=True)\n    # Content hash for deduplication/caching (legacy 'id' field for backwards compatibility)\n    image_hash = serializers.CharField(read_only=True)\n    dominantColor = serializers.SerializerMethodField()\n    aspectRatio = serializers.SerializerMethodField()\n    url = serializers.SerializerMethodField()\n    location = serializers.SerializerMethodField()\n    date = serializers.SerializerMethodField()\n    birthTime = serializers.SerializerMethodField()\n    video_length = serializers.SerializerMethodField()\n    type = serializers.SerializerMethodField()\n    owner = SimpleUserSerializer()\n    # Stack information (can be multiple stacks)\n    stacks = serializers.SerializerMethodField()\n    # Flag indicating if this photo has a RAW file variant (PhotoPrism-like model)\n    has_raw_variant = serializers.SerializerMethodField()\n\n    class Meta:\n        model = Photo\n        fields = (\n            \"id\",\n            \"image_hash\",\n            \"dominantColor\",\n            \"url\",\n            \"location\",\n            \"date\",\n            \"birthTime\",\n            \"aspectRatio\",\n            \"type\",\n            \"video_length\",\n            \"rating\",\n            \"owner\",\n            \"exif_gps_lat\",\n            \"exif_gps_lon\",\n            \"removed\",\n            \"in_trashcan\",\n            \"stacks\",\n            \"has_raw_variant\",\n        )\n\n    # TODO: Rename this field to aspect_ratio\n    def get_aspectRatio(self, obj) -> float:\n        return obj.thumbnail.aspect_ratio\n\n    # TODO: Remove this field in the future (kept for backwards compatibility)\n    def get_url(self, obj) -> str:\n        return obj.image_hash\n\n    def get_location(self, obj) -> str:\n        if (\n            hasattr(obj, \"search_instance\")\n            and obj.search_instance\n            and obj.search_instance.search_location\n        ):\n            return obj.search_instance.search_location\n        else:\n            return \"\"\n\n    def get_date(self, obj) -> str:\n        if obj.exif_timestamp:\n            return obj.exif_timestamp.isoformat()\n        else:\n            return \"\"\n\n    def get_video_length(self, obj) -> int:\n        if obj.video_length:\n            return obj.video_length\n        else:\n            return \"\"\n\n    # TODO: Remove this field in the future\n    def get_birthTime(self, obj) -> str:\n        if obj.exif_timestamp:\n            return obj.exif_timestamp\n        else:\n            return \"\"\n\n    def get_dominantColor(self, obj) -> str:\n        if obj.thumbnail.dominant_color:\n            dominant_color = obj.thumbnail.dominant_color[1:-1]\n            return \"#%02x%02x%02x\" % tuple(map(int, dominant_color.split(\", \")))\n        else:\n            return \"\"\n\n    def get_type(self, obj) -> str:\n        if obj.video:\n            return \"video\"\n        # Use len() instead of .count() to leverage prefetched embedded_media\n        if obj.main_file and len(obj.main_file.embedded_media.all()) > 0:\n            return \"motion_photo\"\n        return \"image\"\n\n    def get_stacks(self, obj) -> list | None:\n        \"\"\"Return stack info if photo is part of any stacks.\n\n        Uses prefetched stacks data when available (from AlbumDateViewSet)\n        to avoid N+1 queries. The prefetch filters by valid stack types and\n        annotates photo_count_annotation so no extra queries are needed.\n        \"\"\"\n        # Use the prefetch cache (obj.stacks.all() won't re-query if prefetched)\n        stacks = obj.stacks.all()\n        if not stacks:\n            return None\n\n        from api.models.photo_stack import PhotoStack\n        valid_stack_types = set(PhotoStack.VALID_STACK_TYPES + [\n            PhotoStack.StackType.RAW_JPEG_PAIR,\n            PhotoStack.StackType.LIVE_PHOTO,\n        ])\n\n        result = []\n        for stack in stacks:\n            # If stacks were prefetched with the type filter, all results are valid.\n            # If not prefetched (called from another serializer context), filter in Python.\n            if stack.stack_type not in valid_stack_types:\n                continue\n            is_primary = stack.primary_photo_id == obj.pk if stack.primary_photo_id else False\n            # Use annotated count if available, otherwise fall back to DB query\n            photo_count = getattr(stack, \"photo_count_annotation\", None)\n            if photo_count is None:\n                photo_count = stack.photos.count()\n            result.append({\n                \"id\": str(stack.id),\n                \"type\": stack.stack_type,\n                \"photo_count\": photo_count,\n                \"is_primary\": is_primary,\n            })\n\n        return result or None\n\n    def get_has_raw_variant(self, obj) -> bool:\n        \"\"\"Check if this photo has a RAW file variant.\n\n        Uses prefetched files when available to avoid N+1 queries.\n        Returns True if any of the photo's files is a RAW file type.\n        \"\"\"\n        # Use prefetch cache if available (iterate in Python), otherwise query DB\n        # File.RAW_FILE = 4\n        if \"files\" in getattr(obj, \"_prefetched_objects_cache\", {}):\n            return any(f.type == 4 for f in obj.files.all())\n        return obj.files.filter(type=4).exists()\n\n\nclass GroupedPhotosSerializer(serializers.ModelSerializer):\n    items = serializers.SerializerMethodField()\n    date = serializers.SerializerMethodField()\n    location = serializers.SerializerMethodField()\n\n    class Meta:\n        model = Photo\n        fields = (\"date\", \"location\", \"items\")\n\n    def get_date(self, obj) -> str:\n        return obj.date\n\n    def get_location(self, obj) -> str:\n        return obj.location\n\n    def get_items(self, obj) -> PhotoSummarySerializer(many=True):\n        return PhotoSummarySerializer(obj.photos, many=True).data\n\n\nclass PhotoEditSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = Photo\n        fields = (\n            \"image_hash\",\n            \"hidden\",\n            \"rating\",\n            \"in_trashcan\",\n            \"removed\",\n            \"video\",\n            \"exif_timestamp\",\n            \"timestamp\",\n            # Allow updating GPS location\n            \"exif_gps_lat\",\n            \"exif_gps_lon\",\n        )\n\n    def update(self, instance, validated_data):\n        # photo can only update the following\n        if \"exif_timestamp\" in validated_data:\n            instance.timestamp = validated_data.pop(\"exif_timestamp\")\n            instance.save()\n            instance._extract_date_time_from_exif()\n\n        # Update GPS location if provided\n        lat = validated_data.pop(\"exif_gps_lat\", None)\n        lon = validated_data.pop(\"exif_gps_lon\", None)\n\n        if lat is not None and lon is not None:\n            try:\n                # Track old places to update album place relations\n                old_album_places = instance._find_album_place()\n\n                instance.exif_gps_lat = float(lat)\n                instance.exif_gps_lon = float(lon)\n                instance.save()\n\n                # Reverse geocode and update geolocation/search location\n                geocode_result = reverse_geocode(\n                    instance.exif_gps_lat, instance.exif_gps_lon\n                )\n                if geocode_result:\n                    geocode_result[\"_v\"] = GEOCODE_VERSION\n                    instance.geolocation_json = geocode_result\n\n                    # Update search location through PhotoSearch model\n                    from api.models.photo_search import PhotoSearch\n\n                    search_instance, _created = PhotoSearch.objects.get_or_create(\n                        photo=instance\n                    )\n                    search_instance.update_search_location(geocode_result)\n                    search_instance.save()\n\n                    # Update album place relations\n                    if old_album_places is not None:\n                        for old_album_place in old_album_places:\n                            old_album_place.photos.remove(instance)\n                            old_album_place.save()\n\n                    if \"features\" in geocode_result:\n                        for geolocation_level, feature in enumerate(\n                            geocode_result[\"features\"]\n                        ):\n                            if (\n                                \"text\" not in feature.keys()\n                                or str(feature[\"text\"]).isnumeric()\n                            ):\n                                continue\n                            album_place = api.models.album_place.get_album_place(\n                                feature[\"text\"], owner=instance.owner\n                            )\n                            if (\n                                album_place.photos.filter(\n                                    image_hash=instance.image_hash\n                                ).count()\n                                == 0\n                            ):\n                                album_place.geolocation_level = (\n                                    len(geocode_result[\"features\"]) - geolocation_level\n                                )\n                            album_place.photos.add(instance)\n                            album_place.save()\n\n                    instance.save()\n                else:\n                    util.logger.warning(\n                        \"Reverse geocoding returned no result for provided coordinates\"\n                    )\n            except Exception as e:\n                util.logger.warning(e)\n                util.logger.warning(\"Failed to update GPS location for photo\")\n        return instance\n\n\nclass PhotoHashListSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = Photo\n        fields = (\"image_hash\", \"video\")\n\n\nclass PhotoDetailsSummarySerializer(serializers.ModelSerializer):\n    photo_summary = serializers.SerializerMethodField()\n    album_date_id = serializers.SerializerMethodField()\n    processing = serializers.SerializerMethodField()\n\n    class Meta:\n        model = Photo\n        fields = (\"photo_summary\", \"album_date_id\", \"processing\")\n\n    def get_photo_summary(self, obj) -> PhotoSummarySerializer:\n        return PhotoSummarySerializer(obj.get()).data\n\n    def get_processing(self, obj) -> bool:\n        return obj.get().thumbnail.aspect_ratio is None\n\n    def get_album_date_id(self, obj) -> int:\n        return (\n            AlbumDate.objects.filter(photos__in=obj)\n            .values_list(\"id\", flat=True)\n            .first()\n        )\n\n\nclass PhotoSerializer(serializers.ModelSerializer):\n    square_thumbnail_url = serializers.SerializerMethodField()\n    big_thumbnail_url = serializers.SerializerMethodField()\n    small_square_thumbnail_url = serializers.SerializerMethodField()\n    similar_photos = serializers.SerializerMethodField()\n    captions_json = serializers.SerializerMethodField()\n    search_captions = serializers.SerializerMethodField()\n    search_location = serializers.SerializerMethodField()\n    people = serializers.SerializerMethodField()\n    shared_to = serializers.PrimaryKeyRelatedField(many=True, read_only=True)\n    image_path = serializers.SerializerMethodField()\n    owner = SimpleUserSerializer(many=False, read_only=True)\n    embedded_media = serializers.SerializerMethodField()\n    # File variants (RAW, JPEG, video for Live Photos, etc.)\n    # PhotoPrism-like model where one Photo can have multiple file variants\n    file_variants = serializers.SerializerMethodField()\n    # Stack information (bursts, brackets, manual stacks) - can be multiple\n    stacks = serializers.SerializerMethodField()\n    # Structured metadata with edit history support\n    metadata = serializers.SerializerMethodField()\n    \n    # Backwards-compatible fields from PhotoMetadata (for API compatibility)\n    height = serializers.SerializerMethodField()\n    width = serializers.SerializerMethodField()\n    focal_length = serializers.SerializerMethodField()\n    fstop = serializers.SerializerMethodField()\n    iso = serializers.SerializerMethodField()\n    shutter_speed = serializers.SerializerMethodField()\n    lens = serializers.SerializerMethodField()\n    camera = serializers.SerializerMethodField()\n    focalLength35Equivalent = serializers.SerializerMethodField()\n    digitalZoomRatio = serializers.SerializerMethodField()\n    subjectDistance = serializers.SerializerMethodField()\n\n    class Meta:\n        model = Photo\n        fields = (\n            \"id\",\n            \"exif_gps_lat\",\n            \"exif_gps_lon\",\n            \"exif_timestamp\",\n            \"captions_json\",\n            \"search_captions\",\n            \"search_location\",\n            \"big_thumbnail_url\",\n            \"square_thumbnail_url\",\n            \"small_square_thumbnail_url\",\n            \"geolocation_json\",\n            \"exif_json\",\n            \"people\",\n            \"image_hash\",\n            \"image_path\",\n            \"rating\",\n            \"hidden\",\n            \"public\",\n            \"removed\",\n            \"in_trashcan\",\n            \"shared_to\",\n            \"similar_photos\",\n            \"video\",\n            \"owner\",\n            \"size\",\n            \"height\",\n            \"width\",\n            \"focal_length\",\n            \"fstop\",\n            \"iso\",\n            \"shutter_speed\",\n            \"lens\",\n            \"camera\",\n            \"focalLength35Equivalent\",\n            \"digitalZoomRatio\",\n            \"subjectDistance\",\n            \"embedded_media\",\n            \"file_variants\",\n            \"stacks\",\n            \"metadata\",\n        )\n    \n    def _get_metadata(self, obj) -> PhotoMetadata | None:\n        \"\"\"Helper to get PhotoMetadata, with caching.\"\"\"\n        if not hasattr(obj, '_cached_metadata'):\n            try:\n                obj._cached_metadata = obj.metadata\n            except PhotoMetadata.DoesNotExist:\n                obj._cached_metadata = None\n        return obj._cached_metadata\n    \n    def get_height(self, obj) -> int:\n        metadata = self._get_metadata(obj)\n        return metadata.height if metadata else 0\n    \n    def get_width(self, obj) -> int:\n        metadata = self._get_metadata(obj)\n        return metadata.width if metadata else 0\n    \n    def get_focal_length(self, obj) -> float | None:\n        metadata = self._get_metadata(obj)\n        return metadata.focal_length if metadata else None\n    \n    def get_fstop(self, obj) -> float | None:\n        metadata = self._get_metadata(obj)\n        return metadata.aperture if metadata else None\n    \n    def get_iso(self, obj) -> int | None:\n        metadata = self._get_metadata(obj)\n        return metadata.iso if metadata else None\n    \n    def get_shutter_speed(self, obj) -> str | None:\n        metadata = self._get_metadata(obj)\n        return metadata.shutter_speed if metadata else None\n    \n    def get_lens(self, obj) -> str | None:\n        metadata = self._get_metadata(obj)\n        return metadata.lens_display if metadata else None\n    \n    def get_camera(self, obj) -> str | None:\n        metadata = self._get_metadata(obj)\n        return metadata.camera_display if metadata else None\n    \n    def get_focalLength35Equivalent(self, obj) -> int | None:\n        metadata = self._get_metadata(obj)\n        return metadata.focal_length_35mm if metadata else None\n    \n    def get_digitalZoomRatio(self, obj) -> float | None:\n        # Not stored in PhotoMetadata (rarely used field)\n        return None\n    \n    def get_subjectDistance(self, obj) -> float | None:\n        # Not stored in PhotoMetadata (rarely used field)\n        return None\n\n    def get_similar_photos(self, obj) -> list:\n        res = search_similar_image(obj.owner, obj, threshold=90)\n        arr = []\n        if len(res) > 0:\n            [arr.append(e) for e in res[\"result\"]]\n            photos = Photo.objects.filter(image_hash__in=arr).all()\n            res = []\n            for photo in photos:\n                type = \"image\"\n                if photo.video:\n                    type = \"video\"\n                res.append({\"image_hash\": photo.image_hash, \"type\": type})\n            return res\n        else:\n            return []\n\n    def get_captions_json(self, obj) -> dict:\n        if (\n            hasattr(obj, \"caption_instance\")\n            and obj.caption_instance\n            and obj.caption_instance.captions_json\n            and len(obj.caption_instance.captions_json) > 0\n        ):\n            return obj.caption_instance.captions_json\n        else:\n            emptyArray = {\n                \"im2txt\": \"\",\n                \"places365\": {\"attributes\": [], \"categories\": [], \"environment\": []},\n            }\n            return emptyArray\n\n    def get_search_captions(self, obj) -> str:\n        if hasattr(obj, \"search_instance\") and obj.search_instance:\n            return obj.search_instance.search_captions or \"\"\n        return \"\"\n\n    def get_search_location(self, obj) -> str:\n        if hasattr(obj, \"search_instance\") and obj.search_instance:\n            return obj.search_instance.search_location or \"\"\n        return \"\"\n\n    def get_image_path(self, obj) -> list[str]:\n        try:\n            paths = []\n            for file in obj.files.all():\n                paths.append(file.path)\n            return paths\n        except Exception:\n            return [\"Missing\"]\n\n    def get_square_thumbnail_url(self, obj) -> str:\n        return (\n            obj.thumbnail.square_thumbnail.url if obj.thumbnail.square_thumbnail else \"\"\n        )\n\n    def get_small_square_thumbnail_url(self, obj) -> str:\n        return (\n            obj.thumbnail.square_thumbnail_small.url\n            if obj.thumbnail.square_thumbnail_small\n            else \"\"\n        )\n\n    def get_big_thumbnail_url(self, obj) -> str:\n        return obj.thumbnail.thumbnail_big.url if obj.thumbnail.thumbnail_big else \"\"\n\n    def get_geolocation(self, obj) -> dict:\n        if obj.geolocation_json:\n            return json.loads(obj.geolocation_json)\n        else:\n            return None\n\n    def get_people(self, obj) -> list:\n        return [\n            {\n                \"name\": (\n                    f.person.name\n                    if f.person\n                    else (\n                        f.cluster_person.name\n                        if f.cluster_person\n                        else (\n                            f.classification_person.name\n                            if f.classification_person\n                            else \"\"\n                        )\n                    )\n                ),\n                \"type\": (\n                    \"user\"\n                    if f.person\n                    else (\n                        \"cluster\"\n                        if f.cluster_person\n                        else (\"classification\" if f.classification_person else \"\")\n                    )\n                ),\n                \"probability\": (\n                    1\n                    if f.person\n                    else (\n                        f.cluster_probability\n                        if f.cluster_person\n                        else (\n                            f.classification_probability\n                            if f.classification_person\n                            else 0\n                        )\n                    )\n                ),\n                \"location\": {\n                    \"top\": f.location_top,\n                    \"bottom\": f.location_bottom,\n                    \"left\": f.location_left,\n                    \"right\": f.location_right,\n                },\n                \"face_url\": f.image.url,\n                \"face_id\": f.id,\n            }\n            for f in obj.faces.all()\n        ]\n\n    def get_embedded_media(self, obj: Photo) -> list[dict]:\n        def serialize_file(file):\n            return {\n                \"id\": file.hash,\n                \"type\": \"video\" if file.type == File.VIDEO else \"image\",\n            }\n\n        if obj.main_file is None:\n            return []\n        embedded_media = obj.main_file.embedded_media.all()\n        if len(embedded_media) == 0:\n            return []\n        return list(\n            map(\n                serialize_file, embedded_media.filter(type__in=[File.VIDEO, File.IMAGE])\n            )\n        )\n\n    def get_metadata(self, obj: Photo) -> dict | None:\n        \"\"\"\n        Return structured metadata from PhotoMetadata if available.\n        \n        This provides:\n        - Normalized field names (aperture, iso, shutter_speed, etc.)\n        - Computed display strings (camera_display, lens_display)\n        - Resolution and megapixel info\n        - Edit tracking (version, source, has_edits)\n        \n        Falls back to None if PhotoMetadata doesn't exist (backwards compatible).\n        \"\"\"\n        try:\n            metadata = obj.metadata\n            return PhotoMetadataSummarySerializer(metadata).data\n        except PhotoMetadata.DoesNotExist:\n            return None\n\n    def get_file_variants(self, obj: Photo) -> list | None:\n        \"\"\"Return file variants for this photo (RAW, JPEG, video for Live Photos, etc.).\n        \n        This implements the PhotoPrism-like model where one Photo can have multiple\n        file variants representing the same capture moment.\n        \"\"\"\n        from api.models.file import File\n        \n        files = obj.files.all()\n        if files.count() <= 1:\n            # Only main file, no additional variants\n            return None\n        \n        variants = []\n        for f in files:\n            # Determine file type label\n            file_type_map = {\n                File.IMAGE: \"image\",\n                File.VIDEO: \"video\",\n                File.RAW_FILE: \"raw\",\n                File.METADATA_FILE: \"metadata\",\n                File.UNKNOWN: \"unknown\",\n            }\n            file_type = file_type_map.get(f.type, \"unknown\")\n            \n            # Check if this is the main file\n            is_main = obj.main_file_id == f.hash if obj.main_file_id else False\n            \n            variants.append({\n                \"hash\": f.hash,\n                \"path\": f.path,\n                \"type\": file_type,\n                \"type_id\": f.type,\n                \"is_main\": is_main,\n                \"filename\": f.path.split(\"/\")[-1] if f.path else None,\n            })\n        \n        return variants\n\n    def get_stacks(self, obj: Photo) -> list | None:\n        \"\"\"Return detailed stack info for photo detail view (supports multiple stacks).\"\"\"\n        from api.models.photo_stack import PhotoStack\n        # Use model-defined valid stack types, plus deprecated types for backwards compatibility\n        valid_stack_types = PhotoStack.VALID_STACK_TYPES + [\n            PhotoStack.StackType.RAW_JPEG_PAIR,\n            PhotoStack.StackType.LIVE_PHOTO,\n        ]\n        stacks = obj.stacks.filter(stack_type__in=valid_stack_types)\n        if not stacks.exists():\n            return None\n        \n        result = []\n        for stack in stacks:\n            is_primary = stack.primary_photo_id == obj.pk if stack.primary_photo_id else False\n            \n            # Get all photos in the stack for the detail view\n            stack_photos = []\n            for photo in stack.photos.select_related(\"thumbnail\").all():\n                # Get width/height from PhotoMetadata\n                try:\n                    photo_metadata = photo.metadata\n                    photo_width = photo_metadata.width or 0\n                    photo_height = photo_metadata.height or 0\n                except PhotoMetadata.DoesNotExist:\n                    photo_width = 0\n                    photo_height = 0\n                \n                stack_photos.append({\n                    \"id\": str(photo.id),\n                    \"image_hash\": photo.image_hash,\n                    \"is_primary\": photo.pk == stack.primary_photo_id,\n                    \"thumbnail_url\": (\n                        f\"/media/square_thumbnails_small/{photo.image_hash}\"\n                        if hasattr(photo, \"thumbnail\") and photo.thumbnail and photo.thumbnail.square_thumbnail_small \n                        else None\n                    ),\n                    \"size\": photo.size,\n                    \"width\": photo_width,\n                    \"height\": photo_height,\n                })\n            \n            result.append({\n                \"id\": str(stack.id),\n                \"type\": stack.stack_type,\n                \"type_display\": stack.get_stack_type_display(),\n                \"photo_count\": len(stack_photos),\n                \"is_primary\": is_primary,\n                \"photos\": stack_photos,\n            })\n        \n        return result\n\n\nclass SharedFromMePhotoThroughSerializer(serializers.ModelSerializer):\n    photo = serializers.SerializerMethodField()\n    user = SimpleUserSerializer(many=False, read_only=True)\n\n    class Meta:\n        model = Photo.shared_to.through\n        fields = (\"user_id\", \"user\", \"photo\")\n\n    def get_photo(self, obj) -> PhotoSummarySerializer:\n        return PhotoSummarySerializer(obj.photo).data\n\n\nclass PublicPhotoDetailSerializer(serializers.ModelSerializer):\n    \"\"\"Serializer for photo details in public albums.\n    \n    Conditionally includes metadata based on sharing settings passed in context.\n    Context must include 'sharing_settings' dict with keys:\n    - share_location: bool\n    - share_camera_info: bool\n    - share_timestamps: bool\n    - share_captions: bool\n    - share_faces: bool\n    \"\"\"\n    \n    # Always included\n    square_thumbnail_url = serializers.SerializerMethodField()\n    big_thumbnail_url = serializers.SerializerMethodField()\n    small_square_thumbnail_url = serializers.SerializerMethodField()\n    video = serializers.BooleanField(read_only=True)\n    image_hash = serializers.CharField(read_only=True)\n    \n    # Conditionally included based on sharing settings\n    exif_timestamp = serializers.SerializerMethodField()\n    exif_gps_lat = serializers.SerializerMethodField()\n    exif_gps_lon = serializers.SerializerMethodField()\n    geolocation_json = serializers.SerializerMethodField()\n    search_location = serializers.SerializerMethodField()\n    \n    # Camera info\n    camera = serializers.SerializerMethodField()\n    lens = serializers.SerializerMethodField()\n    focal_length = serializers.SerializerMethodField()\n    fstop = serializers.SerializerMethodField()\n    iso = serializers.SerializerMethodField()\n    shutter_speed = serializers.SerializerMethodField()\n    width = serializers.SerializerMethodField()\n    height = serializers.SerializerMethodField()\n    \n    # Captions\n    search_captions = serializers.SerializerMethodField()\n    captions_json = serializers.SerializerMethodField()\n    \n    # People/faces\n    people = serializers.SerializerMethodField()\n\n    class Meta:\n        model = Photo\n        fields = (\n            \"image_hash\",\n            \"video\",\n            \"square_thumbnail_url\",\n            \"big_thumbnail_url\",\n            \"small_square_thumbnail_url\",\n            \"exif_timestamp\",\n            \"exif_gps_lat\",\n            \"exif_gps_lon\",\n            \"geolocation_json\",\n            \"search_location\",\n            \"camera\",\n            \"lens\",\n            \"focal_length\",\n            \"fstop\",\n            \"iso\",\n            \"shutter_speed\",\n            \"width\",\n            \"height\",\n            \"search_captions\",\n            \"captions_json\",\n            \"people\",\n        )\n\n    def _get_sharing_settings(self) -> dict:\n        \"\"\"Get sharing settings from context.\"\"\"\n        return self.context.get('sharing_settings', {})\n    \n    def _get_metadata(self, obj) -> PhotoMetadata | None:\n        \"\"\"Helper to get PhotoMetadata.\"\"\"\n        if not hasattr(obj, '_cached_metadata'):\n            try:\n                obj._cached_metadata = obj.metadata\n            except PhotoMetadata.DoesNotExist:\n                obj._cached_metadata = None\n        return obj._cached_metadata\n\n    # Always available\n    def get_square_thumbnail_url(self, obj) -> str:\n        return obj.thumbnail.square_thumbnail.url if obj.thumbnail and obj.thumbnail.square_thumbnail else \"\"\n\n    def get_small_square_thumbnail_url(self, obj) -> str:\n        return obj.thumbnail.square_thumbnail_small.url if obj.thumbnail and obj.thumbnail.square_thumbnail_small else \"\"\n\n    def get_big_thumbnail_url(self, obj) -> str:\n        return obj.thumbnail.thumbnail_big.url if obj.thumbnail and obj.thumbnail.thumbnail_big else \"\"\n\n    # Timestamp - conditional\n    def get_exif_timestamp(self, obj):\n        if self._get_sharing_settings().get('share_timestamps', False):\n            return obj.exif_timestamp\n        return None\n\n    # Location - conditional\n    def get_exif_gps_lat(self, obj):\n        if self._get_sharing_settings().get('share_location', False):\n            return obj.exif_gps_lat\n        return None\n\n    def get_exif_gps_lon(self, obj):\n        if self._get_sharing_settings().get('share_location', False):\n            return obj.exif_gps_lon\n        return None\n\n    def get_geolocation_json(self, obj):\n        if self._get_sharing_settings().get('share_location', False):\n            return obj.geolocation_json\n        return None\n\n    def get_search_location(self, obj) -> str:\n        if self._get_sharing_settings().get('share_location', False):\n            if hasattr(obj, \"search_instance\") and obj.search_instance:\n                return obj.search_instance.search_location or \"\"\n        return \"\"\n\n    # Camera info - conditional\n    def get_camera(self, obj) -> str | None:\n        if self._get_sharing_settings().get('share_camera_info', False):\n            metadata = self._get_metadata(obj)\n            return metadata.camera_display if metadata else None\n        return None\n\n    def get_lens(self, obj) -> str | None:\n        if self._get_sharing_settings().get('share_camera_info', False):\n            metadata = self._get_metadata(obj)\n            return metadata.lens_display if metadata else None\n        return None\n\n    def get_focal_length(self, obj) -> float | None:\n        if self._get_sharing_settings().get('share_camera_info', False):\n            metadata = self._get_metadata(obj)\n            return metadata.focal_length if metadata else None\n        return None\n\n    def get_fstop(self, obj) -> float | None:\n        if self._get_sharing_settings().get('share_camera_info', False):\n            metadata = self._get_metadata(obj)\n            return metadata.aperture if metadata else None\n        return None\n\n    def get_iso(self, obj) -> int | None:\n        if self._get_sharing_settings().get('share_camera_info', False):\n            metadata = self._get_metadata(obj)\n            return metadata.iso if metadata else None\n        return None\n\n    def get_shutter_speed(self, obj) -> str | None:\n        if self._get_sharing_settings().get('share_camera_info', False):\n            metadata = self._get_metadata(obj)\n            return metadata.shutter_speed if metadata else None\n        return None\n\n    def get_width(self, obj) -> int:\n        if self._get_sharing_settings().get('share_camera_info', False):\n            metadata = self._get_metadata(obj)\n            return metadata.width if metadata else 0\n        return 0\n\n    def get_height(self, obj) -> int:\n        if self._get_sharing_settings().get('share_camera_info', False):\n            metadata = self._get_metadata(obj)\n            return metadata.height if metadata else 0\n        return 0\n\n    # Captions - conditional\n    def get_search_captions(self, obj) -> str:\n        if self._get_sharing_settings().get('share_captions', False):\n            if hasattr(obj, \"search_instance\") and obj.search_instance:\n                return obj.search_instance.search_captions or \"\"\n        return \"\"\n\n    def get_captions_json(self, obj) -> dict:\n        if self._get_sharing_settings().get('share_captions', False):\n            if (\n                hasattr(obj, \"caption_instance\")\n                and obj.caption_instance\n                and obj.caption_instance.captions_json\n                and len(obj.caption_instance.captions_json) > 0\n            ):\n                return obj.caption_instance.captions_json\n        return {\"im2txt\": \"\", \"places365\": {\"attributes\": [], \"categories\": [], \"environment\": []}}\n\n    # People/faces - conditional\n    def get_people(self, obj) -> list:\n        if not self._get_sharing_settings().get('share_faces', False):\n            return []\n        \n        return [\n            {\n                \"name\": (\n                    f.person.name\n                    if f.person\n                    else (\n                        f.cluster_person.name\n                        if f.cluster_person\n                        else \"Unknown\"\n                    )\n                ),\n                \"face_url\": f.image.url if f.image else None,\n                \"face_id\": f.id,\n            }\n            for f in obj.faces.all()\n            if f.person or f.cluster_person\n        ]\n"
  },
  {
    "path": "api/serializers/simple.py",
    "content": "from rest_framework import serializers\n\nfrom api.models import Photo, User\n\n\nclass PhotoSuperSimpleSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = Photo\n        fields = (\"image_hash\", \"rating\", \"hidden\", \"exif_timestamp\", \"public\", \"video\")\n\n\nclass PhotoSimpleSerializer(serializers.ModelSerializer):\n    square_thumbnail = serializers.SerializerMethodField()\n\n    class Meta:\n        model = Photo\n        fields = (\n            \"square_thumbnail\",\n            \"image_hash\",\n            \"exif_timestamp\",\n            \"exif_gps_lat\",\n            \"exif_gps_lon\",\n            \"rating\",\n            \"geolocation_json\",\n            \"public\",\n            \"video\",\n        )\n\n    def get_square_thumbnail(self, obj) -> str:\n        return (\n            obj.thumbnail.square_thumbnail.url\n            if obj.thumbnail and obj.thumbnail.square_thumbnail\n            else \"\"\n        )\n\n\nclass SimpleUserSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = User\n        fields = (\n            \"id\",\n            \"username\",\n            \"first_name\",\n            \"last_name\",\n        )\n"
  },
  {
    "path": "api/serializers/user.py",
    "content": "import os\n\nfrom django.conf import settings\nfrom django.contrib.auth import get_user_model\nfrom django.db.models import Q\nfrom django_q.tasks import Chain\nfrom rest_framework import serializers\nfrom rest_framework.exceptions import ValidationError\n\nfrom api.batch_jobs import batch_calculate_clip_embedding\nfrom api.ml_models import do_all_models_exist, download_models\nfrom api.models import Photo, User\nfrom api.serializers.simple import PhotoSuperSimpleSerializer\nfrom api.util import is_valid_path, logger\n\n\nclass UserSerializer(serializers.ModelSerializer):\n    public_photo_count = serializers.SerializerMethodField()\n    public_photo_samples = serializers.SerializerMethodField()\n    photo_count = serializers.SerializerMethodField()\n    avatar_url = serializers.SerializerMethodField()\n\n    class Meta:\n        model = User\n        extra_kwargs = {\n            \"password\": {\"write_only\": True},\n            \"first_name\": {\"required\": False},\n            \"last_name\": {\"required\": False},\n            \"scan_directory\": {\"required\": False},\n            \"confidence\": {\"required\": False},\n            \"confidence_person\": {\"required\": False},\n            \"semantic_search_topk\": {\"required\": False},\n            \"nextcloud_server_address\": {\"required\": False},\n            \"nextcloud_username\": {\"required\": False},\n            \"nextcloud_scan_directory\": {\"required\": False},\n            \"nextcloud_app_password\": {\"write_only\": True},\n            \"favorite_min_rating\": {\"required\": False},\n            \"save_metadata_to_disk\": {\"required\": False},\n            \"save_face_tags_to_disk\": {\"required\": False},\n            \"text_alignment\": {\"required\": False},\n            \"header_size\": {\"required\": False},\n            \"skip_raw_files\": {\"required\": False},\n            \"stack_raw_jpeg\": {\"required\": False},\n            \"slideshow_interval\": {\"required\": False},\n            \"duplicate_sensitivity\": {\"required\": False},\n            \"duplicate_clear_existing\": {\"required\": False},\n        }\n        fields = (\n            \"id\",\n            \"username\",\n            \"email\",\n            \"scan_directory\",\n            \"confidence\",\n            \"confidence_person\",\n            \"transcode_videos\",\n            \"semantic_search_topk\",\n            \"first_name\",\n            \"public_photo_samples\",\n            \"last_name\",\n            \"public_photo_count\",\n            \"date_joined\",\n            \"password\",\n            \"avatar\",\n            \"is_superuser\",\n            \"photo_count\",\n            \"nextcloud_server_address\",\n            \"nextcloud_username\",\n            \"nextcloud_app_password\",\n            \"nextcloud_scan_directory\",\n            \"avatar_url\",\n            \"favorite_min_rating\",\n            \"image_scale\",\n            \"text_alignment\",\n            \"header_size\",\n            \"save_metadata_to_disk\",\n            \"save_face_tags_to_disk\",\n            \"datetime_rules\",\n            \"burst_detection_rules\",\n            \"llm_settings\",\n            \"default_timezone\",\n            \"public_sharing\",\n            \"public_sharing_defaults\",\n            \"face_recognition_model\",\n            \"min_cluster_size\",\n            \"confidence_unknown_face\",\n            \"min_samples\",\n            \"cluster_selection_epsilon\",\n            \"skip_raw_files\",\n            \"stack_raw_jpeg\",\n            \"slideshow_interval\",\n            \"duplicate_sensitivity\",\n            \"duplicate_clear_existing\",\n        )\n\n    def validate_nextcloud_app_password(self, value):\n        return value\n\n    def create(self, validated_data):\n        if \"scan_directory\" in validated_data.keys():\n            if (\n                not self.context[\"request\"].user.is_superuser\n                or validated_data[\"scan_directory\"] == \"initial\"\n            ):\n                validated_data.pop(\"scan_directory\")\n        # make sure username is always lowercase\n        if \"username\" in validated_data.keys():\n            validated_data[\"username\"] = validated_data[\"username\"].lower()\n        if \"is_superuser\" in validated_data.keys():\n            is_superuser = validated_data.pop(\"is_superuser\")\n            if is_superuser and self.context[\"request\"].user.is_authenticated and self.context[\"request\"].user.is_superuser:\n                user = User.objects.create_superuser(**validated_data)\n            else:\n                user = User.objects.create_user(**validated_data)\n        else:\n            user = User.objects.create_user(**validated_data)\n        logger.info(f\"Created user {user.id}\")\n        return user\n\n    def update(self, instance, validated_data):\n        # user can only update the following\n        if \"password\" in validated_data:\n            password = validated_data.pop(\"password\")\n            if password != \"\" and not settings.DEMO_SITE:\n                instance.set_password(password)\n        if \"avatar\" in validated_data:\n            instance.avatar = validated_data.pop(\"avatar\")\n            instance.save()\n        if \"email\" in validated_data:\n            instance.email = validated_data.pop(\"email\")\n            instance.save()\n        if \"first_name\" in validated_data:\n            instance.first_name = validated_data.pop(\"first_name\")\n            instance.save()\n        if \"last_name\" in validated_data:\n            instance.last_name = validated_data.pop(\"last_name\")\n            instance.save()\n        if \"transcode_videos\" in validated_data:\n            instance.transcode_videos = validated_data.pop(\"transcode_videos\")\n            instance.save()\n        if \"nextcloud_server_address\" in validated_data:\n            instance.nextcloud_server_address = validated_data.pop(\n                \"nextcloud_server_address\"\n            )\n            instance.save()\n        if \"nextcloud_username\" in validated_data:\n            instance.nextcloud_username = validated_data.pop(\"nextcloud_username\")\n            instance.save()\n        if \"nextcloud_app_password\" in validated_data:\n            instance.nextcloud_app_password = validated_data.pop(\n                \"nextcloud_app_password\"\n            )\n            instance.save()\n        if \"nextcloud_scan_directory\" in validated_data:\n            instance.nextcloud_scan_directory = validated_data.pop(\n                \"nextcloud_scan_directory\"\n            )\n            instance.save()\n        if \"confidence\" in validated_data:\n            instance.confidence = validated_data.pop(\"confidence\")\n            instance.save()\n            logger.info(f\"Updated confidence for user {instance.confidence}\")\n        if \"confidence_person\" in validated_data:\n            instance.confidence_person = validated_data.pop(\"confidence_person\")\n            instance.save()\n            logger.info(\n                f\"Updated person album confidence for user {instance.confidence_person}\"\n            )\n        if \"semantic_search_topk\" in validated_data:\n            new_semantic_search_topk = validated_data.pop(\"semantic_search_topk\")\n\n            if instance.semantic_search_topk == 0 and new_semantic_search_topk > 0:\n                chain = Chain()\n                if not do_all_models_exist():\n                    chain.append(download_models, User.objects.get(id=instance.id))\n                chain.append(\n                    batch_calculate_clip_embedding, User.objects.get(id=instance.id)\n                )\n                chain.run()\n\n            instance.semantic_search_topk = new_semantic_search_topk\n            instance.save()\n            logger.info(\n                f\"Updated semantic_search_topk for user {instance.semantic_search_topk}\"\n            )\n        if \"favorite_min_rating\" in validated_data:\n            new_favorite_min_rating = validated_data.pop(\"favorite_min_rating\")\n            instance.favorite_min_rating = new_favorite_min_rating\n            instance.save()\n            logger.info(\n                f\"Updated favorite_min_rating for user {instance.favorite_min_rating}\"\n            )\n        if \"save_metadata_to_disk\" in validated_data:\n            instance.save_metadata_to_disk = validated_data.pop(\"save_metadata_to_disk\")\n            instance.save()\n            logger.info(\n                f\"Updated save_metadata_to_disk for user {instance.save_metadata_to_disk}\"\n            )\n        if \"save_face_tags_to_disk\" in validated_data:\n            instance.save_face_tags_to_disk = validated_data.pop(\n                \"save_face_tags_to_disk\"\n            )\n            instance.save()\n            logger.info(\n                f\"Updated save_face_tags_to_disk to {instance.save_face_tags_to_disk} for user {instance.username}\"\n            )\n        if \"image_scale\" in validated_data:\n            new_image_scale = validated_data.pop(\"image_scale\")\n            instance.image_scale = new_image_scale\n            instance.save()\n            logger.info(f\"Updated image_scale for user {instance.image_scale}\")\n        if \"text_alignment\" in validated_data:\n            new_text_alignment = validated_data.pop(\"text_alignment\")\n            instance.text_alignment = new_text_alignment\n            instance.save()\n            logger.info(f\"Updated text_alignment for user {instance.text_alignment}\")\n        if \"header_size\" in validated_data:\n            new_header_size = validated_data.pop(\"header_size\")\n            instance.header_size = new_header_size\n            instance.save()\n            logger.info(f\"Updated header_size for user {instance.header_size}\")\n        if \"datetime_rules\" in validated_data:\n            new_datetime_rules = validated_data.pop(\"datetime_rules\")\n            instance.datetime_rules = new_datetime_rules\n            instance.save()\n            logger.info(f\"Updated datetime_rules for user {instance.datetime_rules}\")\n        if \"default_timezone\" in validated_data:\n            new_default_timezone = validated_data.pop(\"default_timezone\")\n            instance.default_timezone = new_default_timezone\n            instance.save()\n            logger.info(\n                f\"Updated default_timezone for user {instance.default_timezone}\"\n            )\n        if \"public_sharing\" in validated_data:\n            instance.public_sharing = validated_data.pop(\"public_sharing\")\n            instance.save()\n        if \"face_recognition_model\" in validated_data:\n            instance.face_recognition_model = validated_data.pop(\n                \"face_recognition_model\"\n            )\n            instance.save()\n        if \"min_cluster_size\" in validated_data:\n            instance.min_cluster_size = validated_data.pop(\"min_cluster_size\")\n            instance.save()\n        if \"confidence_unknown_face\" in validated_data:\n            instance.confidence_unknown_face = validated_data.pop(\n                \"confidence_unknown_face\"\n            )\n            instance.save()\n        if \"min_samples\" in validated_data:\n            instance.min_samples = validated_data.pop(\"min_samples\")\n            instance.save()\n        if \"cluster_selection_epsilon\" in validated_data:\n            instance.cluster_selection_epsilon = validated_data.pop(\n                \"cluster_selection_epsilon\"\n            )\n            instance.save()\n        if \"llm_settings\" in validated_data:\n            instance.llm_settings = validated_data.pop(\"llm_settings\")\n            instance.save()\n        if \"skip_raw_files\" in validated_data:\n            instance.skip_raw_files = validated_data.pop(\"skip_raw_files\")\n            instance.save()\n            logger.info(\n                f\"Updated skip_raw_files to {instance.skip_raw_files} for user {instance.username}\"\n            )\n        if \"stack_raw_jpeg\" in validated_data:\n            instance.stack_raw_jpeg = validated_data.pop(\"stack_raw_jpeg\")\n            instance.save()\n            logger.info(\n                f\"Updated stack_raw_jpeg to {instance.stack_raw_jpeg} for user {instance.username}\"\n            )\n        if \"slideshow_interval\" in validated_data:\n            instance.slideshow_interval = validated_data.pop(\"slideshow_interval\")\n            instance.save()\n            logger.info(\n                f\"Updated slideshow_interval to {instance.slideshow_interval} for user {instance.username}\"\n            )\n        if \"duplicate_sensitivity\" in validated_data:\n            instance.duplicate_sensitivity = validated_data.pop(\"duplicate_sensitivity\")\n            instance.save()\n            logger.info(\n                f\"Updated duplicate_sensitivity to {instance.duplicate_sensitivity} for user {instance.username}\"\n            )\n        if \"duplicate_clear_existing\" in validated_data:\n            instance.duplicate_clear_existing = validated_data.pop(\n                \"duplicate_clear_existing\"\n            )\n            instance.save()\n            logger.info(\n                f\"Updated duplicate_clear_existing to {instance.duplicate_clear_existing} for user {instance.username}\"\n            )\n\n        return instance\n\n    def get_photo_count(self, obj) -> int:\n        return Photo.objects.filter(owner=obj).count()\n\n    def get_public_photo_count(self, obj) -> int:\n        return Photo.objects.filter(Q(owner=obj) & Q(public=True)).count()\n\n    def get_public_photo_samples(self, obj) -> PhotoSuperSimpleSerializer(many=True):\n        return PhotoSuperSimpleSerializer(\n            Photo.objects.filter(Q(owner=obj) & Q(public=True))[:10], many=True\n        ).data\n\n    def get_avatar_url(self, obj) -> str or None:\n        try:\n            return obj.avatar.url\n        except Exception:\n            return None\n\n\nclass PublicUserSerializer(serializers.ModelSerializer):\n    public_photo_count = serializers.SerializerMethodField()\n    public_photo_samples = serializers.SerializerMethodField()\n    avatar_url = serializers.SerializerMethodField()\n\n    class Meta:\n        model = User\n        fields = (\n            \"id\",\n            \"avatar_url\",\n            \"username\",\n            \"first_name\",\n            \"last_name\",\n            \"public_photo_count\",\n            \"public_photo_samples\",\n        )\n\n    def get_public_photo_count(self, obj) -> int:\n        return Photo.objects.filter(Q(owner=obj) & Q(public=True)).count()\n\n    def get_public_photo_samples(self, obj) -> PhotoSuperSimpleSerializer(many=True):\n        return PhotoSuperSimpleSerializer(\n            Photo.objects.filter(Q(owner=obj) & Q(public=True))[:10], many=True\n        ).data\n\n    def get_avatar_url(self, obj) -> str or None:\n        try:\n            return obj.avatar.url\n        except ValueError:\n            return None\n\n\nclass SignupUserSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = User\n        extra_kwargs = {\n            \"username\": {\"required\": True},\n            \"password\": {\n                \"write_only\": True,\n                \"required\": True,\n                \"min_length\": 3,  # configurable min password length?\n            },\n            \"email\": {\"required\": True},\n            \"first_name\": {\"required\": True},\n            \"last_name\": {\"required\": True},\n            \"is_superuser\": {\"write_only\": True},\n        }\n        fields = (\n            \"username\",\n            \"password\",\n            \"email\",\n            \"first_name\",\n            \"last_name\",\n            \"is_superuser\",\n        )\n\n    def create(self, validated_data):\n        should_be_superuser = User.objects.filter(is_superuser=True).count() == 0\n        user = super().create(validated_data)\n        user.set_password(validated_data.pop(\"password\"))\n        user.is_staff = should_be_superuser\n        user.is_superuser = should_be_superuser\n        user.save()\n        return user\n\n\nclass DeleteUserSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = get_user_model()\n        fields = \"__all__\"\n\n\nclass ManageUserSerializer(serializers.ModelSerializer):\n    photo_count = serializers.SerializerMethodField()\n\n    class Meta:\n        model = get_user_model()\n        fields = (\n            \"username\",\n            \"scan_directory\",\n            \"skip_raw_files\",\n            \"stack_raw_jpeg\",\n            \"confidence\",\n            \"semantic_search_topk\",\n            \"last_login\",\n            \"date_joined\",\n            \"photo_count\",\n            \"id\",\n            \"favorite_min_rating\",\n            \"image_scale\",\n            \"save_metadata_to_disk\",\n            \"email\",\n            \"first_name\",\n            \"last_name\",\n            \"password\",\n        )\n        extra_kwargs = {\n            \"password\": {\"write_only\": True},\n            \"scan_directory\": {\"required\": False},\n            \"skip_raw_files\": {\"required\": False},\n            \"stack_raw_jpeg\": {\"required\": False},\n        }\n\n    def get_photo_count(self, obj) -> int:\n        return Photo.objects.filter(owner=obj).count()\n\n    def update(self, instance: User, validated_data):\n        if \"password\" in validated_data:\n            password = validated_data.pop(\"password\")\n            if password != \"\" and not settings.DEMO_SITE:\n                instance.set_password(password)\n\n        if \"scan_directory\" in validated_data:\n            new_scan_directory = validated_data.pop(\"scan_directory\")\n\n            if new_scan_directory:  # Ensure it's not an empty string\n                abs_new_scan_directory = os.path.abspath(new_scan_directory)\n\n                if not is_valid_path(abs_new_scan_directory, settings.DATA_ROOT):\n                    raise ValidationError(\n                        \"Scan directory must be inside the data root.\"\n                    )\n\n                if os.path.exists(abs_new_scan_directory):\n                    instance.scan_directory = abs_new_scan_directory\n                    logger.info(\n                        f\"Updated scan directory for user {instance.scan_directory}\"\n                    )\n                else:\n                    raise ValidationError(\"Scan directory does not exist\")\n        if \"skip_raw_files\" in validated_data:\n            instance.skip_raw_files = validated_data.pop(\"skip_raw_files\")\n        if \"stack_raw_jpeg\" in validated_data:\n            instance.stack_raw_jpeg = validated_data.pop(\"stack_raw_jpeg\")\n\n        if \"username\" in validated_data:\n            username = validated_data.pop(\"username\")\n            if username != \"\":\n                other_user = User.objects.filter(username=username).first()\n                if other_user is not None and other_user != instance:\n                    raise ValidationError(\"User name is already taken\")\n\n            instance.username = username\n\n        if \"email\" in validated_data:\n            email = validated_data.pop(\"email\")\n            instance.email = email\n\n        if \"first_name\" in validated_data:\n            first_name = validated_data.pop(\"first_name\")\n            instance.first_name = first_name\n\n        if \"last_name\" in validated_data:\n            last_name = validated_data.pop(\"last_name\")\n            instance.last_name = last_name\n\n        instance.save()\n        return instance\n"
  },
  {
    "path": "api/services.py",
    "content": "import platform\nimport subprocess\nimport time\nfrom datetime import timedelta\n\nimport requests\nfrom django.db.models import Q\nfrom django.utils import timezone\n\nfrom api.models import Photo\nfrom api.util import logger\n\n# Track services that should not be restarted due to system incompatibility\nINCOMPATIBLE_SERVICES = set()\n\n# CPU features required for different services\nSERVICE_CPU_REQUIREMENTS = {\n    \"llm\": {\n        \"required\": [\"avx\", \"sse4_2\"],  # Essential for llama.cpp\n        \"recommended\": [\"avx2\", \"fma\", \"f16c\"],  # Improve performance\n    }\n}\n\n# Define all the services that can be started, with their respective ports\nSERVICES = {\n    \"image_similarity\": 8002,\n    \"thumbnail\": 8003,\n    \"face_recognition\": 8005,\n    \"clip_embeddings\": 8006,\n    \"llm\": 8008,\n    \"image_captioning\": 8007,\n    \"exif\": 8010,\n    \"tags\": 8011,\n}\n\nHTTP_OK = 200\n\n\ndef check_services():\n    for service in SERVICES.keys():\n        if service in INCOMPATIBLE_SERVICES:\n            logger.info(f\"Skipping restart of incompatible service: {service}\")\n            continue\n\n        if not is_healthy(service):\n            stop_service(service)\n            logger.info(f\"Restarting {service}\")\n            start_service(service)\n\n\ndef is_healthy(service):\n    port = SERVICES.get(service)\n    try:\n        res = requests.get(f\"http://localhost:{port}/health\")\n        # If response has timestamp, check if it needs to be restarted\n        if res.json().get(\"last_request_time\") is not None:\n            if res.json()[\"last_request_time\"] < time.time() - 120:\n                logger.info(f\"Service {service} is stale and needs to be restarted\")\n                return False\n        return res.status_code == HTTP_OK\n    except BaseException as e:\n        logger.exception(f\"Error checking health of {service}: {str(e)}\")\n        return False\n\n\ndef start_service(service):\n    # Check system compatibility before attempting to start the service\n    if not is_service_compatible(service):\n        logger.error(f\"Service '{service}' is not compatible with this system\")\n        return False\n\n    if service == \"image_similarity\":\n        subprocess.Popen(\n            [\n                \"python\",\n                \"image_similarity/main.py\",\n                \"2>&1 | tee /logs/image_similarity.log\",\n            ]\n        )\n    elif service in SERVICES.keys():\n        subprocess.Popen(\n            [\n                \"python\",\n                f\"service/{service}/main.py\",\n                \"2>&1 | tee /logs/{service}.log\",\n            ]\n        )\n    else:\n        logger.warning(\"Unknown service:\", service)\n        return False\n\n    logger.info(f\"Service '{service}' started successfully\")\n    return True\n\n\ndef stop_service(service):\n    try:\n        # Find the process ID (PID) of the service using `ps` and `grep`\n        ps_command = f\"ps aux | grep '[p]ython.*{service}/main.py' | awk '{{print $2}}'\"\n        result = subprocess.run(\n            ps_command,\n            shell=True,\n            check=True,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n        )\n\n        pids = result.stdout.decode().strip().split()\n\n        if not pids:\n            logger.warning(\"Service '%s' is not running\", service)\n            return False\n\n        # Kill each process found\n        for pid in pids:\n            subprocess.run([\"kill\", \"-9\", pid], check=True)\n            logger.info(f\"Service '{service}' with PID {pid} stopped successfully\")\n\n        return True\n    except subprocess.CalledProcessError as e:\n        logger.error(f\"Failed to stop service '{service}': {e.stderr.decode().strip()}\")\n        return False\n    except Exception as e:\n        logger.error(f\"An error occurred while stopping service '{service}': {e}\")\n        return False\n\n\ndef _is_arm_architecture():\n    \"\"\"Check if the current system is running on ARM architecture\n    \n    Returns:\n        bool: True if ARM architecture, False otherwise\n    \"\"\"\n    machine = platform.machine().lower()\n    return machine in ['aarch64', 'arm64', 'armv7l', 'armv8']\n\n\ndef check_cpu_features():\n    \"\"\"Check for CPU instruction sets for various services\n    \n    Note: x86/x64-specific instruction sets (AVX, SSE, etc.) only apply to x86/x64 CPUs.\n    On ARM architectures, these checks are skipped as they are not relevant.\n    \"\"\"\n    # Check if we're on ARM architecture\n    if _is_arm_architecture():\n        machine = platform.machine()\n        logger.info(f\"Detected ARM architecture ({machine}), skipping x86-specific CPU feature checks\")\n        return []  # Return empty list as x86 features don't apply to ARM\n    \n    # Features to check for (x86/x64 specific)\n    features_to_check = [\"avx\", \"avx2\", \"sse4_2\", \"fma\", \"f16c\"]\n    available_features = []\n\n    if not available_features:\n        try:\n            import cpuinfo\n\n            cpu_info = cpuinfo.get_cpu_info()\n            flags = cpu_info.get(\"flags\", [])\n            for feature in features_to_check:\n                if feature in flags:\n                    available_features.append(feature)\n        except ImportError:\n            pass\n\n    return available_features\n\n\ndef has_required_cpu_features(service):\n    \"\"\"Check if CPU has required features for a specific service\n    \n    On ARM architectures, x86-specific CPU checks are bypassed since those\n    instruction sets don't exist on ARM. Services like llama.cpp support ARM natively.\n    \"\"\"\n    if service not in SERVICE_CPU_REQUIREMENTS:\n        return True  # No CPU requirements for this service\n\n    # Check if we're on ARM architecture\n    if _is_arm_architecture():\n        machine = platform.machine()\n        logger.info(f\"Running on ARM architecture ({machine}), skipping x86-specific CPU feature requirements for {service}\")\n        return True  # Skip x86-specific checks on ARM\n\n    requirements = SERVICE_CPU_REQUIREMENTS[service]\n    required_features = requirements.get(\"required\", [])\n    recommended_features = requirements.get(\"recommended\", [])\n\n    available_features = check_cpu_features()\n\n    logger.info(f\"CPU features detected for {service}: {available_features}\")\n\n    missing_required = []\n    missing_recommended = []\n\n    for feature in required_features:\n        if feature not in available_features:\n            missing_required.append(feature)\n\n    for feature in recommended_features:\n        if feature not in available_features:\n            missing_recommended.append(feature)\n\n    if missing_required:\n        logger.error(f\"Service '{service}' requires CPU features: {missing_required}\")\n        logger.error(f\"Missing required CPU features: {missing_required}\")\n        return False\n\n    if missing_recommended:\n        logger.warning(\n            f\"Service '{service}' performance may be degraded without: {missing_recommended}\"\n        )\n\n    logger.info(f\"CPU compatible with service '{service}'\")\n    return True\n\n\ndef is_service_compatible(service):\n    \"\"\"Check if a service is compatible with the current system\"\"\"\n    # Check CPU compatibility\n    if not has_required_cpu_features(service):\n        INCOMPATIBLE_SERVICES.add(service)\n        return False\n\n    return True\n\n\ndef cleanup_deleted_photos():\n    deleted_photos = Photo.objects.filter(\n        Q(removed=True) & Q(last_modified__gte=timezone.now() - timedelta(days=30))\n    )\n    for photo in deleted_photos:\n        photo.delete()\n"
  },
  {
    "path": "api/social_graph.py",
    "content": "import networkx as nx\nfrom django.db import connection\n\nfrom api.models import Person\nfrom api.util import logger\n\n\ndef build_social_graph(user):\n    try:\n        query = \"\"\"\n            WITH face AS (\n                SELECT photo_id, person_id, name, owner_id\n                FROM api_face\n                JOIN api_person ON api_person.id = person_id\n                JOIN api_photo ON api_photo.id = photo_id\n                WHERE person_id IS NOT NULL\n                    AND owner_id = {}\n            )\n            SELECT f1.name, f2.name\n            FROM face f1\n            JOIN face f2 USING (photo_id)\n            WHERE f1.person_id != f2.person_id\n            GROUP BY f1.name, f2.name\n        \"\"\".replace(\"{}\", str(user.id))\n        G = nx.Graph()\n        with connection.cursor() as cursor:\n            cursor.execute(query)\n            links = cursor.fetchall()\n            if len(links) == 0:\n                return {\"nodes\": [], \"links\": []}\n            for link in links:\n                G.add_edge(link[0], link[1])\n        pos = nx.spring_layout(G, k=1 / 2, scale=1000, iterations=20)\n        return {\n            \"nodes\": [\n                {\"id\": node, \"x\": coords[0], \"y\": coords[1]}\n                for node, coords in pos.items()\n            ],\n            \"links\": [{\"source\": pair[0], \"target\": pair[1]} for pair in G.edges()],\n        }\n    except Exception:\n        logger.exception(f\"Error building social graph for user {user.id}\")\n        raise\n\n\ndef build_ego_graph(person_id):\n    G = nx.Graph()\n    person = Person.objects.prefetch_related(\"faces__photo__faces__person\").filter(\n        id=person_id\n    )[0]\n    for this_person_face in person.faces.all():\n        for other_person_face in this_person_face.photo.faces.all():\n            G.add_edge(person.name, other_person_face.person.name)\n    nodes = [{\"id\": node} for node in G.nodes()]\n    links = [{\"source\": pair[0], \"target\": pair[1]} for pair in G.edges()]\n    res = {\"nodes\": nodes, \"links\": links}\n    return res\n"
  },
  {
    "path": "api/stack_detection.py",
    "content": "\"\"\"\nStack detection module for grouping related photos organizationally.\n\nHandles organizational stack types:\n- BURST_SEQUENCE: Photos taken in rapid succession\n- EXPOSURE_BRACKET: Bracketed exposures for HDR\n- MANUAL: User-created stacks (not detected, created by user)\n\nNOTE: RAW+JPEG pairs and Live Photos are NO LONGER handled as stacks.\nThey now use the Photo.files ManyToMany field for file variants\n(PhotoPrism-like model). This is handled during scan, not detection.\n\nNOTE: Duplicate detection (exact copies and visual duplicates) is now\nhandled separately by api/duplicate_detection.py. This module focuses\non organizational grouping, not storage cleanup.\n\nBurst detection uses a rules-based system with two categories:\n- Hard criteria: Deterministic (EXIF tags, filename patterns)\n- Soft criteria: Estimation (timestamp proximity, visual similarity)\n\"\"\"\n\nfrom collections import defaultdict\n\nfrom django.db.models import Q\n\nfrom api.models import Photo\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.long_running_job import LongRunningJob\nfrom api.burst_detection_rules import (\n    as_rules,\n    get_enabled_rules,\n    get_hard_rules,\n    get_soft_rules,\n    group_photos_by_timestamp,\n    group_photos_by_visual_similarity,\n    BurstRuleTypes,\n)\nfrom api.util import logger\n\n\ndef clear_stacks_of_type(user, stack_type):\n    \"\"\"\n    Clear all stacks of a specific type for a user before re-detection.\n    This ensures we start fresh and don't create duplicate stacks.\n\n    Args:\n        user: The user whose stacks to clear\n        stack_type: The stack type to clear (e.g., PhotoStack.StackType.BURST_SEQUENCE)\n\n    Returns:\n        Number of stacks deleted\n    \"\"\"\n    stacks_to_delete = PhotoStack.objects.filter(owner=user, stack_type=stack_type)\n\n    count = stacks_to_delete.count()\n\n    # Unlink all photos from these stacks (ManyToMany)\n    for stack in stacks_to_delete:\n        for photo in stack.photos.all():\n            photo.stacks.remove(stack)\n\n    # Delete the stacks\n    stacks_to_delete.delete()\n\n    if count > 0:\n        logger.info(f\"Cleared {count} {stack_type} stacks for {user.username}\")\n\n    return count\n\n\ndef detect_burst_sequences(\n    user, interval_ms=2000, use_visual_similarity=True, progress_callback=None\n):\n    \"\"\"\n    Detect burst sequences using user's configured rules.\n\n    This function now uses a rules-based system with two categories:\n    - Hard criteria: EXIF tags, filename patterns (deterministic)\n    - Soft criteria: Timestamp proximity, visual similarity (estimation)\n\n    By default, only hard criteria rules are enabled.\n\n    Args:\n        user: The user whose photos to analyze\n        interval_ms: Default milliseconds between burst photos (for soft rules without config)\n        use_visual_similarity: Default for visual similarity (for soft rules without config)\n        progress_callback: Optional callback(current, total, found)\n\n    Returns:\n        Number of stacks created\n    \"\"\"\n    # Clear existing burst stacks before re-detection\n    clear_stacks_of_type(user, PhotoStack.StackType.BURST_SEQUENCE)\n\n    # Get user's burst detection rules\n    rules_config = user.burst_detection_rules\n    if isinstance(rules_config, str):\n        import json\n\n        rules_config = json.loads(rules_config)\n\n    rules = as_rules(rules_config)\n    enabled_rules = get_enabled_rules(rules)\n\n    if not enabled_rules:\n        logger.info(f\"No burst detection rules enabled for {user.username}\")\n        return 0\n\n    hard_rules = get_hard_rules(rules)\n    soft_rules = get_soft_rules(rules)\n\n    stacks_created = 0\n\n    # === Phase 1: Hard criteria detection ===\n    if hard_rules:\n        stacks_created += _detect_bursts_hard_criteria(\n            user, hard_rules, progress_callback\n        )\n\n    # === Phase 2: Soft criteria detection ===\n    if soft_rules:\n        stacks_created += _detect_bursts_soft_criteria(\n            user, soft_rules, interval_ms, use_visual_similarity, progress_callback\n        )\n\n    logger.info(\n        f\"Burst detection for {user.username}: found {stacks_created} sequences\"\n    )\n    return stacks_created\n\n\ndef _detect_bursts_hard_criteria(user, hard_rules, progress_callback=None):\n    \"\"\"\n    Detect bursts using hard criteria (EXIF tags, filename patterns).\n\n    These are deterministic rules that identify burst photos based on\n    camera metadata or filename conventions.\n    \"\"\"\n    from api.metadata.reader import get_metadata\n\n    # Get all photos that could be in bursts\n    photos = Photo.objects.filter(\n        Q(owner=user) & Q(hidden=False) & Q(in_trashcan=False)\n    ).select_related(\"main_file\", \"metadata\")\n\n    total = photos.count()\n    if total == 0:\n        return 0\n\n    # Collect required EXIF tags from all rules\n    required_tags = set()\n    for rule in hard_rules:\n        required_tags.update(rule.get_required_exif_tags())\n    required_tags = list(required_tags)\n\n    # Group photos by burst group_key\n    burst_groups = defaultdict(list)\n\n    for i, photo in enumerate(photos):\n        if not photo.main_file:\n            continue\n\n        # Get EXIF tags for this photo\n        try:\n            exif_values = get_metadata(photo.main_file.path, required_tags)\n            exif_tags = dict(zip(required_tags, exif_values))\n        except Exception as e:\n            logger.debug(f\"Could not read EXIF for {photo.main_file.path}: {e}\")\n            exif_tags = {}\n\n        # Try each hard rule until one matches\n        for rule in hard_rules:\n            is_burst, group_key = rule.is_burst_photo(photo, exif_tags)\n            if is_burst and group_key:\n                burst_groups[group_key].append(photo)\n                break  # Photo matched a rule, don't try others\n\n        if progress_callback and i % 100 == 0:\n            progress_callback(i, total, len(burst_groups))\n\n    # Create stacks from groups with 2+ photos\n    stacks_created = 0\n    for group_key, photos_in_group in burst_groups.items():\n        if len(photos_in_group) >= 2:\n            # Sort by timestamp if available\n            photos_in_group.sort(key=lambda p: p.exif_timestamp or p.added_on)\n            stack = _create_burst_stack(user, photos_in_group)\n            if stack:\n                stacks_created += 1\n                logger.debug(\n                    f\"Created hard-criteria burst stack: {group_key} with {len(photos_in_group)} photos\"\n                )\n\n    logger.info(\n        f\"Hard criteria burst detection: found {stacks_created} stacks from {len(burst_groups)} groups\"\n    )\n    return stacks_created\n\n\ndef _detect_bursts_soft_criteria(\n    user,\n    soft_rules,\n    default_interval_ms=2000,\n    default_use_visual=True,\n    progress_callback=None,\n):\n    \"\"\"\n    Detect bursts using soft criteria (timestamp proximity, visual similarity).\n\n    These are estimation-based rules that group photos based on timing\n    and/or visual similarity.\n    \"\"\"\n    # Get photos ordered by timestamp (needed for proximity detection)\n    photos = (\n        Photo.objects.filter(\n            Q(owner=user)\n            & Q(exif_timestamp__isnull=False)\n            & Q(hidden=False)\n            & Q(in_trashcan=False)\n        )\n        .order_by(\"exif_timestamp\")\n        .select_related(\"main_file\", \"metadata\")\n    )\n\n    total = photos.count()\n    if total < 2:\n        return 0\n\n    stacks_created = 0\n    photos_list = list(photos)\n\n    # Process each soft rule\n    for rule in soft_rules:\n        if rule.rule_type == BurstRuleTypes.TIMESTAMP_PROXIMITY:\n            # Get rule-specific parameters or use defaults\n            interval_ms = rule.params.get(\"interval_ms\", default_interval_ms)\n            require_same_camera = rule.params.get(\"require_same_camera\", True)\n\n            groups = group_photos_by_timestamp(\n                photos_list, interval_ms, require_same_camera\n            )\n\n            for group in groups:\n                # Filter out photos already in burst stacks\n                photos_to_stack = [\n                    p\n                    for p in group\n                    if not p.stacks.filter(\n                        stack_type=PhotoStack.StackType.BURST_SEQUENCE\n                    ).exists()\n                ]\n                if len(photos_to_stack) >= 2:\n                    stack = _create_burst_stack(user, photos_to_stack)\n                    if stack:\n                        stacks_created += 1\n\n        elif rule.rule_type == BurstRuleTypes.VISUAL_SIMILARITY:\n            similarity_threshold = rule.params.get(\"similarity_threshold\", 15)\n\n            groups = group_photos_by_visual_similarity(\n                photos_list, similarity_threshold\n            )\n\n            for group in groups:\n                # Filter out photos already in burst stacks\n                photos_to_stack = [\n                    p\n                    for p in group\n                    if not p.stacks.filter(\n                        stack_type=PhotoStack.StackType.BURST_SEQUENCE\n                    ).exists()\n                ]\n                if len(photos_to_stack) >= 2:\n                    stack = _create_burst_stack(user, photos_to_stack)\n                    if stack:\n                        stacks_created += 1\n\n    logger.info(f\"Soft criteria burst detection: found {stacks_created} stacks\")\n    return stacks_created\n\n\ndef _create_burst_stack(user, photos):\n    \"\"\"Helper to create a burst stack from a list of photos.\"\"\"\n    if len(photos) < 2:\n        return None\n\n    # Filter out photos that are already in a burst stack to prevent duplicates\n    # A photo should only be in one burst stack at a time\n    photos_to_stack = [\n        photo\n        for photo in photos\n        if not photo.stacks.filter(\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE\n        ).exists()\n    ]\n\n    # If all photos are already stacked, skip\n    if len(photos_to_stack) < 2:\n        return None\n\n    # Use create_or_merge to ensure photos aren't in multiple stacks of the same type\n    # Pass sequence timestamps for burst stacks\n    stack = PhotoStack.create_or_merge(\n        owner=user,\n        stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        photos=photos_to_stack,\n        sequence_start=photos_to_stack[0].exif_timestamp,\n        sequence_end=photos_to_stack[-1].exif_timestamp,\n    )\n\n    logger.info(\n        f\"Created/merged BURST_SEQUENCE stack with {len(photos_to_stack)} photos\"\n    )\n    return stack\n\n\ndef batch_detect_stacks(user, options=None):\n    \"\"\"\n    Run batch stack detection for a user.\n\n    NOTE: RAW+JPEG pairs and Live Photos are now handled as file variants\n    during scan (PhotoPrism-like model), not as stacks here.\n\n    Burst detection uses the user's configured burst_detection_rules from their profile.\n\n    Args:\n        user: The user whose photos to analyze\n        options: Dict with detection options:\n            - detect_bursts: bool (default: True) - uses user's burst_detection_rules\n    \"\"\"\n    if options is None:\n        options = {}\n\n    detect_bursts = options.get(\"detect_bursts\", True)\n\n    # Create long-running job for progress tracking\n    job = LongRunningJob.create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_SCAN_PHOTOS,\n        start_now=True,\n    )\n\n    try:\n        total_found = 0\n\n        # Detect burst sequences (uses user's burst_detection_rules)\n        if detect_bursts:\n\n            def progress_burst(current, total, found):\n                job.set_result(\n                    {\n                        \"stage\": \"burst_sequences\",\n                        \"current\": current,\n                        \"total\": total,\n                        \"found\": found,\n                    }\n                )\n\n            burst_count = detect_burst_sequences(user, progress_callback=progress_burst)\n            total_found += burst_count\n\n        job.complete(result={\"status\": \"completed\", \"stacks_found\": total_found})\n\n        logger.info(\n            f\"Stack detection completed for {user.username}: {total_found} stacks found\"\n        )\n\n    except Exception as e:\n        logger.error(f\"Stack detection failed for {user.username}: {e}\")\n        job.fail(error=e)\n        raise\n"
  },
  {
    "path": "api/stacks/__init__.py",
    "content": "\"\"\"\nPhoto stacking detection and management.\n\nThis package provides unified photo grouping functionality:\n- Exact copies (same MD5 hash)\n- Visual duplicates (similar pHash/CLIP)\n- RAW+JPEG pairs\n- Burst sequences\n- Exposure brackets\n- Live Photos (embedded motion video)\n- Manual user groupings\n\"\"\"\n\nfrom api.stacks.live_photo import (\n    detect_live_photo,\n    extract_embedded_motion_video,\n    find_apple_live_photo_video,\n    has_embedded_motion_video,\n    process_live_photos_batch,\n)\n\n__all__ = [\n    \"detect_live_photo\",\n    \"extract_embedded_motion_video\",\n    \"find_apple_live_photo_video\",\n    \"has_embedded_motion_video\",\n    \"process_live_photos_batch\",\n]\n"
  },
  {
    "path": "api/stacks/live_photo.py",
    "content": "\"\"\"\nLive Photo detection and stacking logic.\n\nHandles extraction and grouping of Live Photos:\n- Google Pixel Motion Photos (embedded MP4 after JPEG EOI)\n- Samsung Motion Photos (MotionPhoto_Data marker)\n- Apple Live Photos (paired .mov file)\n\nThis module moves embedded media extraction from directory_watcher to \na dedicated stacks-aware component for better organization.\n\"\"\"\n\nfrom mmap import ACCESS_READ, mmap\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\n\nimport magic\nfrom django.conf import settings\n\nfrom api.models.file import File\nfrom api.models.photo_stack import PhotoStack\nfrom api.util import logger\n\nif TYPE_CHECKING:\n    from api.models.photo import Photo\n    from api.models.user import User\n\n\n# Markers for embedded motion video detection\nJPEG_EOI_MARKER = b\"\\xff\\xd9\"\nGOOGLE_PIXEL_MP4_SIGNATURES = [b\"ftypmp42\", b\"ftypisom\", b\"ftypiso2\"]\nSAMSUNG_MOTION_MARKER = b\"MotionPhoto_Data\"\n\n# Apple Live Photo video extensions\nAPPLE_LIVE_PHOTO_EXTENSIONS = [\".mov\", \".MOV\"]\n\n\ndef _locate_google_embedded_video(data: bytes) -> int:\n    \"\"\"Find position of embedded MP4 in Google Motion Photo.\"\"\"\n    for signature in GOOGLE_PIXEL_MP4_SIGNATURES:\n        position = data.find(signature)\n        if position != -1:\n            # MP4 header starts 4 bytes before ftyp\n            return position - 4\n    return -1\n\n\ndef _locate_samsung_embedded_video(data: bytes) -> int:\n    \"\"\"Find position of embedded video in Samsung Motion Photo.\"\"\"\n    position = data.find(SAMSUNG_MOTION_MARKER)\n    if position != -1:\n        # Video starts immediately after the marker\n        return position + len(SAMSUNG_MOTION_MARKER)\n    return -1\n\n\ndef has_embedded_motion_video(path: str) -> bool:\n    \"\"\"\n    Check if a JPEG file contains an embedded motion video.\n    \n    Supports:\n    - Google Pixel Motion Photos\n    - Samsung Motion Photos\n    \n    Args:\n        path: Path to the image file\n        \n    Returns:\n        True if embedded video detected, False otherwise\n    \"\"\"\n    try:\n        mime = magic.Magic(mime=True)\n        mime_type = mime.from_file(path)\n        if mime_type != \"image/jpeg\":\n            return False\n            \n        with open(path, \"rb\") as image:\n            with mmap(image.fileno(), 0, access=ACCESS_READ) as mm:\n                return (\n                    _locate_google_embedded_video(mm) != -1 or\n                    _locate_samsung_embedded_video(mm) != -1\n                )\n    except Exception as e:\n        logger.warning(f\"Error checking for embedded video in {path}: {e}\")\n        return False\n\n\ndef extract_embedded_motion_video(path: str, output_hash: str) -> str | None:\n    \"\"\"\n    Extract embedded motion video from a JPEG file.\n    \n    Args:\n        path: Path to the source image file\n        output_hash: Hash to use for output filename\n        \n    Returns:\n        Path to extracted video file, or None if extraction failed\n    \"\"\"\n    try:\n        with open(str(path), \"rb\") as image:\n            with mmap(image.fileno(), 0, access=ACCESS_READ) as mm:\n                # Try Google format first, then Samsung\n                position = _locate_google_embedded_video(mm)\n                if position == -1:\n                    position = _locate_samsung_embedded_video(mm)\n                    \n                if position == -1:\n                    return None\n                    \n                # Create output directory\n                output_dir = Path(settings.MEDIA_ROOT) / \"embedded_media\"\n                output_dir.mkdir(parents=True, exist_ok=True)\n                \n                output_path = output_dir / f\"{output_hash}_motion.mp4\"\n                \n                with open(output_path, \"wb\") as video:\n                    mm.seek(position)\n                    data = mm.read(mm.size() - position)\n                    video.write(data)\n                    \n                logger.info(f\"Extracted motion video to {output_path}\")\n                return str(output_path)\n                \n    except Exception as e:\n        logger.error(f\"Error extracting embedded video from {path}: {e}\")\n        return None\n\n\ndef find_apple_live_photo_video(image_path: str) -> str | None:\n    \"\"\"\n    Find the companion .mov file for an Apple Live Photo.\n    \n    Apple Live Photos are stored as separate .HEIC/.JPG and .MOV files\n    with the same base name (from ContentIdentifier).\n    \n    Args:\n        image_path: Path to the image file\n        \n    Returns:\n        Path to companion video file, or None if not found\n    \"\"\"\n    base_path = Path(image_path)\n    stem = base_path.stem\n    parent = base_path.parent\n    \n    for ext in APPLE_LIVE_PHOTO_EXTENSIONS:\n        video_path = parent / f\"{stem}{ext}\"\n        if video_path.exists():\n            return str(video_path)\n            \n    return None\n\n\ndef detect_live_photo(photo: \"Photo\", user: \"User\") -> PhotoStack | None:\n    \"\"\"\n    Detect if a photo is part of a Live Photo and create a stack.\n    \n    This handles:\n    1. Embedded motion videos (Google/Samsung) - extracts and links\n    2. Apple Live Photos - finds and links companion video\n    \n    Args:\n        photo: The Photo instance to check\n        user: Owner of the photo\n        \n    Returns:\n        PhotoStack instance if Live Photo detected, None otherwise\n    \"\"\"\n    if not photo.main_file:\n        return None\n        \n    image_path = photo.main_file.path\n    \n    # Check for embedded motion video (Google/Samsung)\n    if has_embedded_motion_video(image_path):\n        return _create_embedded_live_photo_stack(photo, user)\n        \n    # Check for Apple Live Photo companion video\n    video_path = find_apple_live_photo_video(image_path)\n    if video_path:\n        return _create_apple_live_photo_stack(photo, video_path, user)\n        \n    return None\n\n\ndef _create_embedded_live_photo_stack(photo: \"Photo\", user: \"User\") -> PhotoStack | None:\n    \"\"\"Create stack for photo with embedded motion video.\"\"\"\n    if not settings.FEATURE_PROCESS_EMBEDDED_MEDIA:\n        logger.debug(\"Embedded media processing disabled\")\n        return None\n        \n    image_path = photo.main_file.path\n    video_path = extract_embedded_motion_video(image_path, photo.main_file.hash)\n    \n    if not video_path:\n        return None\n        \n    # Create File record for the extracted video\n    video_file = File.create(video_path, user)\n    \n    # Link as embedded media on the original file\n    photo.main_file.embedded_media.add(video_file)\n    \n    # Create or update stack\n    existing_stack = photo.stacks.filter(stack_type=PhotoStack.StackType.LIVE_PHOTO).first()\n    if existing_stack:\n        return existing_stack\n        \n    # Create new Live Photo stack\n    stack = PhotoStack.objects.create(\n        owner=user,\n        stack_type=PhotoStack.StackType.LIVE_PHOTO,\n        primary_photo=photo,\n    )\n    \n    # Link photo to stack (ManyToMany)\n    photo.stacks.add(stack)\n    \n    logger.info(f\"Created Live Photo stack {stack.id} for embedded motion in {image_path}\")\n    return stack\n\n\ndef _create_apple_live_photo_stack(\n    photo: \"Photo\", \n    video_path: str, \n    user: \"User\"\n) -> PhotoStack | None:\n    \"\"\"Create stack for Apple Live Photo with companion video.\"\"\"\n    from api.models.photo import Photo\n    \n    # Check if video is already a known photo/file\n    video_file = File.objects.filter(path=video_path).first()\n    \n    if not video_file:\n        # Create File record for the video\n        video_file = File.create(video_path, user)\n        \n    # Find or create the video as a Photo\n    video_photo = Photo.objects.filter(main_file=video_file).first()\n    \n    if not video_photo:\n        # Video file exists but no Photo record - might be created by scan\n        # Link as embedded media instead\n        photo.main_file.embedded_media.add(video_file)\n        \n    # Create or find stack\n    existing_stack = photo.stacks.filter(stack_type=PhotoStack.StackType.LIVE_PHOTO).first()\n    if existing_stack:\n        stack = existing_stack\n    else:\n        stack = PhotoStack.objects.create(\n            owner=user,\n            stack_type=PhotoStack.StackType.LIVE_PHOTO,\n            primary_photo=photo,\n        )\n        photo.stacks.add(stack)\n        \n    # If video is a separate Photo, link it to the same stack\n    if video_photo and not video_photo.stacks.filter(stack_type=PhotoStack.StackType.LIVE_PHOTO).exists():\n        video_photo.stacks.add(stack)\n        \n    logger.info(f\"Created Apple Live Photo stack {stack.id} for {photo.main_file.path}\")\n    return stack\n\n\ndef process_live_photos_batch(user: \"User\", photos: list[\"Photo\"]) -> dict:\n    \"\"\"\n    Process multiple photos for Live Photo detection.\n    \n    Args:\n        user: User who owns the photos\n        photos: List of Photo instances to check\n        \n    Returns:\n        Dict with counts: {detected: int, stacks_created: int}\n    \"\"\"\n    detected = 0\n    stacks_created = 0\n    \n    for photo in photos:\n        try:\n            stack = detect_live_photo(photo, user)\n            if stack:\n                detected += 1\n                if stack.photo_count <= 1:\n                    # New stack (might just have the photo, video linked separately)\n                    stacks_created += 1\n        except Exception as e:\n            logger.error(f\"Error processing Live Photo detection for {photo.id}: {e}\")\n            \n    return {\n        \"detected\": detected,\n        \"stacks_created\": stacks_created,\n    }\n"
  },
  {
    "path": "api/stats.py",
    "content": "import os\nfrom datetime import datetime\n\nimport numpy as np\nfrom django.db import connection\nfrom django.db.models import Avg, Count, Max, Min, Q, Sum\nfrom django.db.models.functions import TruncMonth\n\nimport random\nimport re\n\nimport seaborn as sns\nfrom api.util import logger\n\nfrom api.models import (\n    AlbumAuto,\n    AlbumDate,\n    AlbumPlace,\n    AlbumThing,\n    AlbumUser,\n    Cluster,\n    Face,\n    Person,\n    Photo,\n    User,\n)\nfrom api.models.user import get_deleted_user\n\n\ndef _is_sqlite() -> bool:\n    return connection.vendor == \"sqlite\"\n\n\ndef jump_by_month(start_date, end_date, month_step=1):\n    current_date = start_date\n    yield current_date\n    while current_date < end_date:\n        carry, new_month = divmod(current_date.month - 1 + month_step, 12)\n        new_month += 1\n        current_date = current_date.replace(\n            year=current_date.year + carry, month=new_month\n        )\n        yield current_date\n\n\ndef median_value(queryset, term):\n    from decimal import Decimal\n\n    count = queryset.count()\n    if count == 0:\n        return\n    values = queryset.values_list(term, flat=True).order_by(term)\n    if count % 2 == 1:\n        return values[int(round(count / 2))]\n    else:\n        return sum(values[count / 2 - 1 : count / 2 + 1]) / Decimal(2.0)\n\n\ndef calc_megabytes(bytes):\n    if bytes == 0 or bytes is None:\n        return 0\n    return round((bytes / 1024) / 1024)\n\n\ndef get_server_stats():\n    # CPU architecture, Speed, Number of Cores, 64bit / 32 Bits\n    import cpuinfo\n\n    cpu_info = cpuinfo.get_cpu_info()\n    # Available RAM\n    import psutil\n\n    available_ram = calc_megabytes(psutil.virtual_memory().total)\n    # GPU\n    import torch\n\n    if torch.cuda.is_available():\n        gpu_name = torch.cuda.get_device_name(0)\n        gpu_memory = calc_megabytes(torch.cuda.get_device_properties(0).total_memory)\n    else:\n        gpu_name = \"\"\n        gpu_memory = \"\"\n    # Total Capacity\n    import shutil\n\n    total_storage, used_storage, free_storage = shutil.disk_usage(\"/\")\n    image_tag = os.environ.get(\"IMAGE_TAG\", \"\")\n    number_of_users = User.objects.filter(~Q(id=get_deleted_user().id)).count()\n    users = []\n    for user in User.objects.filter(~Q(id=get_deleted_user().id)):\n        date_joined = user.date_joined\n        number_of_photos = Photo.objects.filter(Q(owner=user)).count()\n        number_of_videos = Photo.objects.filter(Q(owner=user) & Q(video=True)).count()\n        number_of_captions = Photo.objects.filter(\n            Q(owner=user)\n            & Q(caption_instance__captions_json__user_caption__isnull=False)\n        ).count()\n        number_of_generated_captions = Photo.objects.filter(\n            Q(owner=user) & Q(caption_instance__captions_json__im2txt__isnull=False)\n        ).count()\n        number_of_albums = AlbumUser.objects.filter(Q(owner=user)).count()\n        min_number_of_photos_per_album = (\n            AlbumUser.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\"))\n            .aggregate(Min(\"count\"))\n        )\n        max_number_of_photos_per_album = (\n            AlbumUser.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\"))\n            .aggregate(Max(\"count\"))\n        )\n        mean_number_of_photos_per_album = (\n            AlbumUser.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\"))\n            .aggregate(Avg(\"count\"))\n        )\n        median_number_of_photos_per_album = median_value(\n            AlbumUser.objects.filter(Q(owner=user)).annotate(count=Count(\"photos\")),\n            \"count\",\n        )\n        min_number_of_videos_per_album = (\n            AlbumUser.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\", filter=Q(photos__video=True)))\n            .aggregate(Min(\"count\"))\n        )\n        max_number_of_videos_per_album = (\n            AlbumUser.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\", filter=Q(photos__video=True)))\n            .aggregate(Max(\"count\"))\n        )\n        mean_number_of_videos_per_album = (\n            AlbumUser.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\", filter=Q(photos__video=True)))\n            .aggregate(Avg(\"count\"))\n        )\n        median_number_of_videos_per_album = median_value(\n            AlbumUser.objects.filter(Q(owner=user)).annotate(\n                count=Count(\"photos\", filter=Q(photos__video=True))\n            ),\n            \"count\",\n        )\n        number_of_persons = Person.objects.filter(Q(cluster_owner=user)).count()\n        min_number_of_faces_per_person = (\n            Person.objects.filter(Q(cluster_owner=user))\n            .annotate(count=Count(\"faces\"))\n            .aggregate(Min(\"count\"))\n        )\n        max_number_of_faces_per_person = (\n            Person.objects.filter(Q(cluster_owner=user))\n            .annotate(count=Count(\"faces\"))\n            .aggregate(Max(\"count\"))\n        )\n        mean_number_of_faces_per_person = (\n            Person.objects.filter(Q(cluster_owner=user))\n            .annotate(count=Count(\"faces\"))\n            .aggregate(Avg(\"count\"))\n        )\n        median_number_of_faces_per_person = median_value(\n            Person.objects.filter(Q(cluster_owner=user)).annotate(count=Count(\"faces\")),\n            \"count\",\n        )\n        number_of_clusters = Cluster.objects.filter(Q(owner=user)).count()\n        number_of_places = AlbumPlace.objects.filter(Q(owner=user)).count()\n        min_number_of_photos_per_place = (\n            AlbumPlace.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\"))\n            .aggregate(Min(\"count\"))\n        )\n        max_number_of_photos_per_place = (\n            AlbumPlace.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\"))\n            .aggregate(Max(\"count\"))\n        )\n        mean_number_of_photos_per_place = (\n            AlbumPlace.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\"))\n            .aggregate(Avg(\"count\"))\n        )\n        median_number_of_photos_per_place = median_value(\n            AlbumPlace.objects.filter(Q(owner=user)).annotate(count=Count(\"photos\")),\n            \"count\",\n        )\n        min_number_of_videos_per_place = (\n            AlbumPlace.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\", filter=Q(photos__video=True)))\n            .aggregate(Min(\"count\"))\n        )\n        max_number_of_videos_per_place = (\n            AlbumPlace.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\", filter=Q(photos__video=True)))\n            .aggregate(Max(\"count\"))\n        )\n        mean_number_of_videos_per_place = (\n            AlbumPlace.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\", filter=Q(photos__video=True)))\n            .aggregate(Avg(\"count\"))\n        )\n        median_number_of_videos_per_place = median_value(\n            AlbumPlace.objects.filter(Q(owner=user)).annotate(\n                count=Count(\"photos\", filter=Q(photos__video=True))\n            ),\n            \"count\",\n        )\n        number_of_things = AlbumThing.objects.filter(Q(owner=user)).count()\n        min_number_of_photos_per_thing = (\n            AlbumThing.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\"))\n            .aggregate(Min(\"count\"))\n        )\n        max_number_of_photos_per_thing = (\n            AlbumThing.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\"))\n            .aggregate(Max(\"count\"))\n        )\n        mean_number_of_photos_per_thing = (\n            AlbumThing.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\"))\n            .aggregate(Avg(\"count\"))\n        )\n        median_number_of_photos_per_thing = median_value(\n            AlbumThing.objects.filter(Q(owner=user)).annotate(count=Count(\"photos\")),\n            \"count\",\n        )\n        min_number_of_videos_per_thing = (\n            AlbumThing.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\", filter=Q(photos__video=True)))\n            .aggregate(Min(\"count\"))\n        )\n        max_number_of_videos_per_thing = (\n            AlbumThing.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\", filter=Q(photos__video=True)))\n            .aggregate(Max(\"count\"))\n        )\n        mean_number_of_videos_per_thing = (\n            AlbumThing.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\", filter=Q(photos__video=True)))\n            .aggregate(Avg(\"count\"))\n        )\n        median_number_of_videos_per_thing = median_value(\n            AlbumThing.objects.filter(Q(owner=user)).annotate(\n                count=Count(\"photos\", filter=Q(photos__video=True))\n            ),\n            \"count\",\n        )\n        number_of_events = AlbumAuto.objects.filter(Q(owner=user)).count()\n        min_number_of_photos_per_event = (\n            AlbumAuto.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\"))\n            .aggregate(Min(\"count\"))\n        )\n        max_number_of_photos_per_event = (\n            AlbumAuto.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\"))\n            .aggregate(Max(\"count\"))\n        )\n        mean_number_of_photos_per_event = (\n            AlbumAuto.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\"))\n            .aggregate(Avg(\"count\"))\n        )\n        median_number_of_photos_per_event = median_value(\n            AlbumAuto.objects.filter(Q(owner=user)).annotate(count=Count(\"photos\")),\n            \"count\",\n        )\n        min_number_of_videos_per_event = (\n            AlbumAuto.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\", filter=Q(photos__video=True)))\n            .aggregate(Min(\"count\"))\n        )\n        max_number_of_videos_per_event = (\n            AlbumAuto.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\", filter=Q(photos__video=True)))\n            .aggregate(Max(\"count\"))\n        )\n        mean_number_of_videos_per_event = (\n            AlbumAuto.objects.filter(Q(owner=user))\n            .annotate(count=Count(\"photos\", filter=Q(photos__video=True)))\n            .aggregate(Avg(\"count\"))\n        )\n        median_number_of_videos_per_event = median_value(\n            AlbumAuto.objects.filter(Q(owner=user)).annotate(\n                count=Count(\"photos\", filter=Q(photos__video=True))\n            ),\n            \"count\",\n        )\n        number_of_favorites = Photo.objects.filter(\n            Q(owner=user) & Q(rating__gte=user.favorite_min_rating)\n        ).count()\n        number_of_hidden = Photo.objects.filter(Q(owner=user) & Q(hidden=True)).count()\n        number_of_public = Photo.objects.filter(Q(owner=user) & Q(public=True)).count()\n        users.append(\n            {\n                \"date_joined\": date_joined.strftime(\"%d-%m-%Y\"),\n                \"total_file_size_in_mb\": calc_megabytes(\n                    Photo.objects.filter(Q(owner=user)).aggregate(Sum(\"size\"))[\n                        \"size__sum\"\n                    ]\n                    or None\n                ),\n                \"number_of_photos\": number_of_photos,\n                \"number_of_videos\": number_of_videos,\n                \"number_of_captions\": number_of_captions,\n                \"number_of_generated_captions\": number_of_generated_captions,\n                \"album\": {\n                    \"count\": number_of_albums,\n                    \"min\": min_number_of_photos_per_album[\"count__min\"] or None,\n                    \"max\": max_number_of_photos_per_album[\"count__max\"] or None,\n                    \"mean\": mean_number_of_photos_per_album[\"count__avg\"] or None,\n                    \"median\": median_number_of_photos_per_album,\n                    \"min_videos\": min_number_of_videos_per_album[\"count__min\"] or None,\n                    \"max_videos\": max_number_of_videos_per_album[\"count__max\"] or None,\n                    \"mean_videos\": mean_number_of_videos_per_album[\"count__avg\"]\n                    or None,\n                    \"median_videos\": median_number_of_videos_per_album,\n                },\n                \"person\": {\n                    \"count\": number_of_persons,\n                    \"min\": min_number_of_faces_per_person[\"count__min\"] or None,\n                    \"max\": max_number_of_faces_per_person[\"count__max\"] or None,\n                    \"mean\": mean_number_of_faces_per_person[\"count__avg\"] or None,\n                    \"median\": median_number_of_faces_per_person,\n                },\n                \"number_of_clusters\": number_of_clusters,\n                \"places\": {\n                    \"count\": number_of_places,\n                    \"min\": min_number_of_photos_per_place[\"count__min\"] or None,\n                    \"max\": max_number_of_photos_per_place[\"count__max\"] or None,\n                    \"mean\": mean_number_of_photos_per_place[\"count__avg\"] or None,\n                    \"median\": median_number_of_photos_per_place,\n                    \"min_videos\": min_number_of_videos_per_place[\"count__min\"] or None,\n                    \"max_videos\": max_number_of_videos_per_place[\"count__max\"] or None,\n                    \"mean_videos\": mean_number_of_videos_per_place[\"count__avg\"]\n                    or None,\n                    \"median_videos\": median_number_of_videos_per_place,\n                },\n                \"things\": {\n                    \"count\": number_of_things,\n                    \"min\": min_number_of_photos_per_thing[\"count__min\"] or None,\n                    \"max\": max_number_of_photos_per_thing[\"count__max\"] or None,\n                    \"mean\": mean_number_of_photos_per_thing[\"count__avg\"] or None,\n                    \"median\": median_number_of_photos_per_thing,\n                    \"min_videos\": min_number_of_videos_per_thing[\"count__min\"] or None,\n                    \"max_videos\": max_number_of_videos_per_thing[\"count__max\"] or None,\n                    \"mean_videos\": mean_number_of_videos_per_thing[\"count__avg\"]\n                    or None,\n                    \"median_videos\": median_number_of_videos_per_thing,\n                },\n                \"events\": {\n                    \"count\": number_of_events,\n                    \"min\": min_number_of_photos_per_event[\"count__min\"] or None,\n                    \"max\": max_number_of_photos_per_event[\"count__max\"] or None,\n                    \"mean\": mean_number_of_photos_per_event[\"count__avg\"] or None,\n                    \"median\": median_number_of_photos_per_event,\n                    \"min_videos\": min_number_of_videos_per_event[\"count__min\"] or None,\n                    \"max_videos\": max_number_of_videos_per_event[\"count__max\"] or None,\n                    \"mean_videos\": mean_number_of_videos_per_event[\"count__avg\"]\n                    or None,\n                    \"median_videos\": median_number_of_videos_per_event,\n                },\n                \"number_of_favorites\": number_of_favorites,\n                \"number_of_hidden\": number_of_hidden,\n                \"number_of_public\": number_of_public,\n            }\n        )\n    res = {\n        \"cpu_info\": cpu_info,\n        \"image_tag\": image_tag,\n        \"available_ram_in_mb\": available_ram,\n        \"gpu_name\": gpu_name,\n        \"gpu_memory_in_mb\": gpu_memory,\n        \"total_storage_in_mb\": calc_megabytes(total_storage),\n        \"used_storage_in_mb\": calc_megabytes(used_storage),\n        \"free_storage_in_mb\": calc_megabytes(free_storage),\n        \"number_of_users\": number_of_users,\n        \"users\": users,\n    }\n    return res\n\n\ndef get_count_stats(user):\n    num_photos = Photo.visible.filter(Q(owner=user)).distinct().count()\n    num_missing_photos = Photo.objects.filter(\n        Q(owner=user) & Q(files=None) | Q(main_file=None)\n    ).count()\n    num_faces = Face.objects.filter(photo__owner=user).count()\n    num_unknown_faces = Face.objects.filter(\n        (\n            Q(person__name__exact=\"unknown\")\n            | Q(person__name__exact=Person.UNKNOWN_PERSON_NAME)\n        )\n        & Q(photo__owner=user)\n    ).count()\n    num_labeled_faces = Face.objects.filter(\n        Q(person__isnull=False) & Q(photo__owner=user) & Q(photo__hidden=False)\n    ).count()\n    num_inferred_faces = Face.objects.filter(\n        Q(person=True) & Q(photo__owner=user) & Q(photo__hidden=False)\n    ).count()\n    num_people = (\n        Person.objects.filter(\n            Q(faces__photo__hidden=False)\n            & Q(faces__photo__owner=user)\n            & Q(faces__person__isnull=False)\n        )\n        .distinct()\n        .annotate(viewable_face_count=Count(\"faces\"))\n        .filter(Q(viewable_face_count__gt=0))\n        .count()\n    )\n    num_albumauto = (\n        AlbumAuto.objects.filter(owner=user)\n        .annotate(photo_count=Count(\"photos\"))\n        .filter(Q(photo_count__gt=0))\n        .count()\n    )\n    num_albumdate = (\n        AlbumDate.objects.filter(owner=user)\n        .annotate(photo_count=Count(\"photos\"))\n        .filter(Q(photo_count__gt=0))\n        .count()\n    )\n    num_albumuser = (\n        AlbumUser.objects.filter(owner=user)\n        .annotate(photo_count=Count(\"photos\"))\n        .filter(Q(photo_count__gt=0))\n        .count()\n    )\n\n    res = {\n        \"num_photos\": num_photos,\n        \"num_missing_photos\": num_missing_photos,\n        \"num_faces\": num_faces,\n        \"num_people\": num_people,\n        \"num_unknown_faces\": num_unknown_faces,\n        \"num_labeled_faces\": num_labeled_faces,\n        \"num_inferred_faces\": num_inferred_faces,\n        \"num_albumauto\": num_albumauto,\n        \"num_albumdate\": num_albumdate,\n        \"num_albumuser\": num_albumuser,\n    }\n    return res\n\n\ndef get_photo_month_counts(user):\n    counts = (\n        Photo.objects.filter(owner=user)\n        .exclude(exif_timestamp=None)\n        .annotate(month=TruncMonth(\"exif_timestamp\"))\n        .values(\"month\")\n        .annotate(c=Count(\"image_hash\"))\n        .values(\"month\", \"c\")\n    )\n\n    all_months = [\n        c[\"month\"]\n        for c in counts\n        if c[\"month\"].year >= 2000 and c[\"month\"].year <= datetime.now().year\n    ]\n\n    if len(all_months) > 0:\n        first_month = min(all_months)\n        last_month = max(all_months)\n\n        month_span = jump_by_month(first_month, last_month)\n        counts = sorted(counts, key=lambda k: k[\"month\"])\n\n        res = []\n        for count in counts:\n            key = \"-\".join([str(count[\"month\"].year), str(count[\"month\"].month)])\n            count = count[\"c\"]\n            res.append([key, count])\n        res = dict(res)\n\n        out = []\n        for month in month_span:\n            m = \"-\".join([str(month.year), str(month.month)])\n            if m in res.keys():\n                out.append({\"month\": m, \"count\": res[m]})\n            else:\n                out.append({\"month\": m, \"count\": 0})\n\n        return out\n    else:\n        return []\n\n\ndef get_searchterms_wordcloud(user):\n    # Python fallbacks (SQLite): stream and aggregate\n    from collections import Counter\n\n    out = {\"captions\": [], \"people\": [], \"locations\": []}\n\n    # Captions: use Places365 categories, attributes and environment from captions_json\n    captions_counter: Counter[str] = Counter()\n    captions_first_seen: dict[str, int] = {}\n    order_index = 0\n    captions_iter = (\n        Photo.objects.filter(owner=user)\n        .exclude(caption_instance__captions_json__isnull=True)\n        .values_list(\"caption_instance__captions_json\", flat=True)\n        .iterator(chunk_size=2000)\n    )\n    for caps in captions_iter:\n        try:\n            places365 = (caps or {}).get(\"places365\", {})\n            categories = places365.get(\"categories\", [])\n            if isinstance(categories, list):\n                for cat in categories:\n                    if not cat:\n                        continue\n                    label = str(cat)\n                    captions_counter[label] += 1\n                    if label not in captions_first_seen:\n                        captions_first_seen[label] = order_index\n                        order_index += 1\n            attributes = places365.get(\"attributes\", [])\n            if isinstance(attributes, list):\n                for attr in attributes:\n                    if not attr:\n                        continue\n                    label = str(attr)\n                    captions_counter[label] += 1\n                    if label not in captions_first_seen:\n                        captions_first_seen[label] = order_index\n                        order_index += 1\n            environment = places365.get(\"environment\")\n            if isinstance(environment, str) and environment:\n                label = environment\n                captions_counter[label] += 1\n                if label not in captions_first_seen:\n                    captions_first_seen[label] = order_index\n                    order_index += 1\n        except Exception:\n            continue\n\n    # People: aggregate with ORM to avoid per-row Python loops\n    people_rows = (\n        Face.objects.filter(photo__owner=user, person__name__isnull=False)\n        .values(\"person__name\")\n        .annotate(c=Count(\"id\"))\n        .order_by(\"-c\")[:100]\n    )\n\n    # Locations: parse geolocation_json, ignore postcode and poi, one word per photo\n    locations_counter: Counter[str] = Counter()\n    locations_first_seen: dict[str, int] = {}\n    geo_iter = (\n        Photo.objects.filter(owner=user)\n        .exclude(geolocation_json=None)\n        .values_list(\"image_hash\", \"geolocation_json\")\n        .iterator(chunk_size=2000)\n    )\n    for _image_hash, geo in geo_iter:\n        try:\n            features = (geo or {}).get(\"features\", [])\n        except Exception:\n            features = []\n        seen_values = set()\n        for feature in features:\n            if not isinstance(feature, dict):\n                continue\n            place_type = feature.get(\"place_type\")\n            value = feature.get(\"text\")\n            if not value:\n                continue\n            # place_type can be list or string\n            types = place_type if isinstance(place_type, list) else [place_type]\n            types = [t for t in types if t]\n            if any(t in (\"postcode\", \"poi\") for t in types):\n                continue\n            seen_values.add(str(value))\n        for value in seen_values:\n            locations_counter[value] += 1\n            if value not in locations_first_seen:\n                locations_first_seen[value] = captions_first_seen.get(\n                    value, order_index\n                )\n                order_index += 1\n\n    # Build outputs (log of count as before)\n    # Ensure stable order to match expectations: attributes/environment may come first\n    # Sort captions by count desc, then by first-seen order for deterministic ties\n    captions_sorted = sorted(\n        captions_counter.items(),\n        key=lambda kv: (-kv[1], captions_first_seen.get(kv[0], 1_000_000)),\n    )[:100]\n    for label, count in captions_sorted:\n        out[\"captions\"].append({\"label\": label, \"y\": float(np.log(count))})\n    for row in people_rows:\n        out[\"people\"].append(\n            {\"label\": row[\"person__name\"], \"y\": float(np.log(row[\"c\"]))}\n        )\n    locations_sorted = sorted(\n        locations_counter.items(),\n        key=lambda kv: (-kv[1], locations_first_seen.get(kv[0], 1_000_000)),\n    )[:100]\n    for label, count in locations_sorted:\n        out[\"locations\"].append({\"label\": label, \"y\": float(np.log(count))})\n\n    return out\n\n\ndef get_location_sunburst(user):\n    levels = []\n\n    from collections import Counter\n\n    counter = Counter()\n    # Stream results to avoid caching entire queryset in memory\n    photo_geo_iter = (\n        Photo.objects.filter(owner=user)\n        .exclude(geolocation_json=None)\n        .values_list(\"geolocation_json\", flat=True)\n        .iterator(chunk_size=2000)\n    )\n    for geo in photo_geo_iter:\n        try:\n            features = (geo or {}).get(\"features\", [])\n        except Exception:\n            features = []\n        if not isinstance(features, list) or len(features) < 3:\n            continue\n        f1 = features[-1] if isinstance(features[-1], dict) else {}\n        f2 = features[-2] if isinstance(features[-2], dict) else {}\n        f3 = features[-3] if isinstance(features[-3], dict) else {}\n        l1 = f1.get(\"text\")\n        l2 = f2.get(\"text\")\n        l3 = f3.get(\"text\")\n        if l1 is None or l2 is None or l3 is None:\n            continue\n        counter[(l1, l2, l3)] += 1\n    levels = [[k[0], k[1], k[2], v] for k, v in counter.items()]\n    levels = sorted(levels, key=lambda x: (x[0], x[1], x[2]))\n\n    data_structure = {\"name\": \"Places I've visited\", \"children\": []}\n    palette = sns.color_palette(\"hls\", 10).as_hex()\n\n    for data in levels:\n        depth_cursor = data_structure[\"children\"]\n        for i, item in enumerate(data[0:-2]):\n            idx = None\n            j = None\n            for j, c in enumerate(depth_cursor):\n                if item in c.values():\n                    idx = j\n            if idx is None:\n                depth_cursor.append(\n                    {\"name\": item, \"children\": [], \"hex\": random.choice(palette)}\n                )\n                idx = len(depth_cursor) - 1\n\n            depth_cursor = depth_cursor[idx][\"children\"]\n            if i == len(data) - 3:\n                depth_cursor.append(\n                    {\n                        \"name\": data[-2],\n                        \"value\": data[-1],\n                        \"hex\": random.choice(palette),\n                    }\n                )\n\n    return data_structure\n\n\ndef get_location_clusters(user):\n    start = datetime.now()\n    # Build clusters in Python from JSON fields (works for both SQLite and Postgres)\n    results_by_location = {}\n    # Stream results to avoid large memory usage\n    photo_geo_iter = (\n        Photo.objects.filter(owner=user)\n        .exclude(geolocation_json=None)\n        .values_list(\"geolocation_json\", flat=True)\n        .iterator(chunk_size=2000)\n    )\n    numeric_pattern = re.compile(r\"^(-)?[0-9]+$\")\n    for geo in photo_geo_iter:\n        try:\n            features = (geo or {}).get(\"features\", [])\n        except Exception:\n            features = []\n        for feature in features:\n            location_text = feature.get(\"text\") if isinstance(feature, dict) else None\n            if not location_text or numeric_pattern.match(str(location_text)):\n                continue\n            center = feature.get(\"center\") if isinstance(feature, dict) else None\n            if not (isinstance(center, (list, tuple)) and len(center) >= 2):\n                continue\n            # Keep first occurrence per distinct location name\n            if location_text not in results_by_location:\n                lon = center[0]\n                lat = center[1]\n                try:\n                    lat_f = float(lat)\n                    lon_f = float(lon)\n                except Exception:\n                    continue\n                results_by_location[location_text] = [lat_f, lon_f, location_text]\n\n    # Order by location to mimic SQL ordering\n    res = [results_by_location[key] for key in sorted(results_by_location.keys())]\n    elapsed = (datetime.now() - start).total_seconds()\n    logger.info(\"location clustering computed in %.2f seconds\" % elapsed)\n    return res\n\n\ndef get_location_timeline(user):\n    # Python fallback: iterate photos ordered by timestamp and build contiguous location spans\n    def extract_location(geo: dict) -> str | None:\n        if not geo or not isinstance(geo, dict):\n            return None\n        features = geo.get(\"features\", [])\n        if not isinstance(features, list) or not features:\n            return None\n        last = features[-1]\n        if isinstance(last, dict):\n            return last.get(\"text\")\n        return None\n\n    # Stream through photos ordered by exif_timestamp\n    qs = (\n        Photo.objects.filter(owner=user)\n        .exclude(exif_timestamp=None)\n        .order_by(\"exif_timestamp\")\n        .values_list(\"geolocation_json\", \"exif_timestamp\")\n        .iterator(chunk_size=2000)\n    )\n    spans: list[tuple[str, datetime, datetime]] = []\n    current_loc: str | None = None\n    run_start: datetime | None = None\n    last_time: datetime | None = None\n    for geo, ts in qs:\n        loc = extract_location(geo)\n        if loc is None:\n            continue\n        if current_loc is None:\n            current_loc = loc\n            run_start = ts\n            last_time = ts\n            continue\n        if loc == current_loc:\n            last_time = ts\n            continue\n        # location changed → close previous span\n        spans.append((current_loc, run_start, last_time))\n        current_loc = loc\n        run_start = ts\n        last_time = ts\n    # close final span\n    if current_loc is not None and run_start is not None and last_time is not None:\n        spans.append((current_loc, run_start, last_time))\n\n    # Coalesce: set each span's end to next span's begin (like SQL LEAD(begin))\n    city_start_end_duration = []\n    for idx, (loc, begin, end) in enumerate(spans):\n        new_end = spans[idx + 1][1] if idx + 1 < len(spans) else end\n        duration_sec = (new_end - begin).total_seconds()\n        city_start_end_duration.append((loc, begin, new_end, duration_sec))\n\n    colors = sns.color_palette(\"Paired\", len(city_start_end_duration)).as_hex()\n\n    data = []\n    for idx, sted in enumerate(city_start_end_duration):\n        data.append(\n            {\n                \"data\": [sted[3]],\n                \"color\": colors[idx],\n                \"loc\": sted[0],\n                \"start\": sted[1].timestamp(),\n                \"end\": sted[2].timestamp(),\n            }\n        )\n    return data\n"
  },
  {
    "path": "api/tests/__init__.py",
    "content": ""
  },
  {
    "path": "api/tests/fixtures/__init__.py",
    "content": ""
  },
  {
    "path": "api/tests/fixtures/api_util/captions_json.py",
    "content": "captions_json = {\n    \"places365\": {\n        \"attributes\": [\n            \"no horizon\",\n            \"man made\",\n            \"enclosed area\",\n            \"cloth\",\n            \"natural light\",\n            \"wood\",\n            \"glass\",\n            \"indoor lighting\",\n            \"dry\",\n        ],\n        \"categories\": [\"phone booth\", \"ticket booth\"],\n        \"environment\": \"indoor\",\n    }\n}\n"
  },
  {
    "path": "api/tests/fixtures/api_util/expectation.py",
    "content": "wordcloud_expectation = {\n    \"captions\": [\n        {\"label\": \"outdoor\", \"y\": 1.9459101490553132},\n        {\"label\": \"indoor\", \"y\": 0.6931471805599453},\n        {\"label\": \"ticket booth\", \"y\": 0.0},\n        {\"label\": \"boardwalk\", \"y\": 0.0},\n        {\"label\": \"phone booth\", \"y\": 0.0},\n        {\"label\": \"delicatessen\", \"y\": 0.0},\n        {\"label\": \"lagoon\", \"y\": 0.0},\n        {\"label\": \"tundra\", \"y\": 0.0},\n        {\"label\": \"marsh\", \"y\": 0.0},\n        {\"label\": \"bakery shop\", \"y\": 0.0},\n        {\"label\": \"market outdoor\", \"y\": 0.0},\n        {\"label\": \"butchers shop\", \"y\": 0.0},\n        {\"label\": \"playground\", \"y\": 0.0},\n        {\"label\": \"picnic area\", \"y\": 0.0},\n    ],\n    \"people\": [],\n    \"locations\": [\n        {\"label\": \"New South Wales\", \"y\": 1.3862943611198906},\n        {\"label\": \"Sydney\", \"y\": 1.3862943611198906},\n        {\"label\": \"Australia\", \"y\": 1.3862943611198906},\n        {\"label\": \"Maroubra\", \"y\": 1.0986122886681098},\n        {\"label\": \"Ladakh\", \"y\": 0.6931471805599453},\n        {\"label\": \"Leh\", \"y\": 0.6931471805599453},\n        {\"label\": \"Berlin\", \"y\": 0.6931471805599453},\n        {\"label\": \"Germany\", \"y\": 0.6931471805599453},\n        {\"label\": \"India\", \"y\": 0.6931471805599453},\n        {\"label\": \"Lakeshore Road\", \"y\": 0.0},\n        {\"label\": \"Shachokol\", \"y\": 0.0},\n        {\"label\": \"Kreuzberg\", \"y\": 0.0},\n        {\"label\": \"Canada\", \"y\": 0.0},\n        {\"label\": \"Bondi Beach\", \"y\": 0.0},\n        {\"label\": \"Peterborough County\", \"y\": 0.0},\n        {\"label\": \"Lakefield\", \"y\": 0.0},\n        {\"label\": \"Main Bazaar\", \"y\": 0.0},\n        {\"label\": \"Ontario\", \"y\": 0.0},\n        {\"label\": \"Chuchat Yakma\", \"y\": 0.0},\n        {\"label\": \"Friedrichshain\", \"y\": 0.0},\n        {\"label\": \"Beach Road\", \"y\": 0.0},\n        {\"label\": \"Fire Route 47\", \"y\": 0.0},\n    ],\n}\n"
  },
  {
    "path": "api/tests/fixtures/api_util/photos.py",
    "content": "photos = [\n    {\n        \"thumbnail_big\": \"thumbnails_big/88070102f4a9a25ba26959b8e1f203a91.webp\",\n        \"square_thumbnail\": \"square_thumbnails/88070102f4a9a25ba26959b8e1f203a91.webp\",\n        \"square_thumbnail_small\": \"square_thumbnails_small/88070102f4a9a25ba26959b8e1f203a91.webp\",\n        \"added_on\": \"2023-06-16 16:30:47.724635 +00:00\",\n        \"exif_gps_lat\": 52.5075472222222,\n        \"exif_gps_lon\": 13.4549222222222,\n        \"exif_timestamp\": \"2017-08-23 16:13:32.000000 +00:00\",\n        \"exif_json\": None,\n        \"geolocation_json\": {\n            \"type\": \"FeatureCollection\",\n            \"query\": [13.454922, 52.507547],\n            \"features\": [\n                {\n                    \"id\": \"poi.712964618154\",\n                    \"text\": \"Badehaus - Szimpla Musiksalon\",\n                    \"type\": \"Feature\",\n                    \"center\": [13.455101, 52.507538],\n                    \"context\": [\n                        {\n                            \"id\": \"postcode.5156410\",\n                            \"text\": \"10245\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpUcTQ2\",\n                        },\n                        {\n                            \"id\": \"locality.90794554\",\n                            \"text\": \"Friedrichshain\",\n                            \"wikidata\": \"Q317056\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpCV2xxT2c\",\n                        },\n                        {\n                            \"id\": \"place.115770\",\n                            \"text\": \"Berlin\",\n                            \"wikidata\": \"Q64\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBY1E2\",\n                            \"short_code\": \"DE-BE\",\n                        },\n                        {\n                            \"id\": \"country.8762\",\n                            \"text\": \"Germany\",\n                            \"wikidata\": \"Q183\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJam8\",\n                            \"short_code\": \"de\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [13.455101, 52.507538],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Badehaus - Szimpla Musiksalon, Revaler Str. 99, Berlin, 10245, Germany\",\n                    \"place_type\": [\"poi\"],\n                    \"properties\": {\n                        \"address\": \"Revaler Str. 99\",\n                        \"category\": \"bar, pub, alcohol, liquor, beer\",\n                        \"landmark\": True,\n                        \"foursquare\": \"4e62924d18a8ce02fce9d584\",\n                    },\n                },\n                {\n                    \"id\": \"postcode.5156410\",\n                    \"bbox\": [13.445615, 52.486046, 13.491453, 52.514722],\n                    \"text\": \"10245\",\n                    \"type\": \"Feature\",\n                    \"center\": [13.458408, 52.504367],\n                    \"context\": [\n                        {\n                            \"id\": \"locality.90794554\",\n                            \"text\": \"Friedrichshain\",\n                            \"wikidata\": \"Q317056\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpCV2xxT2c\",\n                        },\n                        {\n                            \"id\": \"place.115770\",\n                            \"text\": \"Berlin\",\n                            \"wikidata\": \"Q64\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBY1E2\",\n                            \"short_code\": \"DE-BE\",\n                        },\n                        {\n                            \"id\": \"country.8762\",\n                            \"text\": \"Germany\",\n                            \"wikidata\": \"Q183\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJam8\",\n                            \"short_code\": \"de\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [13.458408, 52.504367],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"10245, Berlin, Germany\",\n                    \"place_type\": [\"postcode\"],\n                    \"properties\": {\"mapbox_id\": \"dXJuOm1ieHBsYzpUcTQ2\"},\n                },\n                {\n                    \"id\": \"locality.90794554\",\n                    \"bbox\": [13.419752, 52.486046, 13.491453, 52.531026],\n                    \"text\": \"Friedrichshain\",\n                    \"type\": \"Feature\",\n                    \"center\": [13.45029, 52.512215],\n                    \"context\": [\n                        {\n                            \"id\": \"place.115770\",\n                            \"text\": \"Berlin\",\n                            \"wikidata\": \"Q64\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBY1E2\",\n                            \"short_code\": \"DE-BE\",\n                        },\n                        {\n                            \"id\": \"country.8762\",\n                            \"text\": \"Germany\",\n                            \"wikidata\": \"Q183\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJam8\",\n                            \"short_code\": \"de\",\n                        },\n                    ],\n                    \"geometry\": {\"type\": \"Point\", \"coordinates\": [13.45029, 52.512215]},\n                    \"relevance\": 1,\n                    \"place_name\": \"Friedrichshain, Berlin, Germany\",\n                    \"place_type\": [\"locality\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q317056\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpCV2xxT2c\",\n                    },\n                },\n                {\n                    \"id\": \"place.115770\",\n                    \"bbox\": [13.08836, 52.338261, 13.761131, 52.675502],\n                    \"text\": \"Berlin\",\n                    \"type\": \"Feature\",\n                    \"center\": [13.3888599, 52.5170365],\n                    \"context\": [\n                        {\n                            \"id\": \"country.8762\",\n                            \"text\": \"Germany\",\n                            \"wikidata\": \"Q183\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJam8\",\n                            \"short_code\": \"de\",\n                        }\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [13.3888599, 52.5170365],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Berlin, Germany\",\n                    \"place_type\": [\"region\", \"place\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q64\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpBY1E2\",\n                        \"short_code\": \"DE-BE\",\n                    },\n                },\n                {\n                    \"id\": \"country.8762\",\n                    \"bbox\": [5.866315, 47.270238, 15.041832, 55.1286491],\n                    \"text\": \"Germany\",\n                    \"type\": \"Feature\",\n                    \"center\": [10.0183432948567, 51.1334813439932],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [10.0183432948567, 51.1334813439932],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Germany\",\n                    \"place_type\": [\"country\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q183\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpJam8\",\n                        \"short_code\": \"de\",\n                    },\n                },\n            ],\n            \"attribution\": \"NOTICE: © 2023 Mapbox and its suppliers. All rights reserved. Use of this data is subject to the Mapbox Terms of Service (https://www.mapbox.com/about/maps/). This response and the information it contains may not be retained. POI(s) provided by Foursquare.\",\n            \"search_text\": \"Badehaus - Szimpla Musiksalon 10245 Friedrichshain Berlin Germany\",\n        },\n        \"captions_json\": {\n            \"places365\": {\n                \"attributes\": [\n                    \"no horizon\",\n                    \"man made\",\n                    \"enclosed area\",\n                    \"cloth\",\n                    \"natural light\",\n                    \"wood\",\n                    \"glass\",\n                    \"indoor lighting\",\n                    \"dry\",\n                ],\n                \"categories\": [\"phone booth\", \"ticket booth\"],\n                \"environment\": \"indoor\",\n            }\n        },\n        \"search_captions\": \"phone booth , ticket booth , indoor\",\n        \"search_location\": \"Badehaus - Szimpla Musiksalon 10245 Friedrichshain Berlin Germany\",\n        \"hidden\": False,\n        \"public\": False,\n        \"video\": False,\n        \"clip_embeddings\": [\n            0.02945636957883835,\n            0.0006848685443401337,\n            -0.21565294265747070,\n            -0.13726805150508880,\n            0.34177422523498535,\n            -0.21947097778320312,\n            -0.36535099148750305,\n            0.43704378604888916,\n            -0.04088297113776207,\n            -0.16377356648445130,\n            0.61226522922515870,\n            -0.27190640568733215,\n            0.21632727980613708,\n            -0.49879187345504760,\n            0.35928872227668760,\n            -0.57344657182693480,\n            -1.09348952770233150,\n            0.12758482992649078,\n            0.22626115381717682,\n            -0.29700523614883423,\n            0.67408502101898190,\n            0.20566113293170930,\n            0.004221141338348389,\n            -0.18163934350013733,\n            0.030123591423034668,\n            -0.36574870347976685,\n            0.37040638923645020,\n            -0.15804094076156616,\n            0.40848651528358460,\n            -0.44146019220352173,\n            -0.29682993888854980,\n            -0.14033758640289307,\n            0.15358698368072510,\n            0.14189875125885010,\n            -0.24039962887763977,\n            0.15688608586788177,\n            -0.13299843668937683,\n            -0.09722766280174255,\n            0.15716867148876190,\n            0.06153672933578491,\n            -0.24909989535808563,\n            0.42868158221244810,\n            -0.09139122068881989,\n            0.026236191391944885,\n            0.43363174796104430,\n            0.15078209340572357,\n            -0.02513679303228855,\n            -0.16301257908344270,\n            0.30406942963600160,\n            -0.20351555943489075,\n            0.32808005809783936,\n            0.11325813829898834,\n            0.06632904708385468,\n            -0.13974805176258087,\n            -0.08816416561603546,\n            0.08763262629508972,\n            0.36618012189865110,\n            -0.016463160514831543,\n            0.51381558179855350,\n            -0.19019323587417603,\n            0.007199913263320923,\n            -0.47690716385841370,\n            -0.011584945023059845,\n            0.27767223119735720,\n            0.05724636837840080,\n            -0.14331746101379395,\n            -0.17595694959163666,\n            0.69013822078704830,\n            -0.29242870211601260,\n            -0.20638096332550050,\n            0.15023268759250640,\n            0.39301073551177980,\n            -0.07778433710336685,\n            -0.31314268708229065,\n            -0.08601596951484680,\n            -0.33508300781250000,\n            -0.46537816524505615,\n            -0.11456200480461120,\n            0.12097512185573578,\n            -0.21884436905384064,\n            0.08210694789886475,\n            -0.20028553903102875,\n            0.019847501069307327,\n            -0.47711223363876340,\n            0.41822510957717896,\n            0.28469321131706240,\n            0.56612819433212280,\n            -0.37348130345344543,\n            0.36494156718254090,\n            -0.29691773653030396,\n            0.27490460872650146,\n            0.055256836116313934,\n            -5.04034137725830100,\n            0.21063046157360077,\n            0.07623146474361420,\n            0.91044491529464720,\n            0.09022644162178040,\n            -0.22425013780593872,\n            -0.15275251865386963,\n            0.56555438041687010,\n            -0.21015779674053192,\n            0.12018989026546478,\n            0.050851717591285706,\n            0.07409352064132690,\n            0.10203989595174790,\n            -0.14679333567619324,\n            -0.45498293638229370,\n            0.18498001992702484,\n            0.08312227576971054,\n            -0.20828101038932800,\n            0.22045168280601501,\n            -0.41870164871215820,\n            -0.18289424479007720,\n            0.27803760766983030,\n            -0.14669309556484222,\n            0.11136791110038757,\n            -0.07714871317148209,\n            0.11111782491207123,\n            0.33326071500778200,\n            -0.22490462660789490,\n            -0.023626238107681274,\n            -0.38946658372879030,\n            -0.11872470378875732,\n            0.003953278064727783,\n            0.06875754147768020,\n            0.06950961053371430,\n            0.20257902145385742,\n            0.48788213729858400,\n            -0.55383354425430300,\n            0.26022326946258545,\n            0.54247814416885380,\n            -0.75806522369384770,\n            -0.16927206516265870,\n            0.86247527599334720,\n            0.00693318247795105,\n            0.48897692561149597,\n            -0.028302762657403946,\n            -0.39459863305091860,\n            -0.09011757373809814,\n            -0.002951778471469879,\n            -0.34152612090110780,\n            -0.053652018308639526,\n            -0.05833066999912262,\n            0.23596245050430298,\n            0.11364882439374924,\n            0.29371896386146545,\n            0.20242361724376678,\n            0.41401028633117676,\n            0.29458260536193850,\n            0.12261603027582169,\n            0.35396143794059753,\n            -0.37751555442810060,\n            -0.65084457397460940,\n            0.43545067310333250,\n            -0.12954968214035034,\n            -0.55164968967437740,\n            -0.028061915189027786,\n            -0.41742083430290220,\n            -0.84223127365112300,\n            0.28066438436508180,\n            -0.39846715331077576,\n            -0.29234075546264650,\n            0.40001532435417175,\n            -0.025802582502365112,\n            0.035069286823272705,\n            0.58283793926239010,\n            1.06991338729858400,\n            -0.004069690592586994,\n            0.16848376393318176,\n            -0.32832053303718567,\n            -0.05214332789182663,\n            -0.22528168559074402,\n            -0.012514784932136536,\n            -0.22871030867099762,\n            -0.46591621637344360,\n            0.43959569931030273,\n            0.52867388725280760,\n            0.08700185269117355,\n            0.82598596811294560,\n            0.72045898437500000,\n            -0.18566657602787018,\n            0.31717744469642640,\n            -0.32986947894096375,\n            -0.08980216830968857,\n            -0.29798981547355650,\n            -0.20281662046909332,\n            0.35008898377418520,\n            -0.06911731511354446,\n            0.46833136677742004,\n            -0.05824550986289978,\n            0.22831164300441742,\n            -0.14332316815853120,\n            -0.54719811677932740,\n            -0.033745840191841125,\n            -0.14754259586334229,\n            -0.15379694104194640,\n            0.27912741899490356,\n            -0.38673168420791626,\n            0.18064787983894348,\n            0.02594844251871109,\n            -0.19353626668453217,\n            0.12792934477329254,\n            0.76823699474334720,\n            -0.25351685285568240,\n            0.19726955890655518,\n            0.04505273699760437,\n            0.08072985708713531,\n            -0.40315085649490356,\n            -0.05216634273529053,\n            0.33589959144592285,\n            0.13084968924522400,\n            -0.71316510438919070,\n            0.62502771615982060,\n            0.15854838490486145,\n            -0.46951773762702940,\n            -0.05899134278297424,\n            -0.07570817321538925,\n            -0.21729907393455505,\n            -0.48353233933448790,\n            -0.30078506469726560,\n            0.01950731873512268,\n            0.35301312804222107,\n            -0.11586478352546692,\n            0.06945395469665527,\n            -0.09096579253673553,\n            -0.003176487982273102,\n            -0.06729926168918610,\n            0.16478994488716125,\n            0.11996459960937500,\n            0.08691802620887756,\n            -0.37051337957382200,\n            -0.27022105455398560,\n            0.44754734635353090,\n            0.58564084768295290,\n            0.14264307916164398,\n            -0.27284860610961914,\n            -0.21267735958099365,\n            -0.62931954860687260,\n            -0.17053475975990295,\n            0.43907707929611206,\n            -0.17706312239170074,\n            -0.20798830687999725,\n            -0.013556711375713348,\n            -0.45459657907485960,\n            0.27621361613273620,\n            -0.00547829270362854,\n            0.09221091866493225,\n            -0.45583301782608030,\n            -0.04141671583056450,\n            -0.04372096061706543,\n            -0.08653303980827332,\n            0.16906698048114777,\n            -0.39207679033279420,\n            0.16125959157943726,\n            0.18793570995330810,\n            -0.062303997576236725,\n            -0.14904721081256866,\n            -0.08313135802745819,\n            0.71267020702362060,\n            -0.12299664318561554,\n            0.45854219794273376,\n            0.37172207236289980,\n            0.23576152324676514,\n            -0.96158236265182500,\n            -0.16355091333389282,\n            0.08433900773525238,\n            -0.26110875606536865,\n            0.41418594121932983,\n            -0.00952976569533348,\n            -0.31196358799934387,\n            0.08939869701862335,\n            0.08166363835334778,\n            0.27331307530403137,\n            -0.23260714113712310,\n            -0.11067157238721848,\n            0.07786403596401215,\n            0.15424127876758575,\n            -0.15310212969779968,\n            -0.57094782590866090,\n            0.08447279036045074,\n            0.09531763195991516,\n            -0.16973701119422913,\n            -0.39517831802368164,\n            0.48301315307617190,\n            -0.49167299270629883,\n            0.16578084230422974,\n            0.012142054736614227,\n            0.38737818598747253,\n            -0.42219209671020510,\n            0.038790080696344376,\n            0.14427416026592255,\n            0.51845633983612060,\n            0.30745857954025270,\n            0.19423109292984010,\n            -0.29949772357940674,\n            -0.22886085510253906,\n            -0.18502810597419740,\n            0.58494806289672850,\n            -0.05995021387934685,\n            0.027786150574684143,\n            0.16225285828113556,\n            -0.21960149705410004,\n            -0.19987022876739502,\n            0.17615880072116852,\n            0.12230071425437927,\n            0.78366780281066900,\n            0.07176887989044190,\n            0.34315368533134460,\n            0.32958623766899110,\n            0.046860724687576294,\n            -0.37855774164199830,\n            0.10880301892757416,\n            0.86260342597961430,\n            -0.25351122021675110,\n            -0.33743318915367126,\n            -0.000004470348358154297,\n            0.10894200950860977,\n            -0.24402043223381042,\n            0.041552767157554626,\n            1.11131787300109860,\n            0.19667577743530273,\n            0.09909820556640625,\n            -0.22913703322410583,\n            0.07402139902114868,\n            -0.51243948936462400,\n            -0.011785134673118591,\n            0.11913090944290161,\n            0.10973167419433594,\n            0.14159473776817322,\n            -0.25530740618705750,\n            -0.10128769278526306,\n            -0.018086418509483337,\n            0.23918014764785767,\n            -0.0025010444223880768,\n            0.028242304921150208,\n            -0.10461166501045227,\n            0.31454890966415405,\n            -0.21300530433654785,\n            -0.67316675186157230,\n            -0.28894531726837160,\n            0.15850226581096650,\n            -0.25393971800804140,\n            0.015249133110046387,\n            0.17902487516403198,\n            0.03674941509962082,\n            -0.40653368830680847,\n            0.06983637809753418,\n            0.58390831947326660,\n            -0.35204166173934937,\n            -0.23273511230945587,\n            -0.50346362590789800,\n            -0.18698497116565704,\n            -0.13735061883926392,\n            -0.029784053564071655,\n            -0.23463270068168640,\n            0.04800243675708771,\n            0.21194185316562653,\n            2.50413703918457030,\n            0.014399580657482147,\n            0.13682425022125244,\n            -0.44549101591110230,\n            -0.05332183837890625,\n            0.26232331991195680,\n            0.48867249488830566,\n            0.90782088041305540,\n            -0.04284442216157913,\n            0.16441944241523743,\n            -0.64545953273773190,\n            0.0021270206198096275,\n            0.57723963260650630,\n            -0.16735523939132690,\n            -0.03681857883930206,\n            -0.23049485683441162,\n            -0.16818353533744812,\n            0.48354497551918030,\n            -0.34307366609573364,\n            0.90225481986999510,\n            -0.08604586869478226,\n            -0.80853521823883060,\n            0.07412493973970413,\n            0.38500127196311950,\n            -0.53182345628738400,\n            -0.37833765149116516,\n            -0.27583479881286620,\n            0.18323482573032380,\n            -0.57118958234786990,\n            0.15203747153282166,\n            -0.45285981893539430,\n            0.14797788858413696,\n            0.92780256271362300,\n            -0.99070614576339720,\n            0.78525125980377200,\n            -0.54372054338455200,\n            0.27046817541122437,\n            0.05927395448088646,\n            0.29292181134223940,\n            0.17652636766433716,\n            -0.28721928596496580,\n            -0.11287573724985123,\n            0.054300934076309204,\n            -0.20183458924293518,\n            -0.06938034296035767,\n            -0.02237860858440399,\n            -0.28468230366706850,\n            0.27039328217506410,\n            -0.44048523902893066,\n            -0.10478976368904114,\n            0.22237929701805115,\n            0.61014431715011600,\n            0.22426150739192963,\n            -0.53682762384414670,\n            0.17780613899230957,\n            0.10927680134773254,\n            -0.08265303075313568,\n            0.08433099091053009,\n            0.22053673863410950,\n            -0.15935529768466950,\n            0.02448994666337967,\n            -0.15990000963211060,\n            -0.10862783342599869,\n            -0.55222094058990480,\n            -0.18120177090168000,\n            -0.18480616807937622,\n            -0.40381407737731934,\n            0.41113349795341490,\n            0.45374616980552673,\n            0.12371205538511276,\n            0.54327690601348880,\n            -1.32808542251586910,\n            0.06879405677318573,\n            -0.32771402597427370,\n            0.30996549129486084,\n            0.73747062683105470,\n            0.0026262253522872925,\n            -0.07966820895671844,\n            -0.43908175826072693,\n            -0.57706052064895630,\n            -0.07506740093231201,\n            -0.03822087496519089,\n            0.14753995835781097,\n            0.26234555244445800,\n            0.37969052791595460,\n            -0.03944581747055054,\n            -0.21361999213695526,\n            0.30636596679687500,\n            0.36208370327949524,\n            -0.51721900701522830,\n            -0.72269493341445920,\n            0.12646321952342987,\n            0.30191451311111450,\n            0.88483667373657230,\n            0.13586276769638062,\n            -0.47170540690422060,\n            -0.22053156793117523,\n            -0.32388603687286377,\n            0.28210371732711790,\n            -0.14361841976642610,\n            0.35813957452774050,\n            -0.71662276983261110,\n            -0.36910200119018555,\n            -0.50887799263000490,\n            0.18198430538177490,\n            -0.12547180056571960,\n            0.019948184490203857,\n            -0.027965955436229706,\n            0.23368665575981140,\n            0.08205719292163849,\n            0.03229612857103348,\n            -0.37393200397491455,\n            0.09985578060150146,\n            0.16484981775283813,\n            -0.03449898585677147,\n            -0.20320037007331848,\n            0.17666932940483093,\n            -0.10235498100519180,\n            -0.18969213962554932,\n            -0.06653118133544922,\n            0.32699528336524963,\n            -0.41083279252052307,\n            0.17249025404453278,\n            -0.71300625801086430,\n            -0.40383389592170715,\n            -0.012773919850587845,\n            -0.04669109359383583,\n            -0.16580447554588318,\n            -0.034973472356796265,\n            0.18406766653060913,\n            0.32842925190925600,\n            -0.43452721834182740,\n            0.07465618103742600,\n            -0.054404426366090775,\n            0.07043577730655670,\n            0.34492620825767517,\n            -0.55067378282547000,\n            -0.35782644152641296,\n            0.22946852445602417,\n            0.13374575972557068,\n            0.23378580808639526,\n            -0.18907654285430908,\n            -0.20370183885097504,\n            -0.63759642839431760,\n            0.29174658656120300,\n            -0.20586502552032470,\n            0.08419455587863922,\n            0.96353048086166380,\n            -0.023577764630317688,\n            0.34594947099685670,\n            -0.07180836796760559,\n            -0.11015791445970535,\n            -0.10569672286510468,\n            0.29831579327583313,\n            0.21254715323448180,\n        ],\n        \"clip_embeddings_magnitude\": 9.564716339111328,\n        \"aspect_ratio\": 0.75,\n        \"rating\": 0,\n        \"dominant_color\": \"[145, 83, 48]\",\n        \"video_length\": None,\n        \"in_trashcan\": False,\n        \"removed\": False,\n        \"timestamp\": None,\n        \"camera\": \"Pixel 2\",\n        \"digitalZoomRatio\": None,\n        \"focalLength35Equivalent\": None,\n        \"focal_length\": 4.442,\n        \"fstop\": 1.8,\n        \"height\": 4032,\n        \"iso\": None,\n        \"lens\": None,\n        \"shutter_speed\": \"1/100\",\n        \"size\": 7194067,\n        \"subjectDistance\": 0.614,\n        \"width\": 3024,\n        \"main_file_id\": \"88070102f4a9a25ba26959b8e1f203a91\",\n    },\n    {\n        \"thumbnail_big\": \"thumbnails_big/cac6402c7ff192e6fa96fee50ba24fd81.webp\",\n        \"square_thumbnail\": \"square_thumbnails/cac6402c7ff192e6fa96fee50ba24fd81.webp\",\n        \"square_thumbnail_small\": \"square_thumbnails_small/cac6402c7ff192e6fa96fee50ba24fd81.webp\",\n        \"added_on\": \"2023-06-16 16:30:44.284649 +00:00\",\n        \"exif_gps_lat\": 34.1620972222222,\n        \"exif_gps_lon\": 77.5858416666667,\n        \"exif_timestamp\": \"2017-08-09 23:04:03.000000 +00:00\",\n        \"exif_json\": None,\n        \"geolocation_json\": {\n            \"type\": \"FeatureCollection\",\n            \"query\": [77.585842, 34.162097],\n            \"features\": [\n                {\n                    \"id\": \"address.15438142488176\",\n                    \"text\": \"Main Bazaar\",\n                    \"type\": \"Feature\",\n                    \"center\": [77.585527, 34.1622089],\n                    \"context\": [\n                        {\n                            \"id\": \"postcode.13053547\",\n                            \"text\": \"194101\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzp4eTVy\",\n                        },\n                        {\n                            \"id\": \"locality.997100139\",\n                            \"text\": \"Chuchat Yakma\",\n                            \"wikidata\": \"Q24909733\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpPMjZLYXc\",\n                        },\n                        {\n                            \"id\": \"place.25135211\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q230818\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWCtJYXc\",\n                        },\n                        {\n                            \"id\": \"district.3196523\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q1921210\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpNTVpy\",\n                        },\n                        {\n                            \"id\": \"region.222315\",\n                            \"text\": \"Ladakh\",\n                            \"wikidata\": \"Q200667\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBMlJy\",\n                            \"short_code\": \"IN-LA\",\n                        },\n                        {\n                            \"id\": \"country.8811\",\n                            \"text\": \"India\",\n                            \"wikidata\": \"Q668\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                            \"short_code\": \"in\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [77.585527, 34.1622089],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Main Bazaar ، 194101 Leh، India\",\n                    \"place_type\": [\"address\"],\n                    \"properties\": {\"accuracy\": \"street\"},\n                },\n                {\n                    \"id\": \"postcode.13053547\",\n                    \"bbox\": [77.107033, 33.67805, 77.812822, 34.511912],\n                    \"text\": \"194101\",\n                    \"type\": \"Feature\",\n                    \"center\": [77.572696, 34.199537],\n                    \"context\": [\n                        {\n                            \"id\": \"locality.997100139\",\n                            \"text\": \"Chuchat Yakma\",\n                            \"wikidata\": \"Q24909733\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpPMjZLYXc\",\n                        },\n                        {\n                            \"id\": \"place.25135211\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q230818\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWCtJYXc\",\n                        },\n                        {\n                            \"id\": \"district.3196523\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q1921210\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpNTVpy\",\n                        },\n                        {\n                            \"id\": \"region.222315\",\n                            \"text\": \"Ladakh\",\n                            \"wikidata\": \"Q200667\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBMlJy\",\n                            \"short_code\": \"IN-LA\",\n                        },\n                        {\n                            \"id\": \"country.8811\",\n                            \"text\": \"India\",\n                            \"wikidata\": \"Q668\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                            \"short_code\": \"in\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [77.572696, 34.199537],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"194101, Leh, Ladakh, India\",\n                    \"place_type\": [\"postcode\"],\n                    \"properties\": {\"mapbox_id\": \"dXJuOm1ieHBsYzp4eTVy\"},\n                },\n                {\n                    \"id\": \"locality.997100139\",\n                    \"bbox\": [76.626529011, 33.347769, 78.047440762, 34.538001],\n                    \"text\": \"Chuchat Yakma\",\n                    \"type\": \"Feature\",\n                    \"center\": [77.602518, 34.07534],\n                    \"context\": [\n                        {\n                            \"id\": \"place.25135211\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q230818\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWCtJYXc\",\n                        },\n                        {\n                            \"id\": \"district.3196523\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q1921210\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpNTVpy\",\n                        },\n                        {\n                            \"id\": \"region.222315\",\n                            \"text\": \"Ladakh\",\n                            \"wikidata\": \"Q200667\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBMlJy\",\n                            \"short_code\": \"IN-LA\",\n                        },\n                        {\n                            \"id\": \"country.8811\",\n                            \"text\": \"India\",\n                            \"wikidata\": \"Q668\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                            \"short_code\": \"in\",\n                        },\n                    ],\n                    \"geometry\": {\"type\": \"Point\", \"coordinates\": [77.602518, 34.07534]},\n                    \"relevance\": 1,\n                    \"place_name\": \"Chuchat Yakma, Leh, Leh, Ladakh, India\",\n                    \"place_type\": [\"locality\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q24909733\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpPMjZLYXc\",\n                    },\n                },\n                {\n                    \"id\": \"place.25135211\",\n                    \"bbox\": [77.107033, 32.33574, 79.305839, 35.522256],\n                    \"text\": \"Leh\",\n                    \"type\": \"Feature\",\n                    \"center\": [77.584813, 34.164203],\n                    \"context\": [\n                        {\n                            \"id\": \"district.3196523\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q1921210\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpNTVpy\",\n                        },\n                        {\n                            \"id\": \"region.222315\",\n                            \"text\": \"Ladakh\",\n                            \"wikidata\": \"Q200667\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBMlJy\",\n                            \"short_code\": \"IN-LA\",\n                        },\n                        {\n                            \"id\": \"country.8811\",\n                            \"text\": \"India\",\n                            \"wikidata\": \"Q668\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                            \"short_code\": \"in\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [77.584813, 34.164203],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Leh, Ladakh, India\",\n                    \"place_type\": [\"place\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q230818\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpBWCtJYXc\",\n                    },\n                },\n                {\n                    \"id\": \"region.222315\",\n                    \"bbox\": [75.306629, 32.33574, 79.305851, 35.673315],\n                    \"text\": \"Ladakh\",\n                    \"type\": \"Feature\",\n                    \"center\": [77.27783203125, 34.0071350643588],\n                    \"context\": [\n                        {\n                            \"id\": \"country.8811\",\n                            \"text\": \"India\",\n                            \"wikidata\": \"Q668\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                            \"short_code\": \"in\",\n                        }\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [77.27783203125, 34.0071350643588],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Ladakh, India\",\n                    \"place_type\": [\"region\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q200667\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpBMlJy\",\n                        \"short_code\": \"IN-LA\",\n                    },\n                },\n                {\n                    \"id\": \"country.8811\",\n                    \"bbox\": [68.1152344, 6.6718373, 97.395359, 35.673315],\n                    \"text\": \"India\",\n                    \"type\": \"Feature\",\n                    \"center\": [78.476681027237, 22.1991660760527],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [78.476681027237, 22.1991660760527],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"India\",\n                    \"place_type\": [\"country\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q668\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                        \"short_code\": \"in\",\n                    },\n                },\n            ],\n            \"attribution\": \"NOTICE: © 2023 Mapbox and its suppliers. All rights reserved. Use of this data is subject to the Mapbox Terms of Service (https://www.mapbox.com/about/maps/). This response and the information it contains may not be retained. POI(s) provided by Foursquare.\",\n            \"search_text\": \"Main Bazaar 194101 Chuchat Yakma Leh Ladakh India\",\n        },\n        \"captions_json\": {\n            \"places365\": {\n                \"attributes\": [\n                    \"no horizon\",\n                    \"cloth\",\n                    \"enclosed area\",\n                    \"natural light\",\n                    \"man made\",\n                    \"rugged scene\",\n                    \"dry\",\n                    \"shopping\",\n                    \"working\",\n                ],\n                \"categories\": [\n                    \"butchers shop\",\n                    \"bakery shop\",\n                    \"delicatessen\",\n                    \"market outdoor\",\n                ],\n                \"environment\": \"indoor\",\n            }\n        },\n        \"search_captions\": \"butchers shop , bakery shop , delicatessen , market outdoor , indoor\",\n        \"search_location\": \"Main Bazaar 194101 Chuchat Yakma Leh Ladakh India\",\n        \"hidden\": False,\n        \"public\": False,\n        \"video\": False,\n        \"clip_embeddings\": [\n            -0.36275115609169006,\n            0.04232349991798401,\n            0.16003805398941040,\n            -0.15414649248123170,\n            0.07362174987792969,\n            0.30168089270591736,\n            0.29503446817398070,\n            0.52876400947570800,\n            0.03624974191188812,\n            0.57748210430145260,\n            0.06661064177751541,\n            -0.08749669790267944,\n            -0.43980333209037780,\n            -0.45719712972640990,\n            0.20980522036552430,\n            0.31552031636238100,\n            0.28292679786682130,\n            -0.23881813883781433,\n            0.26318275928497314,\n            -0.25081199407577515,\n            -1.56406641006469730,\n            -0.20272035896778107,\n            -0.30980134010314940,\n            -0.13830965757369995,\n            -0.65654301643371580,\n            0.31328544020652770,\n            0.39124441146850586,\n            0.14648842811584473,\n            0.10790233314037323,\n            -0.0011418797075748444,\n            0.29148331284523010,\n            0.39446878433227540,\n            0.15861868858337402,\n            -0.01094374805688858,\n            0.69656980037689210,\n            0.53402501344680790,\n            -0.19458736479282380,\n            -0.33765941858291626,\n            0.20578813552856445,\n            1.02007591724395750,\n            -0.27630817890167236,\n            -0.055134907364845276,\n            0.10728104412555695,\n            0.19857768714427948,\n            -0.051949791610240936,\n            -0.95591288805007930,\n            -0.016068458557128906,\n            0.14163839817047120,\n            0.07818788290023804,\n            0.30343624949455260,\n            -0.00487295538187027,\n            -0.03668808937072754,\n            0.23092812299728394,\n            0.22601000964641570,\n            -0.17142324149608612,\n            0.19191186130046844,\n            0.24479267001152039,\n            0.75923907756805420,\n            0.52936017513275150,\n            0.59186935424804690,\n            -0.60621523857116700,\n            0.32727080583572390,\n            -0.11824478209018707,\n            -0.05898610129952431,\n            -0.012164704501628876,\n            -0.10524882376194000,\n            -0.64567971229553220,\n            1.40957057476043700,\n            -0.30128875374794006,\n            -0.52295249700546260,\n            0.053494296967983246,\n            -0.34456601738929750,\n            -0.22763675451278687,\n            -0.07936172187328339,\n            -0.61760181188583370,\n            -0.18934124708175660,\n            -0.04926346242427826,\n            0.07053080201148987,\n            -0.32558596134185790,\n            -0.61692816019058230,\n            0.25635927915573120,\n            -0.40595006942749023,\n            -0.45488622784614563,\n            -1.27675628662109380,\n            0.23719409108161926,\n            -0.24088490009307860,\n            -0.042445749044418335,\n            -0.0018720626831054688,\n            1.05558359622955320,\n            -0.49247956275939940,\n            0.17333477735519410,\n            0.035649579018354416,\n            -5.73797512054443400,\n            1.30945181846618650,\n            0.51552683115005490,\n            0.15089595317840576,\n            0.35859110951423645,\n            -0.04071284830570221,\n            -0.86825460195541380,\n            1.31599712371826170,\n            -0.09168644249439240,\n            0.63117879629135130,\n            -0.17361941933631897,\n            0.10322868078947067,\n            -0.60555946826934810,\n            0.23382598161697388,\n            -0.82185673713684080,\n            -0.10401326417922974,\n            0.13238205015659332,\n            -0.03597678989171982,\n            -0.51136314868927000,\n            0.15768136084079742,\n            -0.23854430019855500,\n            -0.14175727963447570,\n            -0.11781750619411469,\n            0.38926169276237490,\n            -0.31990337371826170,\n            0.24176508188247680,\n            0.59939318895339970,\n            -0.72715508937835690,\n            0.39353600144386290,\n            -0.38137459754943850,\n            0.20685830712318420,\n            -0.38994306325912476,\n            0.05647854506969452,\n            -0.23935276269912720,\n            -0.22889091074466705,\n            -0.019344180822372437,\n            0.040057696402072906,\n            -0.24383866786956787,\n            -0.39092701673507690,\n            -0.018549658358097076,\n            0.51890051364898680,\n            0.86515939235687260,\n            0.28614503145217896,\n            0.64386940002441410,\n            0.31628671288490295,\n            -0.40825888514518740,\n            -0.33183339238166810,\n            0.16869042813777924,\n            0.010168105363845825,\n            0.30278283357620240,\n            -0.60569459199905400,\n            -0.20262216031551360,\n            -1.09207832813262940,\n            -0.027563804760575294,\n            0.06484303623437881,\n            0.27420419454574585,\n            -0.49345779418945310,\n            0.10804055631160736,\n            -0.14800167083740234,\n            0.08484844118356705,\n            -0.78326535224914550,\n            -0.006926223635673523,\n            -0.25266957283020020,\n            -0.26330786943435670,\n            0.89378148317337040,\n            -0.64368873834609990,\n            0.25188437104225160,\n            0.04949730634689331,\n            -0.23263952136039734,\n            -0.0038242843002080917,\n            0.24761386215686798,\n            0.21502655744552612,\n            0.27725243568420410,\n            -0.03591444715857506,\n            0.11199191957712173,\n            0.35744479298591614,\n            0.15654230117797852,\n            -0.11716540157794952,\n            -0.49658292531967163,\n            -0.12044863402843475,\n            -0.59336757659912110,\n            -0.04827989637851715,\n            0.27895766496658325,\n            0.60082894563674930,\n            -0.44568234682083130,\n            0.18149539828300476,\n            0.84179604053497310,\n            -0.20165166258811950,\n            0.15945611894130707,\n            -0.19363774359226227,\n            -0.38449636101722720,\n            -0.38157621026039124,\n            0.23142817616462708,\n            -0.32686632871627810,\n            0.32904222607612610,\n            -0.62547576427459720,\n            -0.17987522482872010,\n            0.13854908943176270,\n            0.28109687566757200,\n            -0.66307181119918820,\n            -0.10197299718856812,\n            0.23179894685745240,\n            0.14371933043003082,\n            -0.015187196433544159,\n            -0.005701176822185516,\n            -0.009048223495483398,\n            -0.36947503685951233,\n            -0.08419185131788254,\n            0.18359073996543884,\n            0.36397165060043335,\n            -0.18554688990116120,\n            0.06282243132591248,\n            0.12127010524272919,\n            0.10142311453819275,\n            -0.75937509536743160,\n            0.14368136227130890,\n            -0.15232974290847778,\n            0.030483879148960114,\n            -0.20716962218284607,\n            0.09505847096443176,\n            -0.03633452206850052,\n            0.11635903269052505,\n            0.19411554932594300,\n            -0.34413999319076540,\n            0.09673729538917542,\n            0.15466451644897460,\n            -0.11886352300643921,\n            -0.31654387712478640,\n            0.023902088403701782,\n            0.59250128269195560,\n            -0.03467556834220886,\n            -0.19663128256797790,\n            0.07225932180881500,\n            -0.20673781633377075,\n            0.10626789927482605,\n            0.27759736776351930,\n            0.14237537980079650,\n            -0.06108829379081726,\n            -0.14290234446525574,\n            -0.034046679735183716,\n            -0.23872807621955872,\n            -0.30120781064033510,\n            0.53876233100891110,\n            0.47913902997970580,\n            0.15108020603656770,\n            -0.21000367403030396,\n            -0.09006668627262115,\n            -0.12558355927467346,\n            -0.29648718237876890,\n            -0.09471682459115982,\n            0.10165409743785858,\n            -0.04843531921505928,\n            -0.20445811748504640,\n            -0.64695239067077640,\n            0.34345996379852295,\n            0.25094231963157654,\n            0.28040134906768800,\n            0.26930731534957886,\n            -0.18992258608341217,\n            -0.31795889139175415,\n            0.10275565087795258,\n            -0.31493186950683594,\n            0.05224027484655380,\n            0.03900603950023651,\n            -0.15962079167366028,\n            -0.40141177177429200,\n            1.50395190715789800,\n            -0.18131579458713531,\n            0.25262355804443360,\n            0.56724452972412110,\n            0.19896474480628967,\n            0.22113317251205444,\n            -0.37682905793190000,\n            -0.34762057662010193,\n            -0.36291345953941345,\n            0.38737395405769350,\n            0.39863935112953186,\n            0.06930093467235565,\n            -0.07484766840934753,\n            0.26550960540771484,\n            -0.37431126832962036,\n            0.06163658946752548,\n            -0.16546487808227540,\n            -0.16572664678096770,\n            -0.41679629683494570,\n            -0.22878777980804443,\n            0.17536523938179016,\n            0.43185859918594360,\n            -0.18755021691322327,\n            0.57233977317810060,\n            0.34548804163932800,\n            0.26037001609802246,\n            0.57140541076660160,\n            1.03332614898681640,\n            -1.69704651832580570,\n            0.44388985633850100,\n            0.08646445721387863,\n            -0.32414677739143370,\n            -0.39306485652923584,\n            0.21713417768478394,\n            -0.38696998357772827,\n            0.16778808832168580,\n            -0.05270355939865112,\n            -0.22387394309043884,\n            0.07469445466995239,\n            0.04779075086116791,\n            0.30713665485382080,\n            -0.33245295286178590,\n            -0.26004213094711304,\n            -0.12230075895786285,\n            -0.65894758701324460,\n            0.06937650591135025,\n            0.11567105352878570,\n            0.38836821913719180,\n            -0.07238084077835083,\n            0.58317494392395020,\n            0.71717137098312380,\n            0.10365724563598633,\n            -0.58237200975418090,\n            0.01732088252902031,\n            0.86498779058456420,\n            -0.04113391041755676,\n            -0.09420709311962128,\n            0.19938360154628754,\n            0.004110394045710564,\n            0.0035112202167510986,\n            0.32821851968765260,\n            0.20233154296875000,\n            0.10732693225145340,\n            1.30262577533721920,\n            -0.25104320049285890,\n            -0.09294586628675461,\n            0.07033525407314300,\n            0.26292678713798523,\n            -0.10476322472095490,\n            0.49015024304389954,\n            -0.38899934291839600,\n            -0.057010307908058167,\n            -0.19918194413185120,\n            -0.23839278519153595,\n            0.34912475943565370,\n            -0.38353130221366880,\n            0.034570418298244476,\n            -0.10393957048654556,\n            -0.19936946034431458,\n            0.30964389443397520,\n            -0.012369591742753983,\n            -0.14967474341392517,\n            -0.11177364736795425,\n            -0.19989341497421265,\n            0.62171781063079830,\n            0.20537722110748290,\n            0.16507479548454285,\n            -0.59005945920944210,\n            -0.11848224699497223,\n            0.00022661685943603516,\n            0.03684444725513458,\n            0.06734884530305862,\n            -0.06063830852508545,\n            0.06855276226997375,\n            0.38286936283111570,\n            -0.07564809173345566,\n            0.11344917118549347,\n            -0.42775198817253113,\n            -0.23143202066421510,\n            -0.13804715871810913,\n            0.08444565534591675,\n            -0.17835569381713867,\n            -0.56565302610397340,\n            -0.04202693700790405,\n            0.09906928986310959,\n            0.78766095638275150,\n            0.38724774122238160,\n            0.22494032979011536,\n            -0.09758158773183823,\n            0.30130439996719360,\n            0.08548129349946976,\n            0.012833205051720142,\n            -0.33451801538467410,\n            -0.02345825731754303,\n            -0.11464481055736542,\n            0.20209941267967224,\n            0.13876622915267944,\n            -0.09112481772899628,\n            0.59946906566619870,\n            -0.31222972273826600,\n            -0.15563198924064636,\n            -0.019779425114393234,\n            -0.18419799208641052,\n            -0.36252117156982420,\n            -0.66804647445678710,\n            0.07264155894517899,\n            0.14690190553665160,\n            0.58527380228042600,\n            0.10589228570461273,\n            0.15317067503929138,\n            -0.14340689778327942,\n            0.11329543590545654,\n            0.21681559085845947,\n            -0.01961892843246460,\n            0.22408972680568695,\n            0.40270829200744630,\n            -0.28345388174057007,\n            0.001580655574798584,\n            0.29231256246566770,\n            -0.31483244895935060,\n            -0.66095465421676640,\n            0.12513107061386108,\n            0.25342079997062683,\n            -0.35947406291961670,\n            0.10373535752296448,\n            -0.14622645080089570,\n            0.03869847208261490,\n            -0.13646872341632843,\n            -0.054877884685993195,\n            -0.29587787389755250,\n            0.10567575693130493,\n            -0.27623349428176880,\n            -0.16489294171333313,\n            0.03129418194293976,\n            -0.36633712053298950,\n            -0.30330657958984375,\n            -0.10047523677349090,\n            0.38868999481201170,\n            0.18341153860092163,\n            -0.42602014541625977,\n            -0.42138785123825073,\n            0.16399800777435303,\n            -0.13668435811996460,\n            0.08918181061744690,\n            0.18008530139923096,\n            0.34570962190628050,\n            0.13715223968029022,\n            -0.35308316349983215,\n            0.017223667353391647,\n            0.16259710490703583,\n            -2.01324987411499020,\n            0.35719367861747740,\n            0.05423700809478760,\n            0.64791858196258540,\n            1.41284525394439700,\n            -0.025331489741802216,\n            0.03643947094678879,\n            -0.07986896485090256,\n            -0.45607382059097290,\n            -0.36353543400764465,\n            -0.022743068635463715,\n            0.41851943731307983,\n            0.05244317650794983,\n            0.17263151705265045,\n            0.07288816571235657,\n            0.30261635780334470,\n            -0.38646593689918520,\n            -0.054808780550956726,\n            -0.28826522827148440,\n            -0.01994667947292328,\n            -0.48321542143821716,\n            -0.21889880299568176,\n            -0.26040261983871460,\n            -0.52937489748001100,\n            -0.041442275047302246,\n            -1.15707290172576900,\n            0.22746005654335022,\n            -0.12931922078132630,\n            0.35249620676040650,\n            0.27543902397155760,\n            -0.39276057481765747,\n            -0.42217552661895750,\n            -0.22715577483177185,\n            0.004897281527519226,\n            -0.31350314617156980,\n            -0.45807397365570070,\n            0.25268691778182983,\n            0.04663296043872833,\n            -0.59933739900588990,\n            0.15925574302673340,\n            -0.08339263498783112,\n            0.18594521284103394,\n            -0.13940119743347168,\n            0.30508360266685486,\n            -0.24364741146564484,\n            -0.64189815521240230,\n            -0.02178923785686493,\n            -0.22042772173881530,\n            0.34853425621986390,\n            0.46653327345848083,\n            -0.03486161679029465,\n            0.50477695465087890,\n            -0.10376831889152527,\n            -0.14050556719303130,\n            -0.08073642104864120,\n            0.06925593316555023,\n            0.21366350352764130,\n            0.24056504666805267,\n            0.12795145809650420,\n            0.16419601440429688,\n            0.06756377220153809,\n            -0.18162062764167786,\n            0.33428809046745300,\n            0.06203627586364746,\n            0.63255858421325680,\n            -0.51533907651901250,\n            0.13819636404514313,\n            0.07493585348129272,\n            -0.19676387310028076,\n            0.30928391218185425,\n            -0.47425344586372375,\n            0.09209989756345749,\n            0.44058740139007570,\n            0.24711114168167114,\n            -0.32648178935050964,\n            0.40268272161483765,\n            0.11267729103565216,\n            0.17878434062004090,\n            -0.52805340290069580,\n            0.46472996473312380,\n            -0.47340789437294006,\n            0.62205803394317630,\n            -0.28207665681838990,\n            0.04906800761818886,\n        ],\n        \"clip_embeddings_magnitude\": 10.556536674499512,\n        \"aspect_ratio\": 1.33,\n        \"rating\": 0,\n        \"dominant_color\": \"[216, 130, 71]\",\n        \"video_length\": None,\n        \"removed\": False,\n        \"in_trashcan\": False,\n        \"timestamp\": None,\n        \"camera\": \"Pixel 2\",\n        \"digitalZoomRatio\": None,\n        \"focalLength35Equivalent\": None,\n        \"focal_length\": 4.442,\n        \"fstop\": 1.8,\n        \"height\": 3024,\n        \"iso\": None,\n        \"lens\": None,\n        \"shutter_speed\": \"1/1000\",\n        \"size\": 7487773,\n        \"subjectDistance\": 0.265,\n        \"width\": 4032,\n        \"main_file_id\": \"cac6402c7ff192e6fa96fee50ba24fd81\",\n    },\n    {\n        \"thumbnail_big\": \"thumbnails_big/1f522608a263a88ea72010fe4f08f3071.webp\",\n        \"square_thumbnail\": \"square_thumbnails/1f522608a263a88ea72010fe4f08f3071.webp\",\n        \"square_thumbnail_small\": \"square_thumbnails_small/1f522608a263a88ea72010fe4f08f3071.webp\",\n        \"added_on\": \"2023-06-16 16:30:51.051404 +00:00\",\n        \"exif_gps_lat\": -33.941975,\n        \"exif_gps_lon\": 151.265088888889,\n        \"exif_timestamp\": \"2017-10-17 10:13:13.000000 +00:00\",\n        \"exif_json\": None,\n        \"geolocation_json\": {\n            \"type\": \"FeatureCollection\",\n            \"query\": [151.265089, -33.941975],\n            \"features\": [\n                {\n                    \"id\": \"poi.721554610670\",\n                    \"text\": \"Mistral Point\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.26523, -33.941658],\n                    \"context\": [\n                        {\n                            \"id\": \"postcode.503310\",\n                            \"text\": \"2035\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpCNjRP\",\n                        },\n                        {\n                            \"id\": \"locality.269412878\",\n                            \"text\": \"Maroubra\",\n                            \"wikidata\": \"Q2914843\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpFQTdxRGc\",\n                        },\n                        {\n                            \"id\": \"place.24496142\",\n                            \"text\": \"Sydney\",\n                            \"wikidata\": \"Q3130\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                        },\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [151.26523, -33.941658],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Mistral Point, Sydney, New South Wales 2035, Australia\",\n                    \"place_type\": [\"poi\"],\n                    \"properties\": {\n                        \"category\": \"historic site, historic\",\n                        \"landmark\": True,\n                        \"foursquare\": \"5958b8724420d86b1808f7bd\",\n                    },\n                },\n                {\n                    \"id\": \"postcode.503310\",\n                    \"bbox\": [151.205214, -33.958015, 151.265693, -33.931801],\n                    \"text\": \"2035\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.242438, -33.942906],\n                    \"context\": [\n                        {\n                            \"id\": \"locality.269412878\",\n                            \"text\": \"Maroubra\",\n                            \"wikidata\": \"Q2914843\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpFQTdxRGc\",\n                        },\n                        {\n                            \"id\": \"place.24496142\",\n                            \"text\": \"Sydney\",\n                            \"wikidata\": \"Q3130\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                        },\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [151.242438, -33.942906],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"2035, Maroubra, New South Wales, Australia\",\n                    \"place_type\": [\"postcode\"],\n                    \"properties\": {\"mapbox_id\": \"dXJuOm1ieHBsYzpCNjRP\"},\n                },\n                {\n                    \"id\": \"locality.269412878\",\n                    \"bbox\": [151.226804557, -33.958002321, 151.292531935, -33.93283624],\n                    \"text\": \"Maroubra\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.2575, -33.9475],\n                    \"context\": [\n                        {\n                            \"id\": \"place.24496142\",\n                            \"text\": \"Sydney\",\n                            \"wikidata\": \"Q3130\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                        },\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\"type\": \"Point\", \"coordinates\": [151.2575, -33.9475]},\n                    \"relevance\": 1,\n                    \"place_name\": \"Maroubra, New South Wales, Australia\",\n                    \"place_type\": [\"locality\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q2914843\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpFQTdxRGc\",\n                    },\n                },\n                {\n                    \"id\": \"place.24496142\",\n                    \"bbox\": [150.520934139, -34.11717528, 151.369884128, -33.562644328],\n                    \"text\": \"Sydney\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.216454, -33.854816],\n                    \"context\": [\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [151.216454, -33.854816],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Sydney, New South Wales, Australia\",\n                    \"place_type\": [\"place\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q3130\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                    },\n                },\n                {\n                    \"id\": \"region.33806\",\n                    \"bbox\": [140.999265, -37.5097258, 159.200456, -28.1370359],\n                    \"text\": \"New South Wales\",\n                    \"type\": \"Feature\",\n                    \"center\": [147.014694071448, -32.168971672412],\n                    \"context\": [\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        }\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [147.014694071448, -32.168971672412],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"New South Wales, Australia\",\n                    \"place_type\": [\"region\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q3224\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                        \"short_code\": \"AU-NSW\",\n                    },\n                },\n                {\n                    \"id\": \"country.8718\",\n                    \"bbox\": [112.8256904, -54.8327658, 159.200456, -9.0436707],\n                    \"text\": \"Australia\",\n                    \"type\": \"Feature\",\n                    \"center\": [134.489562606981, -25.7349684916223],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [134.489562606981, -25.7349684916223],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Australia\",\n                    \"place_type\": [\"country\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q408\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                        \"short_code\": \"au\",\n                    },\n                },\n            ],\n            \"attribution\": \"NOTICE: © 2023 Mapbox and its suppliers. All rights reserved. Use of this data is subject to the Mapbox Terms of Service (https://www.mapbox.com/about/maps/). This response and the information it contains may not be retained. POI(s) provided by Foursquare.\",\n            \"search_text\": \"Mistral Point 2035 Maroubra Sydney New South Wales Australia\",\n        },\n        \"captions_json\": {\n            \"places365\": {\n                \"attributes\": [\n                    \"natural light\",\n                    \"open area\",\n                    \"cloth\",\n                    \"sunny\",\n                    \"rugged scene\",\n                    \"man made\",\n                    \"no horizon\",\n                    \"dry\",\n                    \"climbing\",\n                ],\n                \"categories\": [],\n                \"environment\": \"outdoor\",\n            }\n        },\n        \"search_captions\": \"outdoor\",\n        \"search_location\": \"Mistral Point 2035 Maroubra Sydney New South Wales Australia\",\n        \"hidden\": False,\n        \"public\": False,\n        \"video\": False,\n        \"clip_embeddings\": [\n            -0.07165607810020447,\n            0.27777394652366640,\n            -0.15911960601806640,\n            -0.008188512176275253,\n            -0.009010881185531616,\n            0.034435927867889404,\n            -0.03819745033979416,\n            0.64109969139099120,\n            0.26547160744667053,\n            0.12205618619918823,\n            0.29898315668106080,\n            -0.10334454476833344,\n            0.48246607184410095,\n            -0.35823887586593630,\n            -0.024076826870441437,\n            -0.39234489202499390,\n            -1.93601369857788090,\n            -0.038323208689689636,\n            0.26534727215766907,\n            -0.31557315587997437,\n            0.42033576965332030,\n            0.10789811611175537,\n            0.25757673382759094,\n            -0.69643688201904300,\n            0.02039242535829544,\n            0.61391353607177730,\n            -0.14854159951210022,\n            -0.02548396587371826,\n            0.09138785302639008,\n            0.01601754128932953,\n            -0.16707235574722290,\n            0.18399655818939210,\n            -0.35197073221206665,\n            -0.67160075902938840,\n            -0.25247159600257874,\n            0.025167599320411682,\n            -0.03638566657900810,\n            0.30362653732299805,\n            0.54022008180618290,\n            -0.18761183321475983,\n            -0.54178249835968020,\n            0.14752727746963500,\n            0.14671368896961212,\n            -0.26148569583892820,\n            0.16243551671504974,\n            -1.18054628372192380,\n            0.53845894336700440,\n            -0.16278918087482452,\n            -0.22392278909683228,\n            -0.31029137969017030,\n            0.10120061784982681,\n            0.36866247653961180,\n            0.68476313352584840,\n            -0.32936185598373413,\n            0.42605328559875490,\n            0.29965081810951233,\n            -0.09844471514225006,\n            -0.25767421722412110,\n            -0.34776532649993896,\n            -0.34964543581008910,\n            -0.35941550135612490,\n            -0.04731412231922150,\n            -0.12926623225212097,\n            0.11695092916488647,\n            -0.32863533496856690,\n            -0.19086918234825134,\n            -0.16123855113983154,\n            1.26160860061645500,\n            -0.38261440396308900,\n            -0.038163766264915466,\n            -0.17712144553661346,\n            0.07694791257381439,\n            -0.38384479284286500,\n            -0.39457264542579650,\n            -0.024550501257181168,\n            -0.30032104253768920,\n            -0.48474478721618650,\n            0.17176222801208496,\n            -0.35844194889068604,\n            -0.22569467127323150,\n            -0.04710277169942856,\n            0.26759219169616700,\n            0.18299442529678345,\n            0.42168390750885010,\n            0.27530056238174440,\n            0.08983490616083145,\n            0.56285107135772700,\n            -0.36590436100959780,\n            -0.21663343906402588,\n            -0.23292037844657898,\n            0.28227549791336060,\n            -0.03927003592252731,\n            -6.69503688812255900,\n            0.40044450759887695,\n            0.17168624699115753,\n            0.16189746558666230,\n            -0.21396432816982270,\n            -0.18209101259708405,\n            -0.14555342495441437,\n            -1.11969792842865000,\n            0.26553612947463990,\n            -0.43248271942138670,\n            -0.23711906373500824,\n            0.13495093584060670,\n            -0.57171511650085450,\n            0.31623297929763794,\n            -0.26300746202468870,\n            0.11482997238636017,\n            -0.04145537316799164,\n            -0.012881413102149963,\n            0.38327059149742126,\n            -0.28872770071029663,\n            0.13417448103427887,\n            0.0004943050444126129,\n            -0.48013591766357420,\n            0.15905582904815674,\n            -0.17036144435405730,\n            0.11494286358356476,\n            0.38910707831382750,\n            -0.04960992932319641,\n            0.0068108439445495605,\n            -0.39681759476661680,\n            0.05528683960437775,\n            0.25615781545639040,\n            0.16935455799102783,\n            -0.15641900897026062,\n            0.030953165143728256,\n            -0.09216502308845520,\n            -0.51438409090042110,\n            0.20902346074581146,\n            0.09113435447216034,\n            -0.28546446561813354,\n            0.12450934201478958,\n            0.90055423974990840,\n            -0.30295214056968690,\n            0.04577522724866867,\n            0.23674716055393220,\n            0.19620284438133240,\n            -0.14195847511291504,\n            0.027747787535190582,\n            0.24356174468994140,\n            -0.24453750252723694,\n            -0.13842593133449554,\n            -0.04814389348030090,\n            -0.29600149393081665,\n            0.33813029527664185,\n            -0.40565192699432373,\n            1.04664814472198490,\n            -0.12125436961650848,\n            -0.04555452987551689,\n            -0.32212057709693910,\n            0.19244483113288880,\n            -0.17625807225704193,\n            -0.11717353016138077,\n            0.52924054861068730,\n            -0.91078692674636840,\n            0.61408323049545290,\n            -0.015624964609742165,\n            0.005238614976406097,\n            0.49075758457183840,\n            -0.62067019939422610,\n            0.30432268977165220,\n            -0.032360561192035675,\n            0.30186992883682250,\n            -0.41565680503845215,\n            -0.36446920037269590,\n            0.94007527828216550,\n            0.70317226648330690,\n            0.23549987375736237,\n            -0.39121294021606445,\n            -0.17178057134151460,\n            -0.042171500623226166,\n            0.05205693840980530,\n            -0.12189273536205292,\n            -0.19439676403999330,\n            -0.12377274036407471,\n            -0.34452289342880250,\n            0.02267320826649666,\n            -0.68486988544464110,\n            0.55518913269042970,\n            0.44782432913780210,\n            0.029707305133342743,\n            -0.07340695708990097,\n            0.25972485542297363,\n            -0.25381219387054443,\n            0.19427055120468140,\n            0.29389485716819763,\n            -0.50578629970550540,\n            0.15614330768585205,\n            0.31318914890289307,\n            -0.08632530272006989,\n            -0.23409178853034973,\n            0.05476500093936920,\n            0.44156715273857117,\n            -0.22440673410892487,\n            0.023980028927326202,\n            -0.43828743696212770,\n            -0.19539453089237213,\n            0.70773118734359740,\n            -0.21142446994781494,\n            -0.012780480086803436,\n            0.06678064167499542,\n            -0.12673433125019073,\n            -0.016046274453401566,\n            0.34845018386840820,\n            -0.37029501795768740,\n            -0.41383981704711914,\n            0.14042145013809204,\n            -0.07714009284973145,\n            0.23623535037040710,\n            -0.07902203500270844,\n            -0.22669017314910890,\n            0.13354891538619995,\n            0.24691657721996307,\n            -0.23718547821044922,\n            0.19054308533668518,\n            -0.10418700426816940,\n            0.33725842833518980,\n            -0.08150300383567810,\n            -0.020559510216116905,\n            -0.06275172531604767,\n            0.32859966158866880,\n            -0.23827262222766876,\n            0.006859809160232544,\n            -0.43626987934112550,\n            0.00422387570142746,\n            -0.42823368310928345,\n            0.06333791464567184,\n            0.24973529577255250,\n            -0.038866639137268066,\n            0.39266145229339600,\n            -0.33822238445281980,\n            0.20358082652091980,\n            0.45638072490692140,\n            0.24203394353389740,\n            -1.19550085067749020,\n            -0.07111288607120514,\n            0.31889787316322327,\n            -0.07209780812263489,\n            0.08574327081441879,\n            -0.05155383050441742,\n            0.10396289825439453,\n            0.37258481979370117,\n            0.31418317556381226,\n            -0.41241401433944700,\n            0.15978108346462250,\n            0.07924050092697144,\n            0.046578992158174515,\n            0.14554628729820251,\n            0.23571312427520752,\n            -0.67628896236419680,\n            0.16695483028888702,\n            -0.21981866657733917,\n            0.16223661601543427,\n            -0.021025659516453743,\n            0.06956741958856583,\n            -0.30338615179061890,\n            -0.16539447009563446,\n            1.11133313179016110,\n            0.07875859737396240,\n            0.08710280805826187,\n            0.09269724786281586,\n            0.43146112561225890,\n            -0.63181507587432860,\n            0.24583837389945984,\n            -0.44661480188369750,\n            0.03440243750810623,\n            0.11665569245815277,\n            0.15819263458251953,\n            0.12243788689374924,\n            0.29396194219589233,\n            0.09694259613752365,\n            0.02281755954027176,\n            -0.21637912094593048,\n            -0.37317517399787903,\n            0.33211824297904970,\n            0.18465763330459595,\n            0.04658152908086777,\n            -0.22277922928333282,\n            -0.01132119633257389,\n            -0.22123819589614868,\n            0.21734745800495148,\n            0.18564417958259583,\n            -0.16151818633079530,\n            -0.012778416275978088,\n            0.22715422511100770,\n            -0.61123466491699220,\n            -0.25164487957954407,\n            -0.62368929386138920,\n            -0.08976317942142487,\n            -0.18425342440605164,\n            0.01974041759967804,\n            0.40116083621978760,\n            0.09709006547927856,\n            0.02330559492111206,\n            -0.24683454632759094,\n            0.46549883484840393,\n            0.17286136746406555,\n            0.13242653012275696,\n            -0.018801003694534302,\n            0.05591413378715515,\n            -0.008567705750465393,\n            0.31677705049514770,\n            -0.03249857574701309,\n            0.23144552111625670,\n            -0.19878269731998444,\n            0.34026873111724854,\n            0.46859806776046753,\n            0.46318793296813965,\n            -0.15173172950744630,\n            0.03247187286615372,\n            -0.35306686162948610,\n            0.89938664436340330,\n            -0.12891116738319397,\n            0.23192928731441498,\n            -0.20485301315784454,\n            0.26682716608047485,\n            0.03473209589719772,\n            -0.05685047805309296,\n            0.85817599296569820,\n            0.20728000998497010,\n            -0.42015826702117920,\n            -0.20325171947479248,\n            -0.18902121484279633,\n            -0.08646465092897415,\n            0.47624379396438600,\n            0.07658274471759796,\n            0.24890711903572083,\n            0.09770368039608002,\n            -0.07584749162197113,\n            -0.38329556584358215,\n            -0.09186994284391403,\n            0.05287090688943863,\n            -0.07528317719697952,\n            -0.30612573027610780,\n            0.03865929692983627,\n            0.20936234295368195,\n            -0.12896691262722015,\n            0.40101802349090576,\n            -0.27413615584373474,\n            -0.11920598894357681,\n            -0.27652582526206970,\n            0.16087099909782410,\n            0.30647218227386475,\n            -0.23265513777732850,\n            -0.04036612808704376,\n            -0.19249220192432404,\n            0.29576075077056885,\n            -0.33208352327346800,\n            -0.26545786857604980,\n            -0.47843095660209656,\n            0.05080226808786392,\n            -0.18463236093521118,\n            -0.31534552574157715,\n            -0.16215364634990692,\n            0.44137579202651980,\n            0.19857212901115417,\n            -0.05095067620277405,\n            0.60800743103027340,\n            0.18676830828189850,\n            0.43479916453361510,\n            -0.31042078137397766,\n            0.34246861934661865,\n            -0.22135923802852630,\n            -0.16289021074771880,\n            0.14682009816169740,\n            -0.05069936811923981,\n            -1.54986953735351560,\n            -0.20729129016399384,\n            0.30774104595184326,\n            -0.74974900484085080,\n            0.17779231071472168,\n            0.29844737052917480,\n            -0.028225738555192947,\n            0.06633662432432175,\n            0.09315972030162811,\n            1.39255237579345700,\n            0.08268950879573822,\n            0.17041748762130737,\n            -0.07805733382701874,\n            -0.009891202673316002,\n            -0.42962673306465150,\n            -0.31951111555099490,\n            -0.59171640872955320,\n            0.13881197571754456,\n            0.05963444709777832,\n            0.36311095952987670,\n            0.20484755933284760,\n            -0.24004952609539032,\n            -0.30473378300666810,\n            -1.23349046707153320,\n            0.12950478494167328,\n            0.13113403320312500,\n            -0.13356469571590424,\n            -0.33658254146575930,\n            -0.17962300777435303,\n            0.44429525732994080,\n            0.25605937838554380,\n            -0.18255349993705750,\n            -0.34041595458984375,\n            0.12862077355384827,\n            -0.10708140581846237,\n            -0.04143147915601730,\n            -0.75912737846374510,\n            0.11328382045030594,\n            -0.33319079875946045,\n            -0.12773975729942322,\n            -0.24314782023429870,\n            -0.08418860286474228,\n            0.024868622422218323,\n            -0.24916192889213562,\n            0.14967927336692810,\n            0.50531852245330810,\n            -0.17624694108963013,\n            -0.32372432947158813,\n            0.26036772131919860,\n            -0.13107712566852570,\n            -0.19526052474975586,\n            -0.34996968507766724,\n            0.09007526934146881,\n            -0.19009685516357422,\n            -0.04918465018272400,\n            0.42598405480384827,\n            -0.95772063732147220,\n            -0.26188156008720400,\n            -0.13462109863758087,\n            -0.48766309022903440,\n            -0.04416780546307564,\n            -0.24330116808414460,\n            0.49982681870460510,\n            -0.59761744737625120,\n            -0.51605522632598880,\n            0.25707191228866577,\n            0.06983644515275955,\n            -0.70166909694671630,\n            -0.44174510240554810,\n            0.59883981943130490,\n            -0.23916383087635040,\n            -0.23846682906150818,\n            -0.09755046665668488,\n            0.06088256835937500,\n            0.57148891687393190,\n            -0.46913427114486694,\n            0.10756542533636093,\n            0.24516284465789795,\n            -0.29232767224311830,\n            -0.41785952448844910,\n            -0.20276978611946106,\n            -0.09826758503913880,\n            0.03998877853155136,\n            0.23493704199790955,\n            -0.30350178480148315,\n            -0.05809980630874634,\n            -0.92815858125686650,\n            -0.07727308571338654,\n            0.009639181196689606,\n            0.11947400122880936,\n            -0.32069391012191770,\n            -0.15333953499794006,\n            -0.20000588893890380,\n            0.51839399337768550,\n            -0.12886822223663330,\n            -0.06542570143938065,\n            -0.07890033721923828,\n            0.67580813169479370,\n            0.23756900429725647,\n            -0.39474987983703613,\n            -0.08904854953289032,\n            0.10808389633893967,\n            0.55502021312713620,\n            -0.06922332942485810,\n            0.08530282974243164,\n            -0.64823102951049800,\n            -0.25969624519348145,\n            0.18102583289146423,\n            -0.15270616114139557,\n            -0.08661939203739166,\n            -0.13988846540451050,\n            -0.13920640945434570,\n            -0.19092643260955810,\n            -0.30402636528015137,\n            0.034376729279756546,\n            0.28963160514831543,\n            -0.026797950267791748,\n            -0.009581595659255981,\n            -0.13647942245006560,\n            0.45053595304489136,\n            -0.13274556398391724,\n            -0.32779148221015930,\n            -0.29205349087715150,\n            -0.04352697730064392,\n            -0.10386516153812408,\n            0.09681421518325806,\n            -0.43473735451698303,\n            -0.12714838981628418,\n            -0.13591085374355316,\n            -0.019401680678129196,\n            0.08398979157209396,\n            -0.21437484025955200,\n            0.16760951280593872,\n            0.06209127604961395,\n            0.10363107919692993,\n            0.037978142499923706,\n            0.25537312030792236,\n            -0.006652772426605225,\n            -0.29459229111671450,\n            0.13102664053440094,\n            0.72616183757781980,\n            -0.90198361873626710,\n            -0.04998414218425751,\n            0.015783268958330154,\n            0.34867671132087710,\n        ],\n        \"clip_embeddings_magnitude\": 10.424424171447754,\n        \"aspect_ratio\": 1.33,\n        \"rating\": 0,\n        \"dominant_color\": \"[99, 135, 179]\",\n        \"video_length\": None,\n        \"removed\": False,\n        \"in_trashcan\": False,\n        \"timestamp\": None,\n        \"camera\": \"Pixel 2\",\n        \"digitalZoomRatio\": None,\n        \"focalLength35Equivalent\": None,\n        \"focal_length\": 4.442,\n        \"fstop\": 1.8,\n        \"height\": 3024,\n        \"iso\": None,\n        \"lens\": None,\n        \"shutter_speed\": \"0\",\n        \"size\": 9246412,\n        \"subjectDistance\": 1.686,\n        \"width\": 4032,\n        \"main_file_id\": \"1f522608a263a88ea72010fe4f08f3071\",\n    },\n    {\n        \"thumbnail_big\": \"thumbnails_big/e05dc030e807ae8fc31442fa2ba7fdf81.webp\",\n        \"square_thumbnail\": \"square_thumbnails/e05dc030e807ae8fc31442fa2ba7fdf81.webp\",\n        \"square_thumbnail_small\": \"square_thumbnails_small/e05dc030e807ae8fc31442fa2ba7fdf81.webp\",\n        \"added_on\": \"2023-06-16 16:30:42.859303 +00:00\",\n        \"exif_gps_lat\": -33.8880555555556,\n        \"exif_gps_lon\": 151.275180555556,\n        \"exif_timestamp\": \"2017-10-18 15:08:09.000000 +00:00\",\n        \"exif_json\": None,\n        \"geolocation_json\": {\n            \"type\": \"FeatureCollection\",\n            \"query\": [151.275181, -33.888056],\n            \"features\": [\n                {\n                    \"id\": \"address.6965576203835628\",\n                    \"text\": \"Beach Road\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.275216500055, -33.888012425],\n                    \"address\": \"17\",\n                    \"context\": [\n                        {\n                            \"id\": \"postcode.429582\",\n                            \"text\": \"2026\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpCbzRP\",\n                        },\n                        {\n                            \"id\": \"locality.259156494\",\n                            \"text\": \"Bondi Beach\",\n                            \"wikidata\": \"Q673418\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpEM0pxRGc\",\n                        },\n                        {\n                            \"id\": \"place.24496142\",\n                            \"text\": \"Sydney\",\n                            \"wikidata\": \"Q3130\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                        },\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [151.275216500055, -33.888012425],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"17 Beach Road, Bondi Beach New South Wales 2026, Australia\",\n                    \"place_type\": [\"address\"],\n                    \"properties\": {\n                        \"accuracy\": \"rooftop\",\n                        \"mapbox_id\": \"dXJuOm1ieGFkcjo3NmQ1MTQzZi1kZmEwLTQ4NzAtYTAyNy0zYWY1MGJiZTIxYWU\",\n                    },\n                },\n                {\n                    \"id\": \"postcode.429582\",\n                    \"bbox\": [151.2582, -33.90058, 151.285977, -33.878047],\n                    \"text\": \"2026\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.271075, -33.898745],\n                    \"context\": [\n                        {\n                            \"id\": \"locality.259156494\",\n                            \"text\": \"Bondi Beach\",\n                            \"wikidata\": \"Q673418\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpEM0pxRGc\",\n                        },\n                        {\n                            \"id\": \"place.24496142\",\n                            \"text\": \"Sydney\",\n                            \"wikidata\": \"Q3130\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                        },\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [151.271075, -33.898745],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"2026, Bondi Beach, New South Wales, Australia\",\n                    \"place_type\": [\"postcode\"],\n                    \"properties\": {\"mapbox_id\": \"dXJuOm1ieHBsYzpCbzRP\"},\n                },\n                {\n                    \"id\": \"locality.259156494\",\n                    \"bbox\": [151.26203377, -33.896871333, 151.282858153, -33.884999032],\n                    \"text\": \"Bondi Beach\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.2745, -33.8915],\n                    \"context\": [\n                        {\n                            \"id\": \"place.24496142\",\n                            \"text\": \"Sydney\",\n                            \"wikidata\": \"Q3130\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                        },\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\"type\": \"Point\", \"coordinates\": [151.2745, -33.8915]},\n                    \"relevance\": 1,\n                    \"place_name\": \"Bondi Beach, New South Wales, Australia\",\n                    \"place_type\": [\"locality\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q673418\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpEM0pxRGc\",\n                    },\n                },\n                {\n                    \"id\": \"place.24496142\",\n                    \"bbox\": [150.520934139, -34.11717528, 151.369884128, -33.562644328],\n                    \"text\": \"Sydney\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.216454, -33.854816],\n                    \"context\": [\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [151.216454, -33.854816],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Sydney, New South Wales, Australia\",\n                    \"place_type\": [\"place\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q3130\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                    },\n                },\n                {\n                    \"id\": \"region.33806\",\n                    \"bbox\": [140.999265, -37.5097258, 159.200456, -28.1370359],\n                    \"text\": \"New South Wales\",\n                    \"type\": \"Feature\",\n                    \"center\": [147.014694071448, -32.168971672412],\n                    \"context\": [\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        }\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [147.014694071448, -32.168971672412],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"New South Wales, Australia\",\n                    \"place_type\": [\"region\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q3224\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                        \"short_code\": \"AU-NSW\",\n                    },\n                },\n                {\n                    \"id\": \"country.8718\",\n                    \"bbox\": [112.8256904, -54.8327658, 159.200456, -9.0436707],\n                    \"text\": \"Australia\",\n                    \"type\": \"Feature\",\n                    \"center\": [134.489562606981, -25.7349684916223],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [134.489562606981, -25.7349684916223],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Australia\",\n                    \"place_type\": [\"country\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q408\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                        \"short_code\": \"au\",\n                    },\n                },\n            ],\n            \"attribution\": \"NOTICE: © 2023 Mapbox and its suppliers. All rights reserved. Use of this data is subject to the Mapbox Terms of Service (https://www.mapbox.com/about/maps/). This response and the information it contains may not be retained. POI(s) provided by Foursquare.\",\n            \"search_text\": \"Beach Road 2026 Bondi Beach Sydney New South Wales Australia\",\n        },\n        \"captions_json\": {\n            \"places365\": {\n                \"attributes\": [\n                    \"no horizon\",\n                    \"man made\",\n                    \"wood\",\n                    \"natural light\",\n                    \"cloth\",\n                    \"railing\",\n                    \"fencing\",\n                    \"soothing\",\n                    \"enclosed area\",\n                ],\n                \"categories\": [\"boardwalk\"],\n                \"environment\": \"outdoor\",\n            }\n        },\n        \"search_captions\": \"boardwalk , outdoor\",\n        \"search_location\": \"Beach Road 2026 Bondi Beach Sydney New South Wales Australia\",\n        \"hidden\": False,\n        \"public\": False,\n        \"video\": False,\n        \"clip_embeddings\": [\n            -0.23971192538738250,\n            -0.06270129978656769,\n            -0.26378244161605835,\n            0.06143847107887268,\n            -0.17431882023811340,\n            -0.45036801695823670,\n            0.23159477114677430,\n            0.45534604787826540,\n            0.41466632485389710,\n            0.43482506275177000,\n            0.033715225756168365,\n            -0.12434051930904388,\n            -0.02738797664642334,\n            0.13923697173595428,\n            0.71357935667037960,\n            -0.10237488895654678,\n            0.44860103726387024,\n            -0.12994986772537231,\n            0.16177919507026672,\n            -0.52953779697418210,\n            -0.15292952954769135,\n            0.35376715660095215,\n            0.68445837497711180,\n            -0.12952817976474762,\n            -0.65442270040512080,\n            -0.11123260110616684,\n            0.35111707448959350,\n            -0.21466320753097534,\n            -0.07700178027153015,\n            0.60484236478805540,\n            -0.35141080617904663,\n            0.42874458432197570,\n            -0.14701411128044128,\n            -0.31290709972381590,\n            0.41403374075889590,\n            0.42659783363342285,\n            0.14210951328277588,\n            0.21080046892166138,\n            -0.20884071290493011,\n            1.55791330337524410,\n            -1.02671170234680180,\n            -0.21040523052215576,\n            0.21100938320159912,\n            -0.018877588212490082,\n            0.045770272612571716,\n            -1.74390268325805660,\n            0.27797201275825500,\n            0.11735321581363678,\n            -0.16868110001087190,\n            0.07083947211503983,\n            0.17496472597122192,\n            0.18361502885818481,\n            0.06942721456289291,\n            0.29114612936973570,\n            0.16485445201396942,\n            0.35742586851119995,\n            0.71032893657684330,\n            0.08239606022834778,\n            -0.48378357291221620,\n            0.40987241268157960,\n            0.61319535970687870,\n            0.07883536070585251,\n            0.52093547582626340,\n            0.56784075498580930,\n            0.13938774168491364,\n            -0.15158170461654663,\n            0.37094724178314210,\n            0.83913630247116090,\n            -0.48059833049774170,\n            -0.05472422018647194,\n            0.50041580200195310,\n            0.14854013919830322,\n            0.11348950862884521,\n            -0.10606382042169571,\n            -0.032269977033138275,\n            -0.10980830341577530,\n            -0.06923694908618927,\n            -0.44631308317184450,\n            0.06693774461746216,\n            -0.67717289924621580,\n            0.16200356185436250,\n            -0.08856121450662613,\n            -0.45839691162109375,\n            -0.59329897165298460,\n            0.027508363127708435,\n            -0.17059496045112610,\n            1.00395536422729500,\n            -0.40785533189773560,\n            0.20544975996017456,\n            -0.61956644058227540,\n            0.30773437023162840,\n            -0.75228130817413330,\n            -6.36993885040283200,\n            0.23692467808723450,\n            -0.18002653121948242,\n            0.41877511143684387,\n            -0.03537013381719589,\n            -0.13835334777832030,\n            -0.28309312462806700,\n            0.99767374992370600,\n            0.43206804990768430,\n            -0.61333036422729490,\n            0.31665223836898804,\n            0.011051952838897705,\n            0.26114824414253235,\n            0.20024497807025910,\n            0.86893093585968020,\n            0.16212932765483856,\n            0.038838282227516174,\n            0.10568191856145859,\n            0.18302081525325775,\n            -0.49132484197616577,\n            -0.13319587707519530,\n            -0.38572445511817930,\n            0.09171506762504578,\n            -0.26322168111801150,\n            -0.66410481929779050,\n            0.13588872551918030,\n            0.47181224822998047,\n            -0.05031928792595863,\n            0.25005054473876953,\n            0.21581515669822693,\n            0.25475072860717773,\n            -0.24969539046287537,\n            0.54527783393859860,\n            -0.27312040328979490,\n            -0.55337727069854740,\n            0.23277127742767334,\n            0.014410212635993958,\n            0.24621416628360748,\n            0.11218813061714172,\n            -0.35024920105934143,\n            -0.21312740445137024,\n            0.80181252956390380,\n            -0.79384481906890870,\n            0.50359219312667850,\n            -0.45367711782455444,\n            -0.48844304680824280,\n            -0.11650219559669495,\n            -0.12895251810550690,\n            -0.26562160253524780,\n            0.20769277215003967,\n            -0.21654091775417328,\n            0.21669554710388184,\n            -0.23972092568874360,\n            0.43769764900207520,\n            0.32644972205162050,\n            0.35533404350280760,\n            0.28178536891937256,\n            0.40197637677192690,\n            0.16447728872299194,\n            0.41968211531639100,\n            -1.00776052474975590,\n            0.09402647614479065,\n            0.07121045887470245,\n            -0.38577982783317566,\n            -0.15076629817485810,\n            -0.67609924077987670,\n            -0.37826904654502870,\n            -0.17423087358474731,\n            -0.019086994230747223,\n            0.09864071011543274,\n            0.30087676644325256,\n            0.27617043256759644,\n            0.06742041558027267,\n            -0.01719839498400688,\n            0.47258347272872925,\n            0.44886517524719240,\n            0.06894390285015106,\n            0.013095356523990631,\n            -0.34940755367279050,\n            0.02119274064898491,\n            0.44170263409614563,\n            -0.17398183047771454,\n            0.11397088319063187,\n            -0.25963869690895080,\n            0.66773271560668950,\n            -0.029286637902259827,\n            0.26047524809837340,\n            0.16783103346824646,\n            -0.10030107200145721,\n            -0.28766095638275146,\n            -0.20763801038265228,\n            -0.020237110555171967,\n            0.26628720760345460,\n            -0.03873760998249054,\n            0.33926361799240110,\n            -0.22293141484260560,\n            -0.02787308767437935,\n            -0.02593088522553444,\n            -0.14129941165447235,\n            0.26731464266777040,\n            -0.08877785503864288,\n            0.31370738148689270,\n            -0.03215152025222778,\n            0.28753554821014404,\n            0.28241288661956787,\n            -0.58873462677001950,\n            -1.19819414615631100,\n            -0.31043520569801330,\n            0.52656930685043330,\n            -0.15177714824676514,\n            0.04967714473605156,\n            0.21121788024902344,\n            -0.34197264909744260,\n            -0.58301079273223880,\n            -0.05980183556675911,\n            0.08901736885309220,\n            -0.016958534717559814,\n            0.70352792739868160,\n            0.04605954885482788,\n            0.33565390110015870,\n            -0.15496665239334106,\n            0.008393734693527222,\n            -0.39955216646194460,\n            -0.58763426542282100,\n            -0.06084361672401428,\n            0.49945196509361267,\n            1.24120748043060300,\n            -0.26017981767654420,\n            0.88132452964782710,\n            0.42076665163040160,\n            -0.11274620890617370,\n            -0.17265769839286804,\n            -0.42378520965576170,\n            0.51237106323242190,\n            -0.05410161614418030,\n            0.19328644871711730,\n            0.06429362297058105,\n            0.08922037482261658,\n            0.26436531543731690,\n            -0.07118786871433258,\n            0.16370669007301330,\n            0.53644728660583500,\n            -0.22373552620410920,\n            -0.10707579553127289,\n            -0.40309336781501770,\n            -0.36508309841156006,\n            -0.35035932064056396,\n            -0.40398806333541870,\n            -0.19331835210323334,\n            0.46320116519927980,\n            0.38004130125045776,\n            0.28961494565010070,\n            -0.11237305402755737,\n            0.92902261018753050,\n            0.52717500925064090,\n            -0.55640512704849240,\n            0.43815851211547850,\n            0.17259076237678528,\n            0.16953691840171814,\n            -0.15717472136020660,\n            0.36117780208587646,\n            -0.15650644898414612,\n            0.41624057292938230,\n            0.23601931333541870,\n            -0.07374796271324158,\n            0.19807760417461395,\n            0.90526473522186280,\n            0.15534612536430360,\n            -0.10369679331779480,\n            0.44986745715141296,\n            0.14716669917106628,\n            -1.63226437568664550,\n            0.34298181533813477,\n            -0.07653657346963882,\n            0.09407752752304077,\n            -0.09107702970504761,\n            -0.15198035538196564,\n            0.40160834789276123,\n            -0.11560469865798950,\n            0.12189065665006638,\n            0.55020934343338010,\n            0.23245319724082947,\n            -0.33362627029418945,\n            -0.026688329875469208,\n            0.036947548389434814,\n            -0.15187057852745056,\n            0.16617432236671448,\n            -0.22867006063461304,\n            -0.14950640499591827,\n            0.18319699168205260,\n            -0.16728959977626800,\n            -0.10700336098670960,\n            -0.40600830316543580,\n            0.11292800307273865,\n            -0.45587986707687380,\n            0.08800569921731949,\n            -0.12228171527385712,\n            -0.20845821499824524,\n            0.012005604803562164,\n            0.009173348546028137,\n            0.03591999411582947,\n            0.06969346106052399,\n            -0.057597748935222626,\n            -0.26176500320434570,\n            0.057852864265441895,\n            0.52407950162887570,\n            -0.06004469096660614,\n            0.09566502273082733,\n            0.45640081167221070,\n            -0.36844995617866516,\n            -0.19065113365650177,\n            -0.24948999285697937,\n            0.10381768643856049,\n            0.20994564890861510,\n            -0.43057119846343994,\n            0.44703856110572815,\n            0.45062947273254395,\n            -0.048142626881599426,\n            -0.43613320589065550,\n            0.68679362535476680,\n            0.80048835277557370,\n            -0.27635425329208374,\n            0.05641265958547592,\n            0.45497626066207886,\n            -0.07284688949584961,\n            0.027119621634483337,\n            -0.71727848052978520,\n            0.051672548055648804,\n            0.49660560488700867,\n            -0.014366000890731812,\n            -0.32767134904861450,\n            0.57496893405914310,\n            -0.10740306973457336,\n            0.31921407580375670,\n            -0.18982721865177155,\n            0.62163728475570680,\n            0.05321182310581207,\n            -0.007055327296257019,\n            -0.39857268333435060,\n            -0.42034840583801270,\n            0.42702692747116090,\n            -0.25245079398155210,\n            -0.15837675333023070,\n            0.13288237154483795,\n            0.38176494836807250,\n            -0.002602334599941969,\n            -0.13858896493911743,\n            -0.14321827888488770,\n            -0.22877745330333710,\n            -0.49555492401123047,\n            0.05159477889537811,\n            0.11018384248018265,\n            0.51627355813980100,\n            0.27100521326065063,\n            0.25322765111923220,\n            0.24974197149276733,\n            0.33265298604965210,\n            -0.29485535621643066,\n            -1.29044723510742190,\n            0.04591430723667145,\n            -0.07321572303771973,\n            -0.14710950851440430,\n            0.12631073594093323,\n            1.13345527648925780,\n            -0.014754462987184525,\n            -0.29979729652404785,\n            0.16985306143760680,\n            -0.009212018921971321,\n            0.07431145012378693,\n            -0.09315565973520279,\n            0.009219720959663391,\n            0.010067738592624664,\n            -0.63250660896301270,\n            0.17347553372383118,\n            -0.07402996718883514,\n            -0.71398895978927610,\n            -0.21345643699169160,\n            -0.05977027118206024,\n            -0.15027517080307007,\n            0.23419082164764404,\n            0.07772324234247208,\n            0.24337679147720337,\n            -0.21138656139373780,\n            -0.14810818433761597,\n            0.86943644285202030,\n            0.16901102662086487,\n            -0.56860476732254030,\n            0.46403664350509644,\n            0.09731522202491760,\n            -0.56933224201202390,\n            -0.12460615485906601,\n            -0.11898320913314820,\n            0.07008326053619385,\n            -0.28138583898544310,\n            -0.13215136528015137,\n            -0.17825207114219666,\n            0.42181590199470520,\n            -1.26650285720825200,\n            -0.38550242781639100,\n            -0.79046458005905150,\n            0.27815857529640200,\n            -0.33496844768524170,\n            0.35100549459457400,\n            -0.047406621277332306,\n            -0.28952226042747500,\n            0.05926326662302017,\n            -0.40099292993545530,\n            -0.67989456653594970,\n            -0.10077974200248718,\n            0.20837950706481934,\n            0.22934542596340180,\n            -0.19858762621879578,\n            0.03676336631178856,\n            -0.35527148842811584,\n            0.16404820978641510,\n            0.00022447854280471802,\n            0.76411741971969600,\n            -0.28233665227890015,\n            0.63915759325027470,\n            -0.37327551841735840,\n            -0.16160306334495544,\n            0.20690485835075378,\n            0.13408637046813965,\n            0.04813005030155182,\n            -0.20736610889434814,\n            -0.04153786227107048,\n            0.45611751079559326,\n            -0.16623178124427795,\n            0.62960529327392580,\n            0.05700150877237320,\n            0.31719794869422910,\n            -0.85579967498779300,\n            0.26484364271163940,\n            -0.24446523189544678,\n            -0.15408588945865630,\n            -0.19397708773612976,\n            -0.11019599437713623,\n            0.03778602182865143,\n            -0.29206633567810060,\n            0.01961149275302887,\n            0.15172131359577180,\n            -0.28065830469131470,\n            0.11242515593767166,\n            -0.18694521486759186,\n            -0.58477157354354860,\n            0.05389549210667610,\n            0.22777841985225677,\n            -0.32870626449584960,\n            0.29864984750747680,\n            0.18124125897884370,\n            -0.20861062407493590,\n            0.0054992809891700745,\n            0.03267093002796173,\n            -0.49986141920089720,\n            -0.11108686029911041,\n            -0.59358328580856320,\n            -0.37555414438247680,\n            -0.06972122937440872,\n            0.20261085033416748,\n            0.14063805341720580,\n            -0.03213777393102646,\n            0.64066505432128910,\n            0.12838377058506012,\n            -0.26487171649932860,\n            0.08924877643585205,\n            0.24947234988212585,\n            0.15273018181324005,\n            0.40036311745643616,\n            0.044563665986061096,\n            0.06986735761165619,\n            -0.06490179151296616,\n            -0.22339831292629242,\n            0.84133744239807130,\n            0.18982148170471191,\n            -0.25457212328910830,\n            0.005809254944324493,\n            -0.10496775060892105,\n            0.09663195163011551,\n            0.053644873201847076,\n            -0.12464396655559540,\n            -0.52167260646820070,\n            -0.03012670949101448,\n            -0.42671746015548706,\n            0.04330782592296600,\n            -0.15239623188972473,\n            0.24043066799640656,\n            -0.27606680989265440,\n            -0.21640032529830933,\n            0.22917535901069640,\n            -0.07214342057704926,\n            -0.007351040840148926,\n            -0.10876821726560593,\n            -0.30059793591499330,\n            0.17446355521678925,\n            -0.11450521647930145,\n            -0.21727171540260315,\n            0.13252682983875275,\n            0.28893244266510010,\n            -0.34937959909439087,\n            0.10954467207193375,\n            0.67957222461700440,\n            -0.40904277563095090,\n            -0.005858458578586578,\n            -0.17941795289516450,\n            -0.02339877001941204,\n            -0.007561013102531433,\n            -0.34222379326820374,\n            -0.23668894171714783,\n            -0.19201411306858063,\n            0.07832273840904236,\n            -0.17331452667713165,\n            0.15513053536415100,\n            0.20789094269275665,\n            0.011405497789382935,\n            -0.022110439836978912,\n            0.31066673994064330,\n            -0.89273244142532350,\n            0.69325125217437740,\n            0.22300001978874207,\n            -0.06441357731819153,\n        ],\n        \"clip_embeddings_magnitude\": 10.693284034729004,\n        \"aspect_ratio\": 1.33,\n        \"rating\": 0,\n        \"dominant_color\": \"[64, 59, 54]\",\n        \"video_length\": None,\n        \"removed\": False,\n        \"in_trashcan\": False,\n        \"timestamp\": None,\n        \"camera\": \"Pixel 2\",\n        \"digitalZoomRatio\": None,\n        \"focalLength35Equivalent\": None,\n        \"focal_length\": 4.442,\n        \"fstop\": 1.8,\n        \"height\": 3024,\n        \"iso\": None,\n        \"lens\": None,\n        \"shutter_speed\": \"1/390\",\n        \"size\": 9145064,\n        \"subjectDistance\": 0.377,\n        \"width\": 4032,\n        \"main_file_id\": \"e05dc030e807ae8fc31442fa2ba7fdf81\",\n    },\n    {\n        \"thumbnail_big\": \"thumbnails_big/c57f08a55c6daa689e6e13a1bf8f81471.webp\",\n        \"square_thumbnail\": \"square_thumbnails/c57f08a55c6daa689e6e13a1bf8f81471.webp\",\n        \"square_thumbnail_small\": \"square_thumbnails_small/c57f08a55c6daa689e6e13a1bf8f81471.webp\",\n        \"added_on\": \"2023-06-16 16:30:49.679482 +00:00\",\n        \"exif_gps_lat\": 33.91345,\n        \"exif_gps_lon\": 78.4573888888889,\n        \"exif_timestamp\": \"2017-10-12 08:00:51.000000 +00:00\",\n        \"exif_json\": None,\n        \"geolocation_json\": {\n            \"type\": \"FeatureCollection\",\n            \"query\": [78.457389, 33.91345],\n            \"features\": [\n                {\n                    \"id\": \"address.4021415981422040\",\n                    \"text\": \"Lakeshore Road\",\n                    \"type\": \"Feature\",\n                    \"center\": [78.45710764679163, 33.91336849412403],\n                    \"context\": [\n                        {\n                            \"id\": \"postcode.13119083\",\n                            \"text\": \"194201\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzp5QzVy\",\n                        },\n                        {\n                            \"id\": \"locality.3711994475\",\n                            \"text\": \"Shachokol\",\n                            \"wikidata\": \"Q24912826\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzozVUNLYXc\",\n                        },\n                        {\n                            \"id\": \"place.25135211\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q230818\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWCtJYXc\",\n                        },\n                        {\n                            \"id\": \"district.3196523\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q1921210\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpNTVpy\",\n                        },\n                        {\n                            \"id\": \"region.222315\",\n                            \"text\": \"Ladakh\",\n                            \"wikidata\": \"Q200667\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBMlJy\",\n                            \"short_code\": \"IN-LA\",\n                        },\n                        {\n                            \"id\": \"country.8811\",\n                            \"text\": \"India\",\n                            \"wikidata\": \"Q668\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                            \"short_code\": \"in\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [78.45710764679163, 33.91336849412403],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Lakeshore Road ، 194201 Leh، India\",\n                    \"place_type\": [\"address\"],\n                    \"properties\": {\"accuracy\": \"street\"},\n                },\n                {\n                    \"id\": \"postcode.13119083\",\n                    \"bbox\": [77.159628, 32.619815, 79.042702, 34.608467],\n                    \"text\": \"194201\",\n                    \"type\": \"Feature\",\n                    \"center\": [77.668644, 34.050466],\n                    \"context\": [\n                        {\n                            \"id\": \"locality.3711994475\",\n                            \"text\": \"Shachokol\",\n                            \"wikidata\": \"Q24912826\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzozVUNLYXc\",\n                        },\n                        {\n                            \"id\": \"place.25135211\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q230818\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWCtJYXc\",\n                        },\n                        {\n                            \"id\": \"district.3196523\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q1921210\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpNTVpy\",\n                        },\n                        {\n                            \"id\": \"region.222315\",\n                            \"text\": \"Ladakh\",\n                            \"wikidata\": \"Q200667\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBMlJy\",\n                            \"short_code\": \"IN-LA\",\n                        },\n                        {\n                            \"id\": \"country.8811\",\n                            \"text\": \"India\",\n                            \"wikidata\": \"Q668\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                            \"short_code\": \"in\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [77.668644, 34.050466],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"194201, Leh, Ladakh, India\",\n                    \"place_type\": [\"postcode\"],\n                    \"properties\": {\"mapbox_id\": \"dXJuOm1ieHBsYzp5QzVy\"},\n                },\n                {\n                    \"id\": \"locality.3711994475\",\n                    \"bbox\": [77.324409401, 33.676681361, 79.042702, 35.496086],\n                    \"text\": \"Shachokol\",\n                    \"type\": \"Feature\",\n                    \"center\": [78.167596, 34.039239],\n                    \"context\": [\n                        {\n                            \"id\": \"place.25135211\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q230818\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWCtJYXc\",\n                        },\n                        {\n                            \"id\": \"district.3196523\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q1921210\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpNTVpy\",\n                        },\n                        {\n                            \"id\": \"region.222315\",\n                            \"text\": \"Ladakh\",\n                            \"wikidata\": \"Q200667\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBMlJy\",\n                            \"short_code\": \"IN-LA\",\n                        },\n                        {\n                            \"id\": \"country.8811\",\n                            \"text\": \"India\",\n                            \"wikidata\": \"Q668\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                            \"short_code\": \"in\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [78.167596, 34.039239],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Shachokol, Leh, Leh, Ladakh, India\",\n                    \"place_type\": [\"locality\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q24912826\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzozVUNLYXc\",\n                    },\n                },\n                {\n                    \"id\": \"place.25135211\",\n                    \"bbox\": [77.107033, 32.33574, 79.305839, 35.522256],\n                    \"text\": \"Leh\",\n                    \"type\": \"Feature\",\n                    \"center\": [77.584813, 34.164203],\n                    \"context\": [\n                        {\n                            \"id\": \"district.3196523\",\n                            \"text\": \"Leh\",\n                            \"wikidata\": \"Q1921210\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpNTVpy\",\n                        },\n                        {\n                            \"id\": \"region.222315\",\n                            \"text\": \"Ladakh\",\n                            \"wikidata\": \"Q200667\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBMlJy\",\n                            \"short_code\": \"IN-LA\",\n                        },\n                        {\n                            \"id\": \"country.8811\",\n                            \"text\": \"India\",\n                            \"wikidata\": \"Q668\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                            \"short_code\": \"in\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [77.584813, 34.164203],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Leh, Ladakh, India\",\n                    \"place_type\": [\"place\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q230818\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpBWCtJYXc\",\n                    },\n                },\n                {\n                    \"id\": \"region.222315\",\n                    \"bbox\": [75.306629, 32.33574, 79.305851, 35.673315],\n                    \"text\": \"Ladakh\",\n                    \"type\": \"Feature\",\n                    \"center\": [77.27783203125, 34.0071350643588],\n                    \"context\": [\n                        {\n                            \"id\": \"country.8811\",\n                            \"text\": \"India\",\n                            \"wikidata\": \"Q668\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                            \"short_code\": \"in\",\n                        }\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [77.27783203125, 34.0071350643588],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Ladakh, India\",\n                    \"place_type\": [\"region\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q200667\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpBMlJy\",\n                        \"short_code\": \"IN-LA\",\n                    },\n                },\n                {\n                    \"id\": \"country.8811\",\n                    \"bbox\": [68.1152344, 6.6718373, 97.395359, 35.673315],\n                    \"text\": \"India\",\n                    \"type\": \"Feature\",\n                    \"center\": [78.476681027237, 22.1991660760527],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [78.476681027237, 22.1991660760527],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"India\",\n                    \"place_type\": [\"country\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q668\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                        \"short_code\": \"in\",\n                    },\n                },\n            ],\n            \"attribution\": \"NOTICE: © 2023 Mapbox and its suppliers. All rights reserved. Use of this data is subject to the Mapbox Terms of Service (https://www.mapbox.com/about/maps/). This response and the information it contains may not be retained. POI(s) provided by Foursquare.\",\n            \"search_text\": \"Lakeshore Road 194201 Shachokol Leh Ladakh India\",\n        },\n        \"captions_json\": {\n            \"places365\": {\n                \"attributes\": [\n                    \"man made\",\n                    \"natural light\",\n                    \"open area\",\n                    \"no horizon\",\n                    \"clouds\",\n                    \"cloth\",\n                    \"vertical components\",\n                    \"sunny\",\n                    \"plastic\",\n                ],\n                \"categories\": [\"playground\"],\n                \"environment\": \"outdoor\",\n            }\n        },\n        \"search_captions\": \"playground , outdoor\",\n        \"search_location\": \"Lakeshore Road 194201 Shachokol Leh Ladakh India\",\n        \"hidden\": False,\n        \"public\": False,\n        \"video\": False,\n        \"clip_embeddings\": [\n            -0.18379329144954681,\n            0.14253073930740356,\n            -0.34837892651557920,\n            0.37873998284339905,\n            0.34088349342346190,\n            0.17458681762218475,\n            0.25472477078437805,\n            0.29220539331436160,\n            -0.40241336822509766,\n            -0.11195755004882812,\n            0.30859681963920593,\n            0.04621639847755432,\n            0.15303245186805725,\n            -0.42163884639739990,\n            -0.18283540010452270,\n            0.27533942461013794,\n            -1.12763762474060060,\n            -0.24703232944011688,\n            0.023488599807024002,\n            -0.51369655132293700,\n            -0.18228112161159515,\n            0.04996514320373535,\n            0.09342576563358307,\n            -0.17483948171138763,\n            -0.25493249297142030,\n            0.019286181777715683,\n            0.17102202773094177,\n            -0.29159709811210630,\n            0.33772629499435425,\n            -0.19569182395935059,\n            -0.19514861702919006,\n            0.35274094343185425,\n            -0.05648523569107056,\n            -0.30300104618072510,\n            0.01596139743924141,\n            0.47075837850570680,\n            -0.26226904988288880,\n            0.10937485098838806,\n            -0.45466923713684080,\n            -0.61406242847442630,\n            0.045746058225631714,\n            -0.09549369663000107,\n            0.49270814657211304,\n            0.02534257248044014,\n            -0.29359999299049380,\n            -0.07753305882215500,\n            -0.02130131423473358,\n            -0.07495892047882080,\n            -0.60064113140106200,\n            -0.014182962477207184,\n            0.18451625108718872,\n            0.35400345921516420,\n            0.22977495193481445,\n            0.06056281179189682,\n            -0.16951103508472443,\n            0.43877935409545900,\n            -0.08079877495765686,\n            -0.07902894914150238,\n            -0.05741514265537262,\n            0.55906730890274050,\n            0.58910524845123290,\n            -0.26552647352218630,\n            0.47798049449920654,\n            -0.17932428419589996,\n            0.03466450423002243,\n            -0.32181957364082336,\n            0.32691439986228943,\n            1.57434558868408200,\n            -0.60944569110870360,\n            -0.30789577960968020,\n            -0.03525789454579353,\n            0.21574194729328156,\n            0.09106104075908661,\n            0.07360906153917313,\n            -0.21098615229129790,\n            0.14903579652309418,\n            -0.20697991549968720,\n            0.09246206283569336,\n            -0.30049309134483340,\n            -0.25683689117431640,\n            -0.42003977298736570,\n            0.38077199459075930,\n            -0.32969945669174194,\n            -0.46575132012367250,\n            0.24943169951438904,\n            0.38942575454711914,\n            -0.37571138143539430,\n            -0.58096361160278320,\n            0.11415434628725052,\n            -0.42314538359642030,\n            0.30862617492675780,\n            0.26128217577934265,\n            -7.43450069427490200,\n            -0.14982101321220398,\n            -0.007992172613739967,\n            0.12065914273262024,\n            -0.38246965408325195,\n            -0.34107217192649840,\n            -0.80451864004135130,\n            -1.04886448383331300,\n            0.55530327558517460,\n            -0.24028851091861725,\n            0.14857441186904907,\n            0.07519923150539398,\n            -0.44258895516395570,\n            -0.22696885466575623,\n            -1.76246547698974600,\n            -0.04149024933576584,\n            -0.33089119195938110,\n            0.034649670124053955,\n            0.20300607383251190,\n            -0.18623992800712585,\n            0.18344642221927643,\n            0.23681724071502686,\n            0.27303919196128845,\n            0.08612959086894989,\n            0.08668576925992966,\n            0.09101299196481705,\n            0.43840888142585754,\n            -0.61762166023254400,\n            -0.20999190211296082,\n            0.46851450204849243,\n            -0.060045816004276276,\n            -0.10672970116138458,\n            -0.12689085304737090,\n            -0.22409234941005707,\n            -0.28366869688034060,\n            -0.25668996572494507,\n            0.04891391843557358,\n            0.35491180419921875,\n            0.27223268151283264,\n            -0.19807010889053345,\n            0.01612691581249237,\n            0.98669499158859250,\n            0.94963777065277100,\n            0.11202965676784515,\n            0.13241872191429138,\n            -0.33038973808288574,\n            -0.24429757893085480,\n            0.30757093429565430,\n            -0.10918612778186798,\n            -0.20302526652812958,\n            -0.014930151402950287,\n            0.67508393526077270,\n            -0.30977970361709595,\n            -0.19329161942005157,\n            0.04983800649642944,\n            0.58730208873748780,\n            -0.76442992687225340,\n            0.10625885426998138,\n            -0.009232066571712494,\n            0.26251035928726196,\n            0.29365420341491700,\n            -0.28812190890312195,\n            0.07605545222759247,\n            -0.65482407808303830,\n            0.35084849596023560,\n            0.34299698472023010,\n            0.33285912871360780,\n            -0.22953245043754578,\n            -0.20801588892936707,\n            -0.05146145820617676,\n            0.18107087910175323,\n            0.51615625619888310,\n            -0.28014412522315980,\n            0.21909615397453308,\n            1.08348631858825680,\n            0.43758583068847656,\n            -0.15551409125328064,\n            -0.11162629723548889,\n            0.08055899292230606,\n            0.09409813582897186,\n            0.021383360028266907,\n            -0.50918841361999510,\n            -0.11208890378475189,\n            -0.38599658012390137,\n            -0.86290252208709720,\n            0.24611333012580872,\n            -0.62409228086471560,\n            0.27000692486763000,\n            -0.019148532301187515,\n            -0.15995115041732788,\n            0.0021793851628899574,\n            0.07647107541561127,\n            0.19650121033191680,\n            0.25712081789970400,\n            0.15782865881919860,\n            -0.12753826379776000,\n            0.13912162184715270,\n            0.057879120111465454,\n            0.12957704067230225,\n            0.23146329820156097,\n            0.024382056668400764,\n            0.04920619726181030,\n            -0.24734464287757874,\n            0.23542645573616028,\n            -0.17139248549938202,\n            -0.16124279797077180,\n            0.63887059688568120,\n            0.39621770381927490,\n            -0.20532843470573425,\n            0.012457119300961494,\n            -0.39233744144439700,\n            0.71717041730880740,\n            0.25644633173942566,\n            -0.49303287267684937,\n            -0.04727388173341751,\n            -0.38488596677780150,\n            0.03999908268451691,\n            -0.11684153228998184,\n            -1.22885262966156000,\n            -0.15317772328853607,\n            0.03499954938888550,\n            0.18243020772933960,\n            -0.32903003692626953,\n            -0.28470265865325930,\n            -0.17769415676593780,\n            0.35230401158332825,\n            -0.68235456943511960,\n            -0.44540110230445860,\n            0.25034108757972720,\n            0.23645302653312683,\n            -0.09606185555458069,\n            0.23800262808799744,\n            0.11027810722589493,\n            0.05310886353254318,\n            -0.15807375311851501,\n            -0.32893413305282590,\n            -0.12554939091205597,\n            -0.09278482198715210,\n            0.36654987931251526,\n            0.31478390097618103,\n            0.022500615566968918,\n            -0.17153693735599518,\n            0.58437371253967290,\n            -0.56753563880920410,\n            0.0016302019357681274,\n            -0.31919938325881960,\n            -0.01767304539680481,\n            -0.30621975660324097,\n            0.42181178927421570,\n            0.18961273133754730,\n            -0.27777704596519470,\n            -0.04874429106712341,\n            -0.83623814582824710,\n            -0.59505325555801390,\n            0.15763781964778900,\n            0.11460117995738983,\n            -0.037775248289108276,\n            0.36981520056724550,\n            0.04750179499387741,\n            0.28530880808830260,\n            -0.32593557238578796,\n            0.20472106337547302,\n            -0.14085072278976440,\n            -0.43984699249267580,\n            -0.44318401813507080,\n            0.16889306902885437,\n            0.69945454597473140,\n            0.022213801741600037,\n            0.04395437240600586,\n            0.018100500106811523,\n            -0.60659039020538330,\n            0.65107047557830810,\n            0.23823469877243042,\n            -0.17507782578468323,\n            0.08020025491714478,\n            0.31494301557540894,\n            0.30044376850128174,\n            -0.23546507954597473,\n            0.11599977314472198,\n            0.48960816860198975,\n            0.23746117949485780,\n            -0.48635044693946840,\n            -0.23878610134124756,\n            0.26654928922653200,\n            -0.25886836647987366,\n            -0.08302380144596100,\n            -0.13755083084106445,\n            0.24222144484519958,\n            0.47945415973663330,\n            0.38373395800590515,\n            0.30907928943634033,\n            -0.25348061323165894,\n            0.12096904218196869,\n            0.28086936473846436,\n            -1.17676830291748050,\n            0.04671703651547432,\n            -0.48752713203430176,\n            -0.20467823743820190,\n            0.37203326821327210,\n            -0.58486735820770260,\n            0.17445452511310577,\n            -0.08156547695398330,\n            0.45127516984939575,\n            -0.02351894974708557,\n            -0.19682705402374268,\n            0.17286786437034607,\n            -0.17587804794311523,\n            0.30918318033218384,\n            0.15095241367816925,\n            -0.22093287110328674,\n            -0.08669745922088623,\n            -0.11007970571517944,\n            0.003908701241016388,\n            -0.44974136352539060,\n            0.13072070479393005,\n            0.77382683753967290,\n            0.30658128857612610,\n            -0.24635344743728638,\n            -0.21027483046054840,\n            0.16422425210475922,\n            0.98671263456344600,\n            -0.42050862312316895,\n            0.22445756196975708,\n            0.23323085904121400,\n            0.21030767261981964,\n            0.08243256807327270,\n            0.18058310449123383,\n            -0.55228275060653690,\n            0.029390739277005196,\n            -0.0027499347925186157,\n            -0.26178988814353943,\n            0.39981740713119507,\n            -0.46446281671524050,\n            0.20612671971321106,\n            0.02648659050464630,\n            -0.06571706384420395,\n            -0.09742008894681930,\n            0.27914172410964966,\n            -0.44574266672134400,\n            -0.004618685692548752,\n            -0.20941592752933502,\n            -0.014536745846271515,\n            0.04214660823345184,\n            0.16787378489971160,\n            0.12518627941608430,\n            0.14714732766151428,\n            0.42325642704963684,\n            -0.18695265054702760,\n            0.59449142217636110,\n            -0.41381680965423584,\n            -0.08633117377758026,\n            0.48078146576881410,\n            0.01567218452692032,\n            -0.11084793508052826,\n            -0.25879496335983276,\n            0.25035518407821655,\n            0.08629626780748367,\n            -0.17640779912471770,\n            0.35798883438110350,\n            0.003633505664765835,\n            0.26752072572708130,\n            -0.008670955896377563,\n            -0.060069844126701355,\n            -0.45262604951858520,\n            0.0029344558715820312,\n            0.81401431560516360,\n            0.30656427145004270,\n            0.10814795643091202,\n            0.16015036404132843,\n            0.27728241682052610,\n            0.003430556505918503,\n            0.59562069177627560,\n            -0.60939180850982670,\n            0.11420226097106934,\n            -0.07706215977668762,\n            -0.68500268459320070,\n            -0.005410104990005493,\n            0.013373695313930511,\n            0.21606793999671936,\n            0.08226697891950607,\n            -0.19103342294692993,\n            0.37374818325042725,\n            0.44795745611190796,\n            -0.11685820668935776,\n            1.60860514640808100,\n            -0.28285527229309080,\n            0.58519774675369260,\n            0.17927187681198120,\n            0.43113380670547485,\n            -0.08384191989898682,\n            -0.23026300966739655,\n            -0.87972706556320190,\n            -0.0010712891817092896,\n            -0.11425804346799850,\n            -0.03720918670296669,\n            -0.013472747057676315,\n            -0.20737977325916290,\n            1.80557346343994140,\n            -0.64729046821594240,\n            -0.30271497368812560,\n            -0.061230555176734924,\n            -0.38844275474548340,\n            0.17786000669002533,\n            -0.031046316027641296,\n            0.24852822721004486,\n            -0.39031293988227844,\n            -0.28145694732666016,\n            -0.52712631225585940,\n            0.43997967243194580,\n            -0.22887703776359558,\n            0.17240211367607117,\n            -0.29442641139030457,\n            0.35376852750778200,\n            0.22806043922901154,\n            -0.06477281451225281,\n            -0.35966825485229490,\n            0.40587043762207030,\n            -0.08254503458738327,\n            0.93505430221557620,\n            0.013737834990024567,\n            -0.16866286098957062,\n            -0.17242559790611267,\n            -0.09762792289257050,\n            0.06496423482894897,\n            -0.53277724981307980,\n            -0.41173180937767030,\n            -0.10729959607124329,\n            -0.22943912446498870,\n            0.09855599701404572,\n            -0.31594187021255493,\n            0.63642692565917970,\n            0.08816694468259811,\n            -0.11014832556247711,\n            -0.28573364019393920,\n            -0.16491752862930298,\n            -0.013046562671661377,\n            -0.57793509960174560,\n            0.16094970703125000,\n            -0.41850280761718750,\n            -0.07970791310071945,\n            0.15436783432960510,\n            -0.29503697156906130,\n            -0.052088260650634766,\n            -0.33966383337974550,\n            0.018201902508735657,\n            -0.06606295704841614,\n            0.04100047051906586,\n            0.36471304297447205,\n            -0.40008947253227234,\n            0.07975389063358307,\n            0.39990115165710450,\n            0.15097494423389435,\n            -0.21128392219543457,\n            0.05080147460103035,\n            -0.046003326773643494,\n            0.25351849198341370,\n            0.16474604606628418,\n            0.08277872204780579,\n            0.22466805577278137,\n            0.22473768889904022,\n            -0.13952386379241943,\n            -0.35128515958786010,\n            -0.17046678066253662,\n            -0.04700194299221039,\n            0.21633064746856690,\n            -0.27318200469017030,\n            0.02723865583539009,\n            -0.47086358070373535,\n            0.25049856305122375,\n            -0.48574218153953550,\n            -0.08048862218856812,\n            -0.36891525983810425,\n            0.03864942863583565,\n            -0.42741003632545470,\n            0.74072462320327760,\n            -0.010117409750819206,\n            0.19975064694881440,\n            0.03020879626274109,\n            0.06506866216659546,\n            -0.04526616632938385,\n            0.26060470938682556,\n            -0.43580517172813416,\n            -0.04713132977485657,\n            -0.33256918191909790,\n            -0.17320886254310608,\n            -0.22390314936637878,\n            -0.31866759061813354,\n            -0.29139828681945800,\n            0.07238420099020004,\n            -0.07740981876850128,\n            0.11880752444267273,\n            -0.26510918140411377,\n            0.15372966229915620,\n            0.41496598720550537,\n            -0.18698418140411377,\n            0.0033263303339481354,\n            -0.50970172882080080,\n            0.15158197283744812,\n            -0.06647455692291260,\n            0.21226441860198975,\n            0.47759312391281130,\n            -0.82750320434570310,\n            -0.008479125797748566,\n            -0.64633381366729740,\n            -0.16696789860725403,\n            0.012448564171791077,\n            -0.03979574143886566,\n            -0.029009684920310974,\n            -0.84793686866760250,\n            0.27539581060409546,\n            -0.03149215131998062,\n            0.12921379506587982,\n            0.14929755032062530,\n            -0.19812837243080140,\n            -0.20390477776527405,\n            0.030443966388702393,\n            -0.46114227175712585,\n            0.15821106731891632,\n            -0.05045306682586670,\n            -0.057202182710170746,\n        ],\n        \"clip_embeddings_magnitude\": 11.107995986938477,\n        \"aspect_ratio\": 1.33,\n        \"rating\": 0,\n        \"dominant_color\": \"[126, 149, 181]\",\n        \"video_length\": None,\n        \"removed\": False,\n        \"in_trashcan\": False,\n        \"timestamp\": None,\n        \"camera\": \"Pixel 2\",\n        \"digitalZoomRatio\": None,\n        \"focalLength35Equivalent\": None,\n        \"focal_length\": 4.442,\n        \"fstop\": 1.8,\n        \"height\": 3024,\n        \"iso\": None,\n        \"lens\": None,\n        \"shutter_speed\": \"0\",\n        \"size\": 6666687,\n        \"subjectDistance\": 4.691,\n        \"width\": 4032,\n        \"main_file_id\": \"c57f08a55c6daa689e6e13a1bf8f81471\",\n    },\n    {\n        \"thumbnail_big\": \"thumbnails_big/c4d896c9c9c0ca3c312b19f2f22d69ae1.webp\",\n        \"square_thumbnail\": \"square_thumbnails/c4d896c9c9c0ca3c312b19f2f22d69ae1.webp\",\n        \"square_thumbnail_small\": \"square_thumbnails_small/c4d896c9c9c0ca3c312b19f2f22d69ae1.webp\",\n        \"added_on\": \"2023-06-16 16:30:53.937783 +00:00\",\n        \"exif_gps_lat\": 44.5542,\n        \"exif_gps_lon\": -78.1953472222222,\n        \"exif_timestamp\": \"2017-08-17 17:59:35.000000 +00:00\",\n        \"exif_json\": None,\n        \"geolocation_json\": {\n            \"type\": \"FeatureCollection\",\n            \"query\": [-78.195347, 44.5542],\n            \"features\": [\n                {\n                    \"id\": \"address.2311178160127006\",\n                    \"text\": \"Fire Route 47\",\n                    \"type\": \"Feature\",\n                    \"center\": [-78.1955566, 44.5540639],\n                    \"address\": \"2880\",\n                    \"context\": [\n                        {\n                            \"id\": \"postcode.2669825575\",\n                            \"text\": \"K0L 2H0\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpueUpPSnc\",\n                        },\n                        {\n                            \"id\": \"place.106145831\",\n                            \"text\": \"Lakefield\",\n                            \"wikidata\": \"Q114506323\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpCbE9vSnc\",\n                        },\n                        {\n                            \"id\": \"district.1140263\",\n                            \"text\": \"Peterborough County\",\n                            \"wikidata\": \"Q730542\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpFV1lu\",\n                        },\n                        {\n                            \"id\": \"region.17447\",\n                            \"text\": \"Ontario\",\n                            \"wikidata\": \"Q1904\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpSQ2M\",\n                            \"short_code\": \"CA-ON\",\n                        },\n                        {\n                            \"id\": \"country.8743\",\n                            \"text\": \"Canada\",\n                            \"wikidata\": \"Q16\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJaWM\",\n                            \"short_code\": \"ca\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [-78.1955566, 44.5540639],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"2880 Fire Route 47, Lakefield, Ontario K0L 2H0, Canada\",\n                    \"place_type\": [\"address\"],\n                    \"properties\": {\n                        \"accuracy\": \"point\",\n                        \"mapbox_id\": \"dXJuOm1ieGFkcjoyOTlkZDc4Yy0wYWVlLTQ2ZGUtYmFkOC04ZDA0Y2VhYzNkZGQ\",\n                    },\n                },\n                {\n                    \"id\": \"postcode.2669825575\",\n                    \"bbox\": [-78.383699659, 44.336123836, -77.917196264, 44.674985335],\n                    \"text\": \"K0L 2H0\",\n                    \"type\": \"Feature\",\n                    \"center\": [-78.27, 44.42],\n                    \"context\": [\n                        {\n                            \"id\": \"place.106145831\",\n                            \"text\": \"Lakefield\",\n                            \"wikidata\": \"Q114506323\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpCbE9vSnc\",\n                        },\n                        {\n                            \"id\": \"district.1140263\",\n                            \"text\": \"Peterborough County\",\n                            \"wikidata\": \"Q730542\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpFV1lu\",\n                        },\n                        {\n                            \"id\": \"region.17447\",\n                            \"text\": \"Ontario\",\n                            \"wikidata\": \"Q1904\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpSQ2M\",\n                            \"short_code\": \"CA-ON\",\n                        },\n                        {\n                            \"id\": \"country.8743\",\n                            \"text\": \"Canada\",\n                            \"wikidata\": \"Q16\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJaWM\",\n                            \"short_code\": \"ca\",\n                        },\n                    ],\n                    \"geometry\": {\"type\": \"Point\", \"coordinates\": [-78.27, 44.42]},\n                    \"relevance\": 1,\n                    \"place_name\": \"K0L 2H0, Lakefield, Ontario, Canada\",\n                    \"place_type\": [\"postcode\"],\n                    \"properties\": {\"mapbox_id\": \"dXJuOm1ieHBsYzpueUpPSnc\"},\n                },\n                {\n                    \"id\": \"place.106145831\",\n                    \"bbox\": [-78.383699659, 44.402393807, -78.086446707, 44.59913282],\n                    \"text\": \"Lakefield\",\n                    \"type\": \"Feature\",\n                    \"center\": [-78.272195, 44.423337],\n                    \"context\": [\n                        {\n                            \"id\": \"district.1140263\",\n                            \"text\": \"Peterborough County\",\n                            \"wikidata\": \"Q730542\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpFV1lu\",\n                        },\n                        {\n                            \"id\": \"region.17447\",\n                            \"text\": \"Ontario\",\n                            \"wikidata\": \"Q1904\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpSQ2M\",\n                            \"short_code\": \"CA-ON\",\n                        },\n                        {\n                            \"id\": \"country.8743\",\n                            \"text\": \"Canada\",\n                            \"wikidata\": \"Q16\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJaWM\",\n                            \"short_code\": \"ca\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [-78.272195, 44.423337],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Lakefield, Ontario, Canada\",\n                    \"place_type\": [\"place\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q114506323\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpCbE9vSnc\",\n                    },\n                },\n                {\n                    \"id\": \"district.1140263\",\n                    \"bbox\": [-78.654828, 44.081278, -77.727372, 44.916769],\n                    \"text\": \"Peterborough County\",\n                    \"type\": \"Feature\",\n                    \"center\": [-78.332479, 44.302625],\n                    \"context\": [\n                        {\n                            \"id\": \"region.17447\",\n                            \"text\": \"Ontario\",\n                            \"wikidata\": \"Q1904\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpSQ2M\",\n                            \"short_code\": \"CA-ON\",\n                        },\n                        {\n                            \"id\": \"country.8743\",\n                            \"text\": \"Canada\",\n                            \"wikidata\": \"Q16\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJaWM\",\n                            \"short_code\": \"ca\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [-78.332479, 44.302625],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Peterborough County, Ontario, Canada\",\n                    \"place_type\": [\"district\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q730542\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpFV1lu\",\n                    },\n                },\n                {\n                    \"id\": \"region.17447\",\n                    \"bbox\": [-95.158717, 41.6400784, -74.3381353, 56.9091182],\n                    \"text\": \"Ontario\",\n                    \"type\": \"Feature\",\n                    \"center\": [-84.2539813003493, 49.4515636581924],\n                    \"context\": [\n                        {\n                            \"id\": \"country.8743\",\n                            \"text\": \"Canada\",\n                            \"wikidata\": \"Q16\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJaWM\",\n                            \"short_code\": \"ca\",\n                        }\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [-84.2539813003493, 49.4515636581924],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Ontario, Canada\",\n                    \"place_type\": [\"region\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q1904\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpSQ2M\",\n                        \"short_code\": \"CA-ON\",\n                    },\n                },\n                {\n                    \"id\": \"country.8743\",\n                    \"bbox\": [-141.010465, 41.6400784, -52.5210105, 83.1472069],\n                    \"text\": \"Canada\",\n                    \"type\": \"Feature\",\n                    \"center\": [-105.750595856519, 55.5859012851966],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [-105.750595856519, 55.5859012851966],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Canada\",\n                    \"place_type\": [\"country\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q16\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpJaWM\",\n                        \"short_code\": \"ca\",\n                    },\n                },\n            ],\n            \"attribution\": \"NOTICE: © 2023 Mapbox and its suppliers. All rights reserved. Use of this data is subject to the Mapbox Terms of Service (https://www.mapbox.com/about/maps/). This response and the information it contains may not be retained. POI(s) provided by Foursquare.\",\n            \"search_text\": \"Fire Route 47 K0L 2H0 Lakefield Peterborough County Ontario Canada\",\n        },\n        \"captions_json\": {\n            \"places365\": {\n                \"attributes\": [\n                    \"natural light\",\n                    \"open area\",\n                    \"vegetation\",\n                    \"foliage\",\n                    \"trees\",\n                    \"leaves\",\n                    \"grass\",\n                    \"man made\",\n                    \"sunny\",\n                ],\n                \"categories\": [\"picnic area\"],\n                \"environment\": \"outdoor\",\n            }\n        },\n        \"search_captions\": \"picnic area , outdoor\",\n        \"search_location\": \"Fire Route 47 K0L 2H0 Lakefield Peterborough County Ontario Canada\",\n        \"hidden\": False,\n        \"public\": False,\n        \"video\": False,\n        \"clip_embeddings\": [\n            0.07200643420219421,\n            -0.27314072847366333,\n            -0.28608414530754090,\n            -0.017477907240390778,\n            0.50455182790756230,\n            0.28596064448356630,\n            0.06314943730831146,\n            0.23560704290866852,\n            -0.19991967082023620,\n            0.05301810801029205,\n            0.23392733931541443,\n            -0.015628136694431305,\n            -0.12933245301246643,\n            -0.62648004293441770,\n            0.19849593937397003,\n            -0.09278710186481476,\n            -1.13199305534362800,\n            0.02051648497581482,\n            0.18314598500728607,\n            -0.07170593738555908,\n            -0.35629284381866455,\n            0.10626928508281708,\n            0.022010430693626404,\n            -0.36885976791381836,\n            0.01585569977760315,\n            0.19657053053379060,\n            -0.33990219235420227,\n            -0.15209683775901794,\n            -0.13219691812992096,\n            -0.29297292232513430,\n            -0.007032429799437523,\n            0.30475685000419617,\n            -0.69666099548339840,\n            -0.55377709865570070,\n            0.50226724147796630,\n            0.43203988671302795,\n            -0.03661022335290909,\n            0.12110148370265960,\n            0.27576541900634766,\n            -0.51852631568908690,\n            -0.16946467757225037,\n            0.06092119216918945,\n            -0.05353017896413803,\n            -0.10094764083623886,\n            0.24288514256477356,\n            -2.32672357559204100,\n            0.23213247954845428,\n            -0.31283071637153625,\n            0.05823267251253128,\n            0.033336833119392395,\n            0.03891675919294357,\n            0.34450155496597290,\n            0.54696977138519290,\n            -0.10590277612209320,\n            -0.38763791322708130,\n            0.11399775743484497,\n            -0.11230312287807465,\n            -0.25931385159492490,\n            -0.71149158477783200,\n            0.19414122402668000,\n            -1.06821513175964360,\n            -0.05036032199859619,\n            0.23391188681125640,\n            0.18708521127700806,\n            -0.28283250331878660,\n            -0.49732804298400880,\n            0.14408025145530700,\n            1.58359146118164060,\n            -0.09503769129514694,\n            0.10219173878431320,\n            0.18811975419521332,\n            0.11770723760128021,\n            0.14510244131088257,\n            -0.025733187794685364,\n            -0.12114733457565308,\n            0.028954997658729553,\n            -0.36884146928787230,\n            -0.14654713869094850,\n            -0.20010140538215637,\n            0.05988488718867302,\n            0.02569446712732315,\n            0.52280324697494510,\n            -0.00884101539850235,\n            -0.84105598926544190,\n            0.08269304037094116,\n            -0.030198069289326668,\n            0.08129344880580902,\n            -0.49774390459060670,\n            -0.61459028720855710,\n            -0.27356880903244020,\n            -0.24041604995727540,\n            -0.33665394783020020,\n            -5.96149921417236300,\n            0.22716827690601350,\n            0.28753006458282470,\n            0.48320820927619934,\n            -0.12874460220336914,\n            0.14355051517486572,\n            -0.34171727299690247,\n            -1.38677775859832760,\n            0.058781594038009644,\n            0.09329684078693390,\n            0.12113958597183228,\n            0.02105279266834259,\n            0.25320750474929810,\n            0.30670967698097230,\n            -0.23736193776130676,\n            -0.008005078881978989,\n            -0.15708926320075990,\n            -0.11563684791326523,\n            -0.19381190836429596,\n            -0.70084750652313230,\n            -0.25472760200500490,\n            -0.25218990445137024,\n            0.04032253846526146,\n            0.15879295766353607,\n            -0.24767071008682250,\n            0.28805834054946900,\n            0.02391856536269188,\n            0.018087051808834076,\n            0.21582140028476715,\n            0.62899506092071530,\n            -0.13227358460426330,\n            0.30459716916084290,\n            0.08614320307970047,\n            -0.23780769109725952,\n            0.25391614437103270,\n            -0.22144922614097595,\n            -0.08764651417732239,\n            0.37465840578079224,\n            -0.19986276328563690,\n            -0.23993769288063050,\n            -0.24029092490673065,\n            0.86103123426437380,\n            -0.20356409251689910,\n            0.18121457099914550,\n            0.007383421063423157,\n            0.02566760778427124,\n            -0.18797449767589570,\n            0.12505835294723510,\n            0.06744767725467682,\n            -0.31292539834976196,\n            0.14250934123992920,\n            -0.16580671072006226,\n            -0.10141041129827500,\n            0.07421887665987015,\n            0.13718751072883606,\n            0.31398600339889526,\n            -0.45369070768356323,\n            0.37390136718750000,\n            -0.17167736589908600,\n            -0.12102013081312180,\n            0.55987459421157840,\n            -0.64511901140213010,\n            0.32400843501091003,\n            -0.15718287229537964,\n            0.03728494048118591,\n            -0.48887744545936584,\n            -0.43595361709594727,\n            0.27848058938980100,\n            -0.26770013570785520,\n            0.11762751638889313,\n            0.14060628414154053,\n            0.14021927118301392,\n            0.16482727229595184,\n            0.12399593740701675,\n            1.10899972915649410,\n            0.58323627710342410,\n            0.25726687908172610,\n            -0.04836788773536682,\n            0.42536145448684690,\n            0.026311412453651428,\n            0.23059077560901642,\n            -0.09234851598739624,\n            -0.42488861083984375,\n            -0.52500975131988530,\n            -0.14144848287105560,\n            0.031238600611686707,\n            -0.22408963739871980,\n            0.16041082143783570,\n            0.19980202615261078,\n            0.12246149778366089,\n            -0.74750423431396480,\n            0.34143632650375366,\n            -0.07465202361345291,\n            0.032671473920345306,\n            0.35502630472183230,\n            -0.54294133186340330,\n            0.01227620244026184,\n            0.11296307295560837,\n            0.23945194482803345,\n            0.22230161726474762,\n            0.09851396828889847,\n            -0.10145194828510284,\n            -0.94168329238891600,\n            -0.63558840751647950,\n            -0.09790701419115067,\n            0.08268978446722030,\n            1.47379052639007570,\n            -0.40544277429580690,\n            -0.19830146431922913,\n            0.12428025901317596,\n            0.01359538733959198,\n            0.11815530061721802,\n            -0.14290475845336914,\n            -0.25821614265441895,\n            -0.27840244770050050,\n            0.51778000593185420,\n            -0.28622409701347350,\n            0.13844540715217590,\n            0.019464679062366486,\n            -0.64047700166702270,\n            -0.51729387044906620,\n            0.36219158768653870,\n            -0.29284214973449707,\n            -0.09910607337951660,\n            -0.44586786627769470,\n            0.26939472556114197,\n            0.001346886157989502,\n            0.10100428760051727,\n            0.12059158086776733,\n            1.46603417396545400,\n            -0.16158343851566315,\n            0.09549902379512787,\n            0.31741645932197570,\n            -0.06466329097747803,\n            -0.37618625164031980,\n            -0.10769309103488922,\n            0.24339321255683900,\n            -0.07732771337032318,\n            -0.28822788596153260,\n            -0.30479827523231506,\n            0.28579634428024290,\n            0.57900738716125490,\n            0.01886954903602600,\n            -0.34693476557731630,\n            0.13962632417678833,\n            -0.36294281482696533,\n            -0.10817342996597290,\n            0.035561710596084595,\n            -0.57529795169830320,\n            -0.12815028429031372,\n            0.03226613253355026,\n            -0.58416968584060670,\n            0.13841366767883300,\n            0.03650939464569092,\n            0.038980357348918915,\n            0.34951549768447876,\n            0.20841777324676514,\n            0.12865397334098816,\n            -0.12616679072380066,\n            0.08742494136095047,\n            0.21315385401248932,\n            0.09627585113048553,\n            0.20098021626472473,\n            -0.06143351271748543,\n            -0.043563079088926315,\n            -0.19623640179634094,\n            1.57240605354309080,\n            -0.35777574777603150,\n            0.02914358116686344,\n            0.63118875026702880,\n            0.18993435800075530,\n            -0.46280235052108765,\n            0.030418217182159424,\n            -0.34837594628334045,\n            0.03651222586631775,\n            0.26378205418586730,\n            0.046745263040065765,\n            0.28378057479858400,\n            -0.009584896266460419,\n            0.19612582027912140,\n            0.21172988414764404,\n            -0.032120171934366226,\n            0.20071959495544434,\n            -0.08056892454624176,\n            0.12477563321590424,\n            -0.13512735068798065,\n            0.14138039946556090,\n            0.31706815958023070,\n            0.024455122649669647,\n            -0.15259601175785065,\n            -0.20282268524169922,\n            -0.28921997547149660,\n            0.45865276455879210,\n            0.11363479495048523,\n            -0.20601873099803925,\n            0.12293325364589691,\n            -0.007529497146606445,\n            -0.20519900321960450,\n            -0.27892541885375977,\n            -0.25480884313583374,\n            0.10527120530605316,\n            -0.22468784451484680,\n            0.27766802906990050,\n            0.27707856893539430,\n            0.42828142642974854,\n            -0.06466045975685120,\n            -0.022840768098831177,\n            0.08824332058429718,\n            0.13453596830368042,\n            0.21060699224472046,\n            -0.13369843363761902,\n            -0.42310655117034910,\n            0.06692334264516830,\n            0.018675953149795532,\n            -0.012705937027931213,\n            0.16295075416564941,\n            -0.006689056754112244,\n            -0.27341771125793457,\n            -0.45861658453941345,\n            0.14121453464031220,\n            0.85903668403625490,\n            -0.19725802540779114,\n            0.14617547392845154,\n            0.27198433876037600,\n            0.17599108815193176,\n            0.02266123890876770,\n            -0.12822464108467102,\n            1.00072443485260000,\n            0.30147033929824830,\n            -0.74144268035888670,\n            -0.16088382899761200,\n            0.11513932049274445,\n            -0.38404512405395510,\n            0.04975423216819763,\n            0.37200531363487244,\n            0.19585512578487396,\n            -0.12441303580999374,\n            -0.24220986664295197,\n            0.15007376670837402,\n            -0.04617227613925934,\n            -0.04232384264469147,\n            -0.34620440006256104,\n            -0.26701658964157104,\n            -0.005242077633738518,\n            0.0031939297914505005,\n            0.02201770246028900,\n            -0.09960493445396423,\n            0.16118554770946503,\n            -0.33185213804244995,\n            0.15700313448905945,\n            0.16201999783515930,\n            0.07118913531303406,\n            0.22056972980499268,\n            -0.011956900358200073,\n            -0.0003955662250518799,\n            0.25377553701400757,\n            -0.41737711429595950,\n            0.26137113571166990,\n            0.42841473221778870,\n            -0.11275280267000198,\n            -0.54838216304779050,\n            0.03743823617696762,\n            0.08181229233741760,\n            1.28952217102050780,\n            0.36465561389923096,\n            -0.28938600420951843,\n            0.47177675366401670,\n            -0.16908544301986694,\n            0.93969750404357910,\n            0.08721843361854553,\n            -0.09430234134197235,\n            0.66503000259399410,\n            -0.48145574331283570,\n            -0.14018303155899048,\n            0.33783870935440063,\n            -0.75327533483505250,\n            -0.52762186527252200,\n            0.04408954456448555,\n            -0.39755284786224365,\n            -0.0019524451345205307,\n            0.30438941717147827,\n            0.04218447208404541,\n            -0.07897476106882095,\n            0.35774332284927370,\n            1.22708070278167720,\n            0.29904451966285706,\n            -0.11844162642955780,\n            -0.15679849684238434,\n            -0.12561301887035370,\n            -0.77952718734741210,\n            -0.26338329911231995,\n            -0.08969837427139282,\n            0.21005888283252716,\n            -0.31030485033988950,\n            0.24320170283317566,\n            0.27376961708068850,\n            -0.18231861293315887,\n            -1.11225414276123050,\n            -0.96022212505340580,\n            0.39804935455322266,\n            0.007329510059207678,\n            -0.21168775856494904,\n            -0.63149207830429080,\n            0.21230995655059814,\n            0.13413298130035400,\n            0.38083824515342710,\n            0.21768637001514435,\n            -0.37690994143486023,\n            0.010201409459114075,\n            0.35002541542053220,\n            -0.00883527286350727,\n            -0.49314117431640625,\n            0.11570226401090622,\n            -0.12012594938278198,\n            0.14163519442081451,\n            -0.31398567557334900,\n            -0.11759512126445770,\n            -0.26619073748588560,\n            0.24936842918395996,\n            -0.07021093368530273,\n            -0.26329314708709717,\n            -0.26164028048515320,\n            -0.48863250017166140,\n            -0.21155467629432678,\n            -0.51766830682754520,\n            -0.41783249378204346,\n            -0.36551034450531006,\n            -0.69736742973327640,\n            0.33944544196128845,\n            -0.14483065903186798,\n            0.44479277729988100,\n            -0.98320567607879640,\n            -0.19574180245399475,\n            0.31662541627883910,\n            -0.44551515579223633,\n            -0.022557497024536133,\n            -0.48501241207122800,\n            -0.09746624529361725,\n            -0.36100867390632630,\n            -0.27448734641075134,\n            0.78780412673950200,\n            -0.30192264914512634,\n            -0.44880360364913940,\n            -0.033154018223285675,\n            0.048185281455516815,\n            0.03699145466089249,\n            0.46661505103111267,\n            -0.06928810477256775,\n            0.32975050806999207,\n            0.66338658332824710,\n            0.026022130623459816,\n            -0.02559243142604828,\n            0.13531213998794556,\n            -0.011617228388786316,\n            -0.24844856560230255,\n            -0.22130966186523438,\n            0.06896549463272095,\n            0.16910549998283386,\n            -0.33283072710037230,\n            -0.23490653932094574,\n            -0.42383372783660890,\n            -0.82607471942901610,\n            -0.12491527199745178,\n            0.09555611014366150,\n            0.14898100495338440,\n            0.41498780250549316,\n            -0.33365392684936523,\n            0.08857516199350357,\n            -0.25846809148788450,\n            -0.023531511425971985,\n            0.24037255346775055,\n            0.25419849157333374,\n            0.18990617990493774,\n            0.21707595884799957,\n            0.054550834000110626,\n            -0.16628548502922058,\n            0.29191961884498596,\n            0.21750520169734955,\n            0.059166401624679565,\n            0.27432167530059814,\n            -0.07397265732288360,\n            -0.39545136690139770,\n            0.32126304507255554,\n            -0.23423250019550323,\n            -0.46515718102455140,\n            0.23323012888431550,\n            0.22779177129268646,\n            -0.19990277290344238,\n            0.16021879017353058,\n            0.12057007849216461,\n            0.48086464405059814,\n            0.21143439412117004,\n            -0.13717344403266907,\n            -0.26784723997116090,\n            0.36808949708938600,\n            0.04166709631681442,\n            0.10163601487874985,\n            -0.65290242433547970,\n            0.001865200698375702,\n            0.24058419466018677,\n            0.43577551841735840,\n            -0.30626660585403440,\n            0.04026487469673157,\n            -0.59185659885406490,\n            0.28134933114051820,\n            0.10597439110279083,\n            -0.02836729586124420,\n            -0.17641222476959229,\n            -0.41398856043815613,\n            0.13548584282398224,\n            0.03822256624698639,\n            0.49377080798149110,\n            0.79975557327270510,\n            -0.52094459533691410,\n            0.37935078144073486,\n            -0.22603368759155273,\n            -0.30841892957687380,\n            0.0037401914596557617,\n            -0.19617760181427002,\n            -0.03347594290971756,\n        ],\n        \"clip_embeddings_magnitude\": 10.361903190612793,\n        \"aspect_ratio\": 1.33,\n        \"rating\": 0,\n        \"dominant_color\": \"[72, 81, 54]\",\n        \"video_length\": None,\n        \"removed\": False,\n        \"in_trashcan\": False,\n        \"timestamp\": None,\n        \"camera\": \"Pixel 2\",\n        \"digitalZoomRatio\": None,\n        \"focalLength35Equivalent\": None,\n        \"focal_length\": 4.442,\n        \"fstop\": 1.8,\n        \"height\": 3024,\n        \"iso\": None,\n        \"lens\": None,\n        \"shutter_speed\": \"1/120\",\n        \"size\": 12186941,\n        \"subjectDistance\": 3.91,\n        \"width\": 4032,\n        \"main_file_id\": \"c4d896c9c9c0ca3c312b19f2f22d69ae1\",\n    },\n    {\n        \"thumbnail_big\": \"thumbnails_big/e21b9ade718afdbe931462d837b74d1c1.webp\",\n        \"square_thumbnail\": \"square_thumbnails/e21b9ade718afdbe931462d837b74d1c1.webp\",\n        \"square_thumbnail_small\": \"square_thumbnails_small/e21b9ade718afdbe931462d837b74d1c1.webp\",\n        \"added_on\": \"2023-06-16 16:30:55.449974 +00:00\",\n        \"exif_gps_lat\": 52.5019361111111,\n        \"exif_gps_lon\": 13.3815138888889,\n        \"exif_timestamp\": \"2017-08-22 13:27:09.000000 +00:00\",\n        \"exif_json\": None,\n        \"geolocation_json\": {\n            \"type\": \"FeatureCollection\",\n            \"query\": [13.381514, 52.501936],\n            \"features\": [\n                {\n                    \"id\": \"poi.1047972024770\",\n                    \"text\": \"Roncalli\",\n                    \"type\": \"Feature\",\n                    \"center\": [13.381502, 52.501711],\n                    \"context\": [\n                        {\n                            \"id\": \"postcode.5541434\",\n                            \"text\": \"10963\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpWSTQ2\",\n                        },\n                        {\n                            \"id\": \"locality.181160506\",\n                            \"text\": \"Kreuzberg\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpDc3hLT2c\",\n                        },\n                        {\n                            \"id\": \"place.115770\",\n                            \"text\": \"Berlin\",\n                            \"wikidata\": \"Q64\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBY1E2\",\n                            \"short_code\": \"DE-BE\",\n                        },\n                        {\n                            \"id\": \"country.8762\",\n                            \"text\": \"Germany\",\n                            \"wikidata\": \"Q183\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJam8\",\n                            \"short_code\": \"de\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [13.381502, 52.501711],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Roncalli, Möckernstr, Berlin, 10963, Germany\",\n                    \"place_type\": [\"poi\"],\n                    \"properties\": {\n                        \"maki\": \"theatre\",\n                        \"address\": \"Möckernstr\",\n                        \"category\": \"circus\",\n                        \"landmark\": True,\n                        \"foursquare\": \"50d22a90e4b0d3035504ae2b\",\n                    },\n                },\n                {\n                    \"id\": \"postcode.5541434\",\n                    \"bbox\": [13.372592, 52.49205, 13.391193, 52.508285],\n                    \"text\": \"10963\",\n                    \"type\": \"Feature\",\n                    \"center\": [13.383894, 52.497062],\n                    \"context\": [\n                        {\n                            \"id\": \"locality.181160506\",\n                            \"text\": \"Kreuzberg\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpDc3hLT2c\",\n                        },\n                        {\n                            \"id\": \"place.115770\",\n                            \"text\": \"Berlin\",\n                            \"wikidata\": \"Q64\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBY1E2\",\n                            \"short_code\": \"DE-BE\",\n                        },\n                        {\n                            \"id\": \"country.8762\",\n                            \"text\": \"Germany\",\n                            \"wikidata\": \"Q183\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJam8\",\n                            \"short_code\": \"de\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [13.383894, 52.497062],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"10963, Berlin, Germany\",\n                    \"place_type\": [\"postcode\"],\n                    \"properties\": {\"mapbox_id\": \"dXJuOm1ieHBsYzpWSTQ2\"},\n                },\n                {\n                    \"id\": \"locality.181160506\",\n                    \"bbox\": [13.368215, 52.48277, 13.453351, 52.50938],\n                    \"text\": \"Kreuzberg\",\n                    \"type\": \"Feature\",\n                    \"center\": [13.411914, 52.497644],\n                    \"context\": [\n                        {\n                            \"id\": \"place.115770\",\n                            \"text\": \"Berlin\",\n                            \"wikidata\": \"Q64\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBY1E2\",\n                            \"short_code\": \"DE-BE\",\n                        },\n                        {\n                            \"id\": \"country.8762\",\n                            \"text\": \"Germany\",\n                            \"wikidata\": \"Q183\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJam8\",\n                            \"short_code\": \"de\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [13.411914, 52.497644],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Kreuzberg, Berlin, Germany\",\n                    \"place_type\": [\"locality\"],\n                    \"properties\": {\"mapbox_id\": \"dXJuOm1ieHBsYzpDc3hLT2c\"},\n                },\n                {\n                    \"id\": \"place.115770\",\n                    \"bbox\": [13.08836, 52.338261, 13.761131, 52.675502],\n                    \"text\": \"Berlin\",\n                    \"type\": \"Feature\",\n                    \"center\": [13.3888599, 52.5170365],\n                    \"context\": [\n                        {\n                            \"id\": \"country.8762\",\n                            \"text\": \"Germany\",\n                            \"wikidata\": \"Q183\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJam8\",\n                            \"short_code\": \"de\",\n                        }\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [13.3888599, 52.5170365],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Berlin, Germany\",\n                    \"place_type\": [\"region\", \"place\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q64\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpBY1E2\",\n                        \"short_code\": \"DE-BE\",\n                    },\n                },\n                {\n                    \"id\": \"country.8762\",\n                    \"bbox\": [5.866315, 47.270238, 15.041832, 55.1286491],\n                    \"text\": \"Germany\",\n                    \"type\": \"Feature\",\n                    \"center\": [10.0183432948567, 51.1334813439932],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [10.0183432948567, 51.1334813439932],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Germany\",\n                    \"place_type\": [\"country\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q183\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpJam8\",\n                        \"short_code\": \"de\",\n                    },\n                },\n            ],\n            \"attribution\": \"NOTICE: © 2023 Mapbox and its suppliers. All rights reserved. Use of this data is subject to the Mapbox Terms of Service (https://www.mapbox.com/about/maps/). This response and the information it contains may not be retained. POI(s) provided by Foursquare.\",\n            \"search_text\": \"Roncalli 10963 Kreuzberg Berlin Germany\",\n        },\n        \"captions_json\": {\n            \"places365\": {\n                \"attributes\": [\n                    \"natural light\",\n                    \"open area\",\n                    \"man made\",\n                    \"clouds\",\n                    \"no horizon\",\n                    \"sunny\",\n                    \"far away horizon\",\n                    \"cloth\",\n                    \"cold\",\n                ],\n                \"categories\": [],\n                \"environment\": \"outdoor\",\n            }\n        },\n        \"search_captions\": \"outdoor\",\n        \"search_location\": \"Roncalli 10963 Kreuzberg Berlin Germany\",\n        \"hidden\": False,\n        \"public\": False,\n        \"video\": False,\n        \"clip_embeddings\": [\n            -0.10258262604475021,\n            0.66643309593200680,\n            -0.06443142890930176,\n            -0.14745938777923584,\n            0.48618343472480774,\n            0.0004399120807647705,\n            -0.0009457916021347046,\n            0.64925503730773930,\n            -0.45721071958541870,\n            0.11818008124828339,\n            -0.11370902508497238,\n            -0.49487552046775820,\n            -0.28128325939178467,\n            -0.30589711666107180,\n            -0.15134385228157043,\n            0.05521773546934128,\n            -1.55117774009704600,\n            -0.16058634221553802,\n            -0.13487541675567627,\n            -0.15009154379367828,\n            -0.34108334779739380,\n            -0.13820111751556396,\n            0.09468824416399002,\n            0.34886407852172850,\n            -0.34096065163612366,\n            -0.02098141610622406,\n            -0.17523168027400970,\n            -0.052726227790117264,\n            0.25815361738204956,\n            -0.45174264907836914,\n            -0.63614970445632930,\n            0.09681782126426697,\n            -0.11660185456275940,\n            -0.18351450562477112,\n            0.41413304209709170,\n            0.14657795429229736,\n            -0.28526934981346130,\n            -0.22788083553314210,\n            -0.21612341701984406,\n            -0.73998987674713130,\n            -0.55964195728302000,\n            -0.22357036173343658,\n            -0.22361208498477936,\n            0.002233438193798065,\n            0.50072944164276120,\n            -1.46298265457153320,\n            0.63237798213958740,\n            0.03941209241747856,\n            0.19379198551177979,\n            -0.44258201122283936,\n            -0.17907603085041046,\n            0.06887031346559525,\n            0.51105743646621700,\n            0.25896197557449340,\n            0.010099773295223713,\n            0.60815042257308960,\n            -0.21678172051906586,\n            0.22954954206943512,\n            -0.71181941032409670,\n            0.17145885527133942,\n            0.79170489311218260,\n            -0.07573703676462173,\n            0.25271376967430115,\n            0.07294708490371704,\n            0.08441808819770813,\n            0.27773401141166687,\n            -0.10114270448684692,\n            1.18037462234497070,\n            -0.10031361877918243,\n            -0.57329255342483520,\n            0.15199229121208190,\n            0.08043078333139420,\n            -0.25711309909820557,\n            0.35362368822097780,\n            0.19381424784660340,\n            0.07242968678474426,\n            -0.30958822369575500,\n            -0.08953941613435745,\n            -0.09282187372446060,\n            -0.37684765458106995,\n            -0.31265074014663696,\n            0.76198458671569820,\n            -0.24638760089874268,\n            -0.18398816883563995,\n            0.20948600769042970,\n            0.68236070871353150,\n            0.26739034056663513,\n            -0.19240638613700867,\n            -0.31598865985870360,\n            -0.029354378581047058,\n            0.008327040821313858,\n            0.18494442105293274,\n            -7.53693056106567400,\n            0.25613960623741150,\n            0.22370323538780212,\n            -0.03313902020454407,\n            -0.22441212832927704,\n            0.24500671029090880,\n            -0.67216014862060550,\n            -0.63962388038635250,\n            -0.10790157318115234,\n            -0.39841800928115845,\n            0.45670357346534730,\n            0.07113364338874817,\n            -0.28614389896392820,\n            0.35553669929504395,\n            -1.00858056545257570,\n            0.17648085951805115,\n            -0.56130737066268920,\n            0.33491787314414980,\n            0.27355626225471497,\n            0.16742312908172607,\n            0.36952716112136840,\n            -0.32609111070632935,\n            -0.21147111058235168,\n            -0.09825261682271957,\n            -0.47114884853363037,\n            0.60953688621521000,\n            0.38453266024589540,\n            -0.38745057582855225,\n            -0.04564698040485382,\n            0.06692779809236526,\n            0.007835905998945236,\n            0.12590317428112030,\n            0.08536458760499954,\n            -0.22401364147663116,\n            0.15795263648033142,\n            -0.46122899651527405,\n            -0.06393150240182877,\n            0.27560144662857056,\n            0.29461151361465454,\n            -0.22232146561145782,\n            -0.14870893955230713,\n            1.00925290584564200,\n            0.08114692568778992,\n            0.010658219456672668,\n            0.019297055900096893,\n            -0.37022376060485840,\n            -0.60042166709899900,\n            -0.08197715133428574,\n            0.17349836230278015,\n            -0.40405350923538210,\n            0.05475588142871857,\n            0.64725798368453980,\n            -0.19707715511322021,\n            0.10167735815048218,\n            -0.24003769457340240,\n            0.47526404261589050,\n            -0.17396590113639832,\n            0.52764558792114260,\n            -0.30993252992630005,\n            -0.06276136636734009,\n            0.54091024398803710,\n            -0.32085520029067993,\n            0.06873718649148941,\n            -0.79230988025665280,\n            0.12832219898700714,\n            0.27965274453163147,\n            -0.11812235414981842,\n            -0.025007829070091248,\n            -0.05913873016834259,\n            0.009386129677295685,\n            0.74419283866882320,\n            0.53745734691619870,\n            -0.07048669457435608,\n            0.32115909457206726,\n            0.85237395763397220,\n            -0.007245570421218872,\n            -0.20299741625785828,\n            -0.31570893526077270,\n            0.36141934990882874,\n            0.026256147772073746,\n            -0.20647422969341278,\n            0.07242944836616516,\n            -0.39613524079322815,\n            -0.15577512979507446,\n            0.56382685899734500,\n            0.15884301066398620,\n            -0.25236248970031740,\n            0.12900674343109130,\n            0.13617977499961853,\n            -0.38444459438323975,\n            -0.17406651377677917,\n            0.38698321580886840,\n            -0.03219318389892578,\n            0.22693467140197754,\n            0.061593152582645416,\n            -0.21263362467288970,\n            0.13701051473617554,\n            -0.057763803750276566,\n            0.25249591469764710,\n            0.08275345712900162,\n            0.20910006761550903,\n            0.03912349045276642,\n            -0.63464707136154170,\n            -0.30452278256416320,\n            -0.32845041155815125,\n            -0.06514476239681244,\n            0.37253341078758240,\n            0.27116832137107850,\n            0.08592364937067032,\n            0.23760634660720825,\n            -0.28635382652282715,\n            -0.04585602134466171,\n            -0.25701779127120970,\n            -0.18018849194049835,\n            -0.48961794376373290,\n            -0.08290880918502808,\n            -0.33540713787078860,\n            0.07882565259933472,\n            0.39085230231285095,\n            0.05874055624008179,\n            0.057173386216163635,\n            -0.08757130801677704,\n            0.07342680543661118,\n            -0.13899344205856323,\n            -0.08424560725688934,\n            0.19400805234909058,\n            -0.72243881225585940,\n            -0.29955309629440310,\n            0.20382502675056458,\n            0.48866674304008484,\n            0.07971449941396713,\n            -0.03938521072268486,\n            -0.62366402149200440,\n            -0.15463593602180480,\n            0.09072853624820709,\n            0.03131377696990967,\n            0.28323334455490110,\n            -0.16908100247383118,\n            -0.20017117261886597,\n            0.40308171510696410,\n            0.45233413577079773,\n            -0.31968781352043150,\n            -0.14290061593055725,\n            -0.73431730270385740,\n            0.32717624306678770,\n            -0.24733215570449830,\n            0.07128322124481201,\n            -0.48950380086898804,\n            -0.0008872002363204956,\n            -0.07294179499149323,\n            -0.22609096765518188,\n            -0.020967215299606323,\n            -0.07549750804901123,\n            0.20787967741489410,\n            0.36314973235130310,\n            0.12670208513736725,\n            -0.31853759288787840,\n            0.32616245746612550,\n            -0.09472109377384186,\n            -0.027377739548683167,\n            0.014935225248336792,\n            0.20435945689678192,\n            -0.16135276854038239,\n            0.19143480062484740,\n            0.08755904436111450,\n            0.20116862654685974,\n            -0.08494839072227478,\n            -0.22879388928413390,\n            0.22606262564659120,\n            -0.03982345014810562,\n            -0.29927518963813780,\n            0.010079406201839447,\n            0.07509651780128479,\n            -0.25567504763603210,\n            -0.11709715425968170,\n            0.42464250326156616,\n            0.06431484222412110,\n            0.17397393286228180,\n            0.28166836500167847,\n            -0.21547360718250275,\n            -0.06485859304666519,\n            -0.07584954798221588,\n            0.12973758578300476,\n            -0.06666970998048782,\n            -0.19354835152626038,\n            -0.27083098888397217,\n            -0.08537831902503967,\n            -0.24621208012104034,\n            -0.11692193150520325,\n            0.12622721493244170,\n            0.64608305692672730,\n            0.07786636054515839,\n            -0.15758465230464935,\n            0.15170501172542572,\n            0.14211302995681763,\n            -0.31157767772674560,\n            -0.15162092447280884,\n            -0.10263395309448242,\n            0.13374274969100952,\n            -0.35772666335105896,\n            0.16042037308216095,\n            -0.09574175626039505,\n            0.13830652832984924,\n            0.33334568142890930,\n            -0.53578650951385500,\n            0.39855188131332400,\n            -0.37783429026603700,\n            -0.25219041109085083,\n            0.06827493011951447,\n            -0.50635170936584470,\n            -0.13055899739265442,\n            0.050748057663440704,\n            0.14656113088130950,\n            -0.85355234146118160,\n            -0.03550938516855240,\n            0.21138493716716766,\n            0.51412421464920040,\n            -0.16174469888210297,\n            0.06829738616943360,\n            0.38762015104293823,\n            1.00867974758148200,\n            -0.11224977672100067,\n            0.23601248860359192,\n            0.21366406977176666,\n            0.22318972647190094,\n            -0.57790690660476680,\n            -0.20985898375511170,\n            -0.13206046819686890,\n            0.43933963775634766,\n            -0.76506268978118900,\n            -0.33175840973854065,\n            0.35506504774093630,\n            -0.66501140594482420,\n            0.40778365731239320,\n            -0.19959415495395660,\n            -0.28629922866821290,\n            0.27318397164344790,\n            0.37771254777908325,\n            -0.12135715782642365,\n            0.011033750139176846,\n            -0.05133216455578804,\n            -0.06439136713743210,\n            -0.07881140708923340,\n            -0.07496929168701172,\n            0.36653387546539307,\n            -0.11792770028114319,\n            0.33755394816398620,\n            -0.09257731586694717,\n            0.009545769542455673,\n            -0.04660305380821228,\n            0.013396577909588814,\n            0.15035420656204224,\n            -0.61903655529022220,\n            -0.17708726227283478,\n            -0.013831155374646187,\n            0.010356694459915161,\n            0.10595686733722687,\n            -0.04540724307298660,\n            0.0009019002318382263,\n            -0.26157030463218690,\n            0.18670830130577087,\n            -0.059031642973423004,\n            -0.11484433710575104,\n            -0.36731621623039246,\n            0.14339649677276610,\n            1.26058232784271240,\n            -0.015540368854999542,\n            -0.07888511568307877,\n            -0.009377516806125641,\n            -0.15602213144302368,\n            -0.12761303782463074,\n            0.25213027000427246,\n            0.92153555154800420,\n            -0.18542146682739258,\n            -0.21386396884918213,\n            -1.57489061355590820,\n            0.014670997858047485,\n            0.35590082406997680,\n            0.23226591944694520,\n            0.29403144121170044,\n            0.19039350748062134,\n            0.12028850615024567,\n            0.14649519324302673,\n            -0.57438409328460690,\n            1.51094436645507810,\n            -0.06900059431791306,\n            -0.09223778545856476,\n            0.05437563359737396,\n            0.12748995423316956,\n            -0.07933007180690765,\n            -0.81089299917221070,\n            -0.47452667355537415,\n            0.12268605083227158,\n            0.40242570638656616,\n            0.28311154246330260,\n            0.27250385284423830,\n            -0.23757700622081757,\n            0.32199540734291077,\n            -0.31787779927253723,\n            0.24869447946548462,\n            -0.32091945409774780,\n            0.13392969965934753,\n            0.024256711825728416,\n            -0.003996938467025757,\n            0.31115883588790894,\n            -0.50246292352676390,\n            -0.20639395713806152,\n            0.03759238123893738,\n            0.17185325920581818,\n            -0.030085332691669464,\n            0.048362910747528076,\n            -0.62065368890762330,\n            -0.020879443734884262,\n            0.12434196472167969,\n            0.040695954114198685,\n            -0.50741922855377200,\n            0.08081452548503876,\n            0.14095300436019897,\n            -0.15079917013645172,\n            0.14225539565086365,\n            -0.03339875489473343,\n            0.25852018594741820,\n            -0.41237697005271910,\n            0.31924033164978030,\n            -0.25935268402099610,\n            -0.32131356000900270,\n            -0.69814765453338620,\n            -0.22040528059005737,\n            0.05100163817405701,\n            -0.15426829457283020,\n            0.22865183651447296,\n            -0.47168889641761780,\n            -0.31051588058471680,\n            0.048167359083890915,\n            -0.21201044321060180,\n            0.10385461151599884,\n            0.18401621282100677,\n            0.12361292541027069,\n            0.30423548817634580,\n            -0.17449685931205750,\n            0.31314414739608765,\n            0.04636995494365692,\n            -0.41227555274963380,\n            0.045520931482315063,\n            0.53545415401458740,\n            0.13680092990398407,\n            -0.36478853225708010,\n            0.40942415595054626,\n            0.23506653308868408,\n            1.04005861282348630,\n            -0.27912554144859314,\n            0.06439234316349030,\n            0.18784391880035400,\n            -0.17379683256149292,\n            -0.18675222992897034,\n            -0.34193059802055360,\n            -0.38465169072151184,\n            0.34344249963760376,\n            0.23465913534164430,\n            0.032892048358917236,\n            -0.28188407421112060,\n            -0.11934018135070801,\n            0.022270366549491882,\n            0.07610686123371124,\n            0.16484493017196655,\n            0.28013628721237180,\n            -0.06442663818597794,\n            -0.13154838979244232,\n            0.38454037904739380,\n            -0.15238852798938750,\n            -0.34664541482925415,\n            0.06196160987019539,\n            0.21366979181766510,\n            -0.23718816041946410,\n            0.29661375284194946,\n            0.07324250787496567,\n            0.02359824627637863,\n            -0.29029119014739990,\n            -0.04170669615268707,\n            -0.13716265559196472,\n            -0.18890701234340668,\n            -0.71853756904602050,\n            -0.14910481870174408,\n            -0.28566592931747437,\n            0.17772153019905090,\n            0.08817628026008606,\n            -0.034255996346473694,\n            -0.23667897284030914,\n            -0.62015527486801150,\n            0.20481406152248383,\n            0.19941198825836182,\n            -0.11482881009578705,\n            0.006309971213340759,\n            0.42098861932754517,\n            0.03637159615755081,\n            0.42487934231758120,\n            -0.13934084773063660,\n            -0.32979014515876770,\n            0.13939698040485382,\n            0.15597808361053467,\n            0.18135486543178558,\n            -0.73175537586212160,\n            -0.26711130142211914,\n            -0.09930166602134705,\n            -0.30019181966781616,\n            0.27222818136215210,\n            -0.13047066330909730,\n            0.21958050131797790,\n            -0.92940568923950200,\n            0.46761587262153625,\n            0.002422422170639038,\n            0.06002846360206604,\n            -0.07387303560972214,\n            -0.14259713888168335,\n            -0.52521455287933350,\n            0.21675816178321838,\n            -0.26278591156005860,\n            -0.029557587578892708,\n            -0.39842849969863890,\n            -0.21753847599029540,\n        ],\n        \"clip_embeddings_magnitude\": 10.904964447021484,\n        \"aspect_ratio\": 1.33,\n        \"rating\": 0,\n        \"dominant_color\": \"[255, 255, 255]\",\n        \"video_length\": None,\n        \"in_trashcan\": False,\n        \"removed\": False,\n        \"timestamp\": None,\n        \"camera\": \"Pixel 2\",\n        \"digitalZoomRatio\": None,\n        \"focalLength35Equivalent\": None,\n        \"focal_length\": 4.441,\n        \"fstop\": 1.8,\n        \"height\": 3024,\n        \"iso\": None,\n        \"lens\": None,\n        \"shutter_speed\": \"0\",\n        \"size\": 4303517,\n        \"subjectDistance\": 1.356,\n        \"width\": 4032,\n        \"main_file_id\": \"e21b9ade718afdbe931462d837b74d1c1\",\n    },\n    {\n        \"thumbnail_big\": \"thumbnails_big/a5bcc802a708c07c18816d1b480a8b7d1.webp\",\n        \"square_thumbnail\": \"square_thumbnails/a5bcc802a708c07c18816d1b480a8b7d1.webp\",\n        \"square_thumbnail_small\": \"square_thumbnails_small/a5bcc802a708c07c18816d1b480a8b7d1.webp\",\n        \"added_on\": \"2023-06-16 16:30:52.549542 +00:00\",\n        \"exif_gps_lat\": -33.9432611111111,\n        \"exif_gps_lon\": 151.263833333333,\n        \"exif_timestamp\": \"2017-08-17 09:31:53.000000 +00:00\",\n        \"exif_json\": None,\n        \"geolocation_json\": {\n            \"type\": \"FeatureCollection\",\n            \"query\": [151.263833, -33.943261],\n            \"features\": [\n                {\n                    \"id\": \"poi.695784709457\",\n                    \"text\": \"Mahon Rock Pool\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.263303, -33.94292],\n                    \"context\": [\n                        {\n                            \"id\": \"locality.269412878\",\n                            \"text\": \"Maroubra\",\n                            \"wikidata\": \"Q2914843\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpFQTdxRGc\",\n                        },\n                        {\n                            \"id\": \"place.24496142\",\n                            \"text\": \"Sydney\",\n                            \"wikidata\": \"Q3130\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                        },\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [151.263303, -33.94292],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Mahon Rock Pool, Marine Pde., Sydney, New South Wales, Australia\",\n                    \"place_type\": [\"poi\"],\n                    \"properties\": {\n                        \"address\": \"Marine Pde.\",\n                        \"category\": \"swimming pool, pool, swim club\",\n                        \"landmark\": True,\n                        \"foursquare\": \"4b550d36f964a52055d927e3\",\n                    },\n                },\n                {\n                    \"id\": \"locality.269412878\",\n                    \"bbox\": [151.226804557, -33.958002321, 151.292531935, -33.93283624],\n                    \"text\": \"Maroubra\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.2575, -33.9475],\n                    \"context\": [\n                        {\n                            \"id\": \"place.24496142\",\n                            \"text\": \"Sydney\",\n                            \"wikidata\": \"Q3130\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                        },\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\"type\": \"Point\", \"coordinates\": [151.2575, -33.9475]},\n                    \"relevance\": 1,\n                    \"place_name\": \"Maroubra, New South Wales, Australia\",\n                    \"place_type\": [\"locality\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q2914843\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpFQTdxRGc\",\n                    },\n                },\n                {\n                    \"id\": \"place.24496142\",\n                    \"bbox\": [150.520934139, -34.11717528, 151.369884128, -33.562644328],\n                    \"text\": \"Sydney\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.216454, -33.854816],\n                    \"context\": [\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [151.216454, -33.854816],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Sydney, New South Wales, Australia\",\n                    \"place_type\": [\"place\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q3130\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                    },\n                },\n                {\n                    \"id\": \"region.33806\",\n                    \"bbox\": [140.999265, -37.5097258, 159.200456, -28.1370359],\n                    \"text\": \"New South Wales\",\n                    \"type\": \"Feature\",\n                    \"center\": [147.014694071448, -32.168971672412],\n                    \"context\": [\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        }\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [147.014694071448, -32.168971672412],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"New South Wales, Australia\",\n                    \"place_type\": [\"region\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q3224\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                        \"short_code\": \"AU-NSW\",\n                    },\n                },\n                {\n                    \"id\": \"country.8718\",\n                    \"bbox\": [112.8256904, -54.8327658, 159.200456, -9.0436707],\n                    \"text\": \"Australia\",\n                    \"type\": \"Feature\",\n                    \"center\": [134.489562606981, -25.7349684916223],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [134.489562606981, -25.7349684916223],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Australia\",\n                    \"place_type\": [\"country\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q408\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                        \"short_code\": \"au\",\n                    },\n                },\n            ],\n            \"attribution\": \"NOTICE: © 2023 Mapbox and its suppliers. All rights reserved. Use of this data is subject to the Mapbox Terms of Service (https://www.mapbox.com/about/maps/). This response and the information it contains may not be retained. POI(s) provided by Foursquare.\",\n            \"search_text\": \"Mahon Rock Pool Maroubra Sydney New South Wales Australia\",\n        },\n        \"captions_json\": {\n            \"places365\": {\n                \"attributes\": [\n                    \"natural light\",\n                    \"open area\",\n                    \"far away horizon\",\n                    \"natural\",\n                    \"sunny\",\n                    \"boating\",\n                    \"moist\",\n                    \"swimming\",\n                    \"ocean\",\n                ],\n                \"categories\": [\"marsh\", \"tundra\", \"lagoon\"],\n                \"environment\": \"outdoor\",\n            }\n        },\n        \"search_captions\": \"marsh , tundra , lagoon , outdoor\",\n        \"search_location\": \"Mahon Rock Pool Maroubra Sydney New South Wales Australia\",\n        \"hidden\": False,\n        \"public\": False,\n        \"video\": False,\n        \"clip_embeddings\": [\n            0.12998622655868530,\n            0.13258025050163270,\n            -0.058508455753326416,\n            0.22892819344997406,\n            -0.023302078247070312,\n            -0.44510543346405030,\n            0.43837052583694460,\n            0.40075856447219850,\n            0.42637223005294800,\n            -0.18267586827278137,\n            -0.32148724794387820,\n            -0.03853927552700043,\n            -0.059241898357868195,\n            -0.12733995914459229,\n            -0.09387104958295822,\n            -0.43112272024154663,\n            -1.19327056407928470,\n            -0.014609143137931824,\n            0.21503102779388428,\n            -0.13053396344184875,\n            0.28765681385993960,\n            0.52494764328002930,\n            0.42401337623596190,\n            0.011837676167488098,\n            0.09145589917898178,\n            0.22389806807041168,\n            -0.036226868629455566,\n            0.09836312383413315,\n            -0.10595801472663880,\n            0.20653992891311646,\n            0.26259601116180420,\n            -0.021937429904937744,\n            -0.37160769104957580,\n            0.06919395923614502,\n            0.17206385731697083,\n            0.05610951408743858,\n            -0.25814598798751830,\n            0.25112861394882200,\n            0.13220319151878357,\n            0.22801768779754640,\n            -0.28782862424850464,\n            0.52349138259887700,\n            0.11140240728855133,\n            0.21639062464237213,\n            0.16601549088954926,\n            -1.26960456371307370,\n            0.03704804554581642,\n            0.15477925539016724,\n            0.32343593239784240,\n            0.05724513530731201,\n            0.23438473045825958,\n            0.34855538606643677,\n            0.30527845025062560,\n            0.047757647931575775,\n            -0.14104431867599487,\n            0.28336334228515625,\n            0.12284316122531891,\n            0.09858018159866333,\n            -0.64525383710861210,\n            -0.28189745545387270,\n            -0.53113412857055660,\n            -0.02064698189496994,\n            -0.029455430805683136,\n            0.31310641765594480,\n            -0.01327805221080780,\n            -0.05426792800426483,\n            -0.12057886272668839,\n            1.52634048461914060,\n            -0.23908194899559020,\n            0.03377249091863632,\n            -0.31758821010589600,\n            0.003850087523460388,\n            -0.44408404827117920,\n            -0.58076667785644530,\n            0.022277936339378357,\n            0.21131259202957153,\n            -0.17391380667686462,\n            -0.28281235694885254,\n            0.19357018172740936,\n            -0.047077856957912445,\n            -0.022741809487342834,\n            0.22117993235588074,\n            -0.06992056965827942,\n            0.18571671843528748,\n            0.06705777347087860,\n            0.24204009771347046,\n            0.11218206584453583,\n            -0.03229340910911560,\n            0.20023432374000550,\n            -0.08615788817405700,\n            0.29021203517913820,\n            0.11631032079458237,\n            -6.34464693069458000,\n            -0.37301033735275270,\n            -0.08067938685417175,\n            0.23319250345230103,\n            -0.11491980403661728,\n            -0.22198167443275452,\n            -0.23849581182003020,\n            -1.53279995918273930,\n            0.25409278273582460,\n            -0.37143617868423460,\n            0.12711408734321594,\n            0.18552611768245697,\n            0.13125161826610565,\n            -0.03438793122768402,\n            0.12672650814056396,\n            -0.30418828129768370,\n            0.05400300398468971,\n            0.22966152429580688,\n            0.10130165517330170,\n            -0.18591997027397156,\n            0.16613908112049103,\n            0.08785743266344070,\n            -0.09753852337598800,\n            -0.19177854061126710,\n            -0.54351925849914550,\n            -0.029669322073459625,\n            0.008011367172002792,\n            0.06446681916713715,\n            0.23291164636611938,\n            -0.78047883510589600,\n            -0.35974910855293274,\n            -0.57393378019332890,\n            0.005923539400100708,\n            -0.07117588073015213,\n            0.13031338155269623,\n            -0.43962341547012330,\n            -0.75585317611694340,\n            0.27976679801940920,\n            -0.005890890955924988,\n            0.16367265582084656,\n            0.010072827339172363,\n            0.86911255121231080,\n            -0.50223058462142940,\n            0.09843732416629791,\n            -0.03996431455016136,\n            -0.25243335962295530,\n            0.011986486613750458,\n            0.07025257498025894,\n            -0.15618172287940980,\n            -0.13723298907279968,\n            0.14785155653953552,\n            -0.07476790249347687,\n            0.011668689548969269,\n            0.15818738937377930,\n            -0.22708828747272491,\n            1.15767204761505130,\n            -0.24034819006919860,\n            0.06645347177982330,\n            0.03723381087183952,\n            0.13653486967086792,\n            -0.91293966770172120,\n            0.061923325061798096,\n            0.020089279860258102,\n            -0.19603200256824493,\n            0.54111683368682860,\n            0.08232687413692474,\n            -0.20884832739830017,\n            0.34021350741386414,\n            -0.87326097488403320,\n            -0.27245533466339110,\n            0.30318510532379150,\n            0.30751353502273560,\n            0.25488168001174927,\n            -0.19319717586040497,\n            1.10999667644500730,\n            0.32085287570953370,\n            0.19622525572776794,\n            -0.29783028364181520,\n            -0.21281230449676514,\n            0.002480059862136841,\n            0.16586032509803772,\n            0.16510939598083496,\n            -0.25083601474761963,\n            0.01780378818511963,\n            -0.23946386575698853,\n            0.26920223236083984,\n            -0.60473775863647460,\n            0.71488064527511600,\n            0.90620148181915280,\n            -0.11498169600963593,\n            -0.11332808434963226,\n            0.29238957166671753,\n            -0.28703141212463380,\n            -0.08944821357727051,\n            0.33648857474327090,\n            -0.27088621258735657,\n            0.056357257068157196,\n            0.06953722238540650,\n            -0.12937217950820923,\n            -0.34909275174140930,\n            0.0033053942024707794,\n            0.36867564916610720,\n            0.02737647294998169,\n            -0.40904921293258667,\n            0.15042823553085327,\n            -0.23831282556056976,\n            1.08098745346069340,\n            -0.14632245898246765,\n            -0.10184669494628906,\n            -0.45605412125587463,\n            -0.26751014590263367,\n            -0.19427978992462158,\n            -0.07291503250598907,\n            -0.23394347727298737,\n            -0.39399421215057373,\n            0.21800534427165985,\n            0.08165672421455383,\n            0.31118094921112060,\n            -0.03596162423491478,\n            0.56992232799530030,\n            -0.25306987762451170,\n            -0.013975441455841064,\n            -0.13325847685337067,\n            0.19483233988285065,\n            0.10027952492237091,\n            0.57050353288650510,\n            0.07020722329616547,\n            -0.30141943693161010,\n            0.10414308309555054,\n            0.39053773880004883,\n            0.13395856320858002,\n            0.06220602989196777,\n            -0.39614889025688170,\n            0.021074017509818077,\n            -0.27187311649322510,\n            0.50517594814300540,\n            0.14774698019027710,\n            0.35505199432373047,\n            -0.014332786202430725,\n            0.16666226089000702,\n            0.16399480402469635,\n            0.09917092323303223,\n            0.35650873184204100,\n            -1.40380215644836430,\n            -0.026044342666864395,\n            -0.19442640244960785,\n            -0.24114309251308440,\n            0.058344028890132904,\n            0.12207233905792236,\n            -0.16192662715911865,\n            0.09302338957786560,\n            0.49859148263931274,\n            -0.14120854437351227,\n            -0.05409412086009979,\n            -0.21078500151634216,\n            0.32789957523345950,\n            0.17087182402610780,\n            0.03868192434310913,\n            -0.58889353275299070,\n            0.18010029196739197,\n            0.04883924126625061,\n            0.26769936084747314,\n            -0.23523572087287903,\n            0.26934176683425903,\n            -0.23642563819885254,\n            0.12310450524091720,\n            0.89556938409805300,\n            -0.06872670352458954,\n            -0.04263883829116821,\n            0.17438516020774840,\n            0.25022634863853455,\n            -0.06414982676506042,\n            0.17062856256961823,\n            -0.67373126745224000,\n            0.33244323730468750,\n            -0.11403056979179382,\n            -0.28086948394775390,\n            -0.023052534088492393,\n            0.10679151117801666,\n            0.13404421508312225,\n            0.12281967699527740,\n            -0.48176917433738710,\n            -0.50348985195159910,\n            0.19038720428943634,\n            0.06314077228307724,\n            0.12262192368507385,\n            -0.35703650116920470,\n            0.24681422114372253,\n            -0.07084030658006668,\n            0.13866774737834930,\n            0.020786508917808533,\n            0.48234757781028750,\n            0.18154349923133850,\n            0.24811989068984985,\n            -0.47986146807670593,\n            -0.26600340008735657,\n            -0.79610115289688110,\n            -0.44987118244171140,\n            0.03849541395902634,\n            -0.26184004545211790,\n            0.42152988910675050,\n            -0.30942755937576294,\n            0.14844314754009247,\n            -0.12019725143909454,\n            1.18861508369445800,\n            0.02964150905609131,\n            -0.38321954011917114,\n            -0.17666542530059814,\n            -0.24750977754592896,\n            -0.18742603063583374,\n            0.23742599785327910,\n            -0.13876700401306152,\n            0.53921639919281010,\n            0.28352069854736330,\n            0.13867411017417908,\n            -0.07083784788846970,\n            0.53155958652496340,\n            -0.008131757378578186,\n            -0.20227709412574768,\n            0.08842383325099945,\n            0.86737281084060670,\n            -0.29989498853683470,\n            0.09684963524341583,\n            -0.031620755791664124,\n            0.58049958944320680,\n            0.11073039472103119,\n            -0.25317376852035520,\n            0.56119495630264280,\n            0.34039902687072754,\n            1.21799755096435550,\n            -0.021552175283432007,\n            -0.15119931101799010,\n            -0.054917752742767334,\n            0.80401933193206790,\n            0.010184556245803833,\n            0.06551001965999603,\n            0.19289986789226532,\n            -0.21603244543075562,\n            0.025988996028900146,\n            -0.65018039941787720,\n            0.50120383501052860,\n            -0.26873117685317993,\n            0.19879785180091858,\n            -0.26082211732864380,\n            0.46192330121994020,\n            -0.55225759744644170,\n            0.01180829107761383,\n            0.05070953071117401,\n            -0.67910110950469970,\n            -0.04475890100002289,\n            0.36456465721130370,\n            0.13348321616649628,\n            -0.01747957617044449,\n            -0.13040468096733093,\n            -0.16885089874267578,\n            0.33254384994506836,\n            -0.29955965280532837,\n            -0.26608777046203613,\n            -0.10515909641981125,\n            -0.32794988155364990,\n            -0.05360160768032074,\n            -0.21728354692459106,\n            -0.39240026473999023,\n            0.33821657299995420,\n            -0.008709043264389038,\n            -0.43232405185699463,\n            0.63957405090332030,\n            0.35856172442436220,\n            0.45638042688369750,\n            -0.13032492995262146,\n            0.46423870325088500,\n            0.29733401536941530,\n            0.21055299043655396,\n            0.33571115136146545,\n            0.27673989534378050,\n            -1.99598908424377440,\n            -0.43283250927925110,\n            0.27975231409072876,\n            -0.27850526571273804,\n            0.48566606640815735,\n            0.14831756055355072,\n            0.12112566083669662,\n            0.37299168109893800,\n            -0.36536252498626710,\n            0.51137733459472660,\n            0.26767280697822570,\n            -0.35320448875427246,\n            0.13971213996410370,\n            0.01589038036763668,\n            -0.30416059494018555,\n            -0.35622701048851013,\n            -0.29882031679153440,\n            -0.0055990517139434814,\n            -0.13705015182495117,\n            0.56266194581985470,\n            -0.38767093420028687,\n            -0.53967058658599850,\n            0.06725452840328217,\n            -1.21143281459808350,\n            0.22121112048625946,\n            0.43655356764793396,\n            0.33551204204559326,\n            -0.27671134471893310,\n            -0.57174313068389890,\n            -0.058216191828250885,\n            0.06912264972925186,\n            -0.85613191127777100,\n            0.19724097847938538,\n            0.16659013926982880,\n            0.14675720036029816,\n            0.24287590384483337,\n            -0.25835406780242920,\n            0.20815043151378632,\n            -0.42670011520385740,\n            0.39319023489952090,\n            0.32358986139297485,\n            0.025813337415456772,\n            -0.048157237470149994,\n            -0.35826218128204346,\n            0.04717639088630676,\n            0.30715617537498474,\n            -0.25429004430770874,\n            -0.10547082126140594,\n            0.02966500073671341,\n            0.30983048677444460,\n            -0.21249084174633026,\n            -0.19436784088611603,\n            0.77192759513854980,\n            -0.53250586986541750,\n            0.12887206673622130,\n            0.32633990049362180,\n            -1.24625861644744870,\n            -0.16218033432960510,\n            -0.13100118935108185,\n            -0.11232861876487732,\n            0.62874174118041990,\n            -0.02767990529537201,\n            -0.05274036154150963,\n            -0.53563487529754640,\n            -0.64365452527999880,\n            -0.05103348195552826,\n            0.08929728716611862,\n            -0.40029305219650270,\n            -0.10031442344188690,\n            0.31065917015075684,\n            -0.21383905410766602,\n            0.14335599541664124,\n            -0.09037648141384125,\n            -0.17714662849903107,\n            0.33824056386947630,\n            -0.82754230499267580,\n            0.16252911090850830,\n            -0.07532244920730591,\n            -0.14527970552444458,\n            -0.22860543429851532,\n            -0.30257803201675415,\n            0.047085925936698914,\n            0.17316375672817230,\n            0.13065533339977264,\n            -0.17600929737091064,\n            0.13690154254436493,\n            -0.20214480161666870,\n            0.01934470236301422,\n            0.12377672642469406,\n            -0.06723305583000183,\n            0.22328042984008790,\n            -0.49700859189033510,\n            -0.34658029675483704,\n            0.20576259493827820,\n            -0.27207553386688230,\n            -0.22085019946098328,\n            -0.38435065746307373,\n            0.44076967239379883,\n            0.22373658418655396,\n            -0.046322643756866455,\n            0.18001255393028260,\n            0.15259465575218200,\n            0.17097039520740510,\n            -0.23130619525909424,\n            0.29231351613998413,\n            -0.37276223301887510,\n            -0.16314452886581420,\n            0.08766435086727142,\n            -0.41216766834259033,\n            -0.21740865707397460,\n            -0.05558343976736069,\n            -0.41705510020256040,\n            -0.020109981298446655,\n            -0.36541312932968140,\n            -0.38398233056068420,\n            0.39583081007003784,\n            0.13429638743400574,\n            -0.41582357883453370,\n            0.08929545432329178,\n            0.37230169773101807,\n            -0.10493122041225433,\n            -0.21501919627189636,\n            -0.18377599120140076,\n            0.017690163105726242,\n            -0.10214073956012726,\n            0.50946366786956790,\n            -0.30789539217948914,\n            -0.52317154407501220,\n            -0.20865441858768463,\n            0.15374669432640076,\n            0.21395552158355713,\n            -0.42342543601989746,\n            0.50638461112976070,\n            0.33827340602874756,\n            0.05084151029586792,\n            0.004318922758102417,\n            0.18277740478515625,\n            0.33151501417160034,\n            -0.54885160923004150,\n            0.30941945314407350,\n            0.16642820835113525,\n            -0.52071034908294680,\n            -0.20474663376808167,\n            -0.01535847783088684,\n            0.11818096041679382,\n        ],\n        \"clip_embeddings_magnitude\": 10.422953605651855,\n        \"aspect_ratio\": 1.33,\n        \"rating\": 0,\n        \"dominant_color\": \"[105, 144, 187]\",\n        \"video_length\": None,\n        \"in_trashcan\": False,\n        \"removed\": False,\n        \"timestamp\": None,\n        \"camera\": \"Pixel 2\",\n        \"digitalZoomRatio\": None,\n        \"focalLength35Equivalent\": None,\n        \"focal_length\": 4.442,\n        \"fstop\": 1.8,\n        \"height\": 3024,\n        \"iso\": None,\n        \"lens\": None,\n        \"shutter_speed\": \"0\",\n        \"size\": 6467070,\n        \"subjectDistance\": 2.229,\n        \"width\": 4032,\n        \"main_file_id\": \"a5bcc802a708c07c18816d1b480a8b7d1\",\n    },\n    {\n        \"thumbnail_big\": \"thumbnails_big/57dca8933514df1efafddf76004187cc1.webp\",\n        \"square_thumbnail\": \"square_thumbnails/57dca8933514df1efafddf76004187cc1.webp\",\n        \"square_thumbnail_small\": \"square_thumbnails_small/57dca8933514df1efafddf76004187cc1.webp\",\n        \"added_on\": \"2023-06-16 16:30:56.866303 +00:00\",\n        \"exif_gps_lat\": -33.9417083333333,\n        \"exif_gps_lon\": 151.265258333333,\n        \"exif_timestamp\": \"2017-08-17 10:32:47.000000 +00:00\",\n        \"exif_json\": None,\n        \"geolocation_json\": {\n            \"type\": \"FeatureCollection\",\n            \"query\": [151.265258, -33.941708],\n            \"features\": [\n                {\n                    \"id\": \"poi.721554610670\",\n                    \"text\": \"Mistral Point\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.26523, -33.941658],\n                    \"context\": [\n                        {\n                            \"id\": \"postcode.503310\",\n                            \"text\": \"2035\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpCNjRP\",\n                        },\n                        {\n                            \"id\": \"locality.269412878\",\n                            \"text\": \"Maroubra\",\n                            \"wikidata\": \"Q2914843\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpFQTdxRGc\",\n                        },\n                        {\n                            \"id\": \"place.24496142\",\n                            \"text\": \"Sydney\",\n                            \"wikidata\": \"Q3130\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                        },\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [151.26523, -33.941658],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Mistral Point, Sydney, New South Wales 2035, Australia\",\n                    \"place_type\": [\"poi\"],\n                    \"properties\": {\n                        \"category\": \"historic site, historic\",\n                        \"landmark\": True,\n                        \"foursquare\": \"5958b8724420d86b1808f7bd\",\n                    },\n                },\n                {\n                    \"id\": \"postcode.503310\",\n                    \"bbox\": [151.205214, -33.958015, 151.265693, -33.931801],\n                    \"text\": \"2035\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.242438, -33.942906],\n                    \"context\": [\n                        {\n                            \"id\": \"locality.269412878\",\n                            \"text\": \"Maroubra\",\n                            \"wikidata\": \"Q2914843\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpFQTdxRGc\",\n                        },\n                        {\n                            \"id\": \"place.24496142\",\n                            \"text\": \"Sydney\",\n                            \"wikidata\": \"Q3130\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                        },\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [151.242438, -33.942906],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"2035, Maroubra, New South Wales, Australia\",\n                    \"place_type\": [\"postcode\"],\n                    \"properties\": {\"mapbox_id\": \"dXJuOm1ieHBsYzpCNjRP\"},\n                },\n                {\n                    \"id\": \"locality.269412878\",\n                    \"bbox\": [151.226804557, -33.958002321, 151.292531935, -33.93283624],\n                    \"text\": \"Maroubra\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.2575, -33.9475],\n                    \"context\": [\n                        {\n                            \"id\": \"place.24496142\",\n                            \"text\": \"Sydney\",\n                            \"wikidata\": \"Q3130\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                        },\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\"type\": \"Point\", \"coordinates\": [151.2575, -33.9475]},\n                    \"relevance\": 1,\n                    \"place_name\": \"Maroubra, New South Wales, Australia\",\n                    \"place_type\": [\"locality\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q2914843\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpFQTdxRGc\",\n                    },\n                },\n                {\n                    \"id\": \"place.24496142\",\n                    \"bbox\": [150.520934139, -34.11717528, 151.369884128, -33.562644328],\n                    \"text\": \"Sydney\",\n                    \"type\": \"Feature\",\n                    \"center\": [151.216454, -33.854816],\n                    \"context\": [\n                        {\n                            \"id\": \"region.33806\",\n                            \"text\": \"New South Wales\",\n                            \"wikidata\": \"Q3224\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                            \"short_code\": \"AU-NSW\",\n                        },\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        },\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [151.216454, -33.854816],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Sydney, New South Wales, Australia\",\n                    \"place_type\": [\"place\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q3130\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                    },\n                },\n                {\n                    \"id\": \"region.33806\",\n                    \"bbox\": [140.999265, -37.5097258, 159.200456, -28.1370359],\n                    \"text\": \"New South Wales\",\n                    \"type\": \"Feature\",\n                    \"center\": [147.014694071448, -32.168971672412],\n                    \"context\": [\n                        {\n                            \"id\": \"country.8718\",\n                            \"text\": \"Australia\",\n                            \"wikidata\": \"Q408\",\n                            \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                            \"short_code\": \"au\",\n                        }\n                    ],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [147.014694071448, -32.168971672412],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"New South Wales, Australia\",\n                    \"place_type\": [\"region\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q3224\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                        \"short_code\": \"AU-NSW\",\n                    },\n                },\n                {\n                    \"id\": \"country.8718\",\n                    \"bbox\": [112.8256904, -54.8327658, 159.200456, -9.0436707],\n                    \"text\": \"Australia\",\n                    \"type\": \"Feature\",\n                    \"center\": [134.489562606981, -25.7349684916223],\n                    \"geometry\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [134.489562606981, -25.7349684916223],\n                    },\n                    \"relevance\": 1,\n                    \"place_name\": \"Australia\",\n                    \"place_type\": [\"country\"],\n                    \"properties\": {\n                        \"wikidata\": \"Q408\",\n                        \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                        \"short_code\": \"au\",\n                    },\n                },\n            ],\n            \"attribution\": \"NOTICE: © 2023 Mapbox and its suppliers. All rights reserved. Use of this data is subject to the Mapbox Terms of Service (https://www.mapbox.com/about/maps/). This response and the information it contains may not be retained. POI(s) provided by Foursquare.\",\n            \"search_text\": \"Mistral Point 2035 Maroubra Sydney New South Wales Australia\",\n        },\n        \"captions_json\": {\n            \"places365\": {\n                \"attributes\": [\n                    \"open area\",\n                    \"natural light\",\n                    \"far away horizon\",\n                    \"sunny\",\n                    \"rugged scene\",\n                    \"dirt\",\n                    \"natural\",\n                    \"dry\",\n                    \"clouds\",\n                ],\n                \"categories\": [],\n                \"environment\": \"outdoor\",\n            }\n        },\n        \"search_captions\": \"outdoor\",\n        \"search_location\": \"Mistral Point 2035 Maroubra Sydney New South Wales Australia\",\n        \"hidden\": False,\n        \"public\": False,\n        \"video\": False,\n        \"clip_embeddings\": [\n            -0.36666020750999450,\n            -0.04985070228576660,\n            -0.55112200975418090,\n            -0.02282443828880787,\n            0.07433946430683136,\n            -0.15077446401119232,\n            -0.15087568759918213,\n            0.59861290454864500,\n            0.74118041992187500,\n            0.10699653625488281,\n            0.09809046983718872,\n            -0.03829161822795868,\n            0.18715769052505493,\n            -0.19874982535839080,\n            -0.17219249904155730,\n            -0.31639525294303894,\n            -1.24799549579620360,\n            -0.18328364193439484,\n            0.50648951530456540,\n            0.03295477479696274,\n            0.05792248249053955,\n            -0.023687392473220825,\n            0.28321290016174316,\n            -0.24516502022743225,\n            -0.14424744248390198,\n            0.39068019390106200,\n            0.07994443178176880,\n            -0.12718169391155243,\n            0.19193711876869202,\n            -0.01991894096136093,\n            -0.29576098918914795,\n            0.08526518195867538,\n            -0.38953876495361330,\n            -0.11270611733198166,\n            -0.12309470027685165,\n            -0.18142205476760864,\n            -0.22653748095035553,\n            0.25507912039756775,\n            0.52982282638549800,\n            -0.36807811260223390,\n            -0.52154517173767090,\n            -0.040144920349121094,\n            0.40785139799118040,\n            -0.30712696909904480,\n            0.22090484201908112,\n            -2.02335238456726070,\n            0.12097204476594925,\n            0.28021758794784546,\n            0.09183792769908905,\n            0.15624284744262695,\n            0.01185154914855957,\n            0.22275637090206146,\n            0.55167198181152340,\n            -0.35499316453933716,\n            0.17891690135002136,\n            0.05891851335763931,\n            0.11313964426517487,\n            -0.24982094764709473,\n            -0.44442304968833923,\n            -0.30212301015853880,\n            -0.49696791172027590,\n            -0.21229097247123718,\n            -0.08969736099243164,\n            0.35271137952804565,\n            -0.13295263051986694,\n            -0.24579304456710815,\n            -0.15918856859207153,\n            1.22507643699646000,\n            -0.47429224848747253,\n            -0.14982843399047852,\n            -0.19515639543533325,\n            -0.18184053897857666,\n            -0.10420016944408417,\n            -0.27105495333671570,\n            -0.014386080205440521,\n            -0.19864511489868164,\n            -0.59518003463745120,\n            0.014985889196395874,\n            -0.06511993706226349,\n            -0.31187006831169130,\n            0.06655596941709518,\n            0.46725121140480040,\n            -0.15730988979339600,\n            0.21003738045692444,\n            0.06053004041314125,\n            0.18053501844406128,\n            0.043053969740867615,\n            -0.38710883259773254,\n            -0.16783890128135680,\n            -0.15172357857227325,\n            0.09899577498435974,\n            -0.08344091475009918,\n            -6.26640892028808600,\n            0.62121075391769410,\n            0.35546118021011350,\n            0.30253031849861145,\n            -0.35551121830940247,\n            -0.04457452893257141,\n            -0.18457348644733430,\n            -1.32966053485870360,\n            0.17469060420989990,\n            -0.33178430795669556,\n            0.08215680718421936,\n            0.06592759490013123,\n            -0.25799661874771120,\n            0.05711726099252701,\n            -0.18006426095962524,\n            -0.13938568532466888,\n            0.35842615365982056,\n            -0.07131820172071457,\n            0.20586800575256348,\n            -0.10146003961563110,\n            -0.034379392862319946,\n            0.09676564484834671,\n            -0.39100694656372070,\n            -0.19553266465663910,\n            -0.39932283759117126,\n            0.14101047813892365,\n            0.24242374300956726,\n            -0.14971837401390076,\n            0.19587853550910950,\n            -0.07761697471141815,\n            -0.13720758259296417,\n            -0.036738086491823196,\n            0.02775958925485611,\n            0.003398708999156952,\n            0.32153791189193726,\n            -0.29471915960311890,\n            -0.67542463541030880,\n            0.15028291940689087,\n            -0.043420448899269104,\n            -0.19056166708469390,\n            0.029275819659233093,\n            0.85994541645050050,\n            -0.41359940171241760,\n            -0.00037150830030441284,\n            0.21370775997638702,\n            0.10217175632715225,\n            -0.15029510855674744,\n            0.15498718619346620,\n            0.14254488050937653,\n            -0.12856674194335938,\n            -0.12141317129135132,\n            -0.21967242658138275,\n            -0.22536158561706543,\n            0.40473008155822754,\n            -0.29137128591537476,\n            1.18561625480651860,\n            -0.30933171510696410,\n            -0.04900578409433365,\n            -0.22565384209156036,\n            0.026661649346351624,\n            -0.53087574243545530,\n            -0.02157217264175415,\n            0.61312061548233030,\n            -0.68817430734634400,\n            0.55277901887893680,\n            -0.02949278987944126,\n            -0.35691511631011963,\n            0.17237737774848938,\n            -0.84646338224411010,\n            0.28129857778549194,\n            -0.001386452466249466,\n            0.13001629710197450,\n            -0.34739860892295840,\n            -0.10144264996051788,\n            0.64412295818328860,\n            0.38456526398658750,\n            0.23173999786376953,\n            -0.17515747249126434,\n            0.020819609984755516,\n            -0.32927608489990234,\n            0.08890204876661300,\n            -0.15161202847957610,\n            -0.18559980392456055,\n            -0.04398374259471893,\n            -0.27180469036102295,\n            0.11402454972267151,\n            -0.30309695005416870,\n            0.71401089429855350,\n            0.58197832107543950,\n            0.09439059346914291,\n            -0.11577179282903671,\n            0.33646234869956970,\n            -0.22551852464675903,\n            0.13584175705909730,\n            0.21372529864311218,\n            -0.58664178848266600,\n            0.24065378308296204,\n            0.34127143025398254,\n            -0.17579154670238495,\n            -0.33026531338691710,\n            0.15201087296009064,\n            0.36881905794143677,\n            -0.50844454765319820,\n            -0.48041981458663940,\n            0.003377307206392288,\n            -0.21604734659194946,\n            1.22471618652343750,\n            -0.34609639644622800,\n            -0.07732010632753372,\n            0.19707258045673370,\n            0.03305445611476898,\n            -0.25127333402633667,\n            0.02786330133676529,\n            -0.13784091174602509,\n            -0.33934032917022705,\n            0.11644668132066727,\n            0.12870812416076660,\n            0.28808489441871643,\n            -0.060060858726501465,\n            0.05354182422161102,\n            -0.18899542093276978,\n            0.08026752620935440,\n            0.14663285017013550,\n            -0.10402667522430420,\n            -0.04774125665426254,\n            0.78730720281600950,\n            0.14746253192424774,\n            -0.08459271490573883,\n            -0.32546862959861755,\n            0.32204133272171020,\n            0.08961439132690430,\n            0.05263507366180420,\n            -0.65238183736801150,\n            0.03257568180561066,\n            -0.52708566188812260,\n            0.38349610567092896,\n            0.15065722167491913,\n            -0.003457099199295044,\n            0.05167556554079056,\n            -0.36230301856994630,\n            0.13933974504470825,\n            0.31344616413116455,\n            0.32701274752616880,\n            -1.06047749519348140,\n            0.03538133203983307,\n            0.014965444803237915,\n            0.14803899824619293,\n            0.08960707485675812,\n            -0.25507515668869020,\n            -0.26133170723915100,\n            0.22333431243896484,\n            0.38788953423500060,\n            -0.26071867346763610,\n            0.34007954597473145,\n            0.05404023081064224,\n            0.24960926175117493,\n            0.35853916406631470,\n            -0.24324321746826172,\n            -0.25807657837867737,\n            0.15211161971092224,\n            0.03374246507883072,\n            0.09933222830295563,\n            0.14579382538795470,\n            0.09634265303611755,\n            -0.15618667006492615,\n            -0.020942021161317825,\n            1.40221035480499270,\n            -0.02753184735774994,\n            -0.018054869025945663,\n            0.15185260772705078,\n            0.36876207590103150,\n            -0.07963977754116058,\n            0.21388293802738190,\n            -0.57031232118606570,\n            0.16593083739280700,\n            -0.12364573776721954,\n            -0.19139841198921204,\n            -0.07277653366327286,\n            -0.07489527761936188,\n            0.06829735636711120,\n            -0.09270213544368744,\n            -0.58580970764160160,\n            -0.57999855279922490,\n            0.10248814523220062,\n            0.07077595591545105,\n            -0.11604766547679901,\n            -0.10343541949987411,\n            0.09502948075532913,\n            0.07064349204301834,\n            0.31945759057998660,\n            0.26488274335861206,\n            -0.11844316124916077,\n            0.10092554986476898,\n            0.05796369910240173,\n            0.007035255432128906,\n            -0.20717875659465790,\n            -0.39001202583312990,\n            -0.23531873524188995,\n            0.029121950268745422,\n            -0.31792175769805910,\n            0.21073228120803833,\n            -0.25988331437110900,\n            0.01320233941078186,\n            -0.04228278994560242,\n            0.87615823745727540,\n            0.23483057320117950,\n            -0.14261351525783540,\n            -0.03390306234359741,\n            0.05797864496707916,\n            -0.08534366637468338,\n            0.60571086406707760,\n            -0.40570425987243650,\n            0.39349102973937990,\n            -0.0029862821102142334,\n            0.21820589900016785,\n            0.17360526323318481,\n            0.51648765802383420,\n            0.10162493586540222,\n            -0.09098815917968750,\n            0.04716239869594574,\n            0.85775423049926760,\n            -0.11656519025564194,\n            0.23683629930019380,\n            0.07459381222724915,\n            0.55665183067321780,\n            -0.03265659511089325,\n            -0.03649625927209854,\n            0.85287141799926760,\n            0.35186266899108887,\n            0.036931782960891724,\n            -0.26376897096633910,\n            -0.45744919776916504,\n            -0.09645552933216095,\n            0.66262829303741460,\n            -0.10217510163784027,\n            0.08431930840015411,\n            -0.050179481506347656,\n            -0.12746801972389220,\n            -0.37214517593383790,\n            -0.06164243817329407,\n            0.26156315207481384,\n            -0.14883297681808472,\n            0.16589079797267914,\n            -0.35074496269226074,\n            0.08279553055763245,\n            -0.13153877854347230,\n            0.16450478136539460,\n            -0.13764882087707520,\n            -0.20287595689296722,\n            -0.046635668724775314,\n            0.12855112552642822,\n            0.24670921266078950,\n            -0.08000148832798004,\n            -0.09557560086250305,\n            -0.25575160980224610,\n            0.15930658578872680,\n            -0.67159068584442140,\n            -0.07150696963071823,\n            0.17778603732585907,\n            0.25455826520919800,\n            -0.23019903898239136,\n            -0.08271875232458115,\n            -0.10421910881996155,\n            0.55327570438385010,\n            0.17031423747539520,\n            -0.99103516340255740,\n            0.73087739944458010,\n            0.33844703435897827,\n            0.61791002750396730,\n            -0.018104268237948418,\n            0.42206788063049316,\n            -0.07971519231796265,\n            0.06754805147647858,\n            0.18134978413581848,\n            0.07568907737731934,\n            -1.62685477733612060,\n            -0.38066196441650390,\n            0.66141670942306520,\n            -0.25981235504150390,\n            0.55625867843627930,\n            0.51772624254226680,\n            0.24509307742118835,\n            -0.20850536227226257,\n            -0.16368716955184937,\n            0.76303339004516600,\n            -0.0018448233604431152,\n            -0.05956418812274933,\n            -0.13241642713546753,\n            0.005002804100513458,\n            -0.28848242759704590,\n            -0.44567650556564330,\n            -0.09337581694126129,\n            0.34333360195159910,\n            0.18656188249588013,\n            0.39478647708892820,\n            0.22161819040775300,\n            -0.41303259134292600,\n            -0.42747217416763306,\n            -1.40258526802062990,\n            0.58988147974014280,\n            0.41526830196380615,\n            0.22585415840148926,\n            -0.047290802001953125,\n            -0.40906193852424620,\n            0.13364250957965850,\n            0.36895215511322020,\n            -0.32853290438652040,\n            0.018866248428821564,\n            0.021997720003128052,\n            0.33384290337562560,\n            0.12766578793525696,\n            -0.25208842754364014,\n            0.29037922620773315,\n            -0.26148244738578796,\n            0.03055279701948166,\n            -0.048704586923122406,\n            -0.019998198375105858,\n            -0.22977681457996368,\n            0.18012699484825134,\n            0.05622486397624016,\n            0.22875103354454040,\n            -0.09111046046018600,\n            -0.21855254471302032,\n            -0.021557403728365898,\n            -0.14998503029346466,\n            -0.33238950371742250,\n            -0.44446778297424316,\n            0.64051526784896850,\n            -0.13707451522350310,\n            0.11501492559909820,\n            0.30077239871025085,\n            -1.04448294639587400,\n            -0.75157833099365230,\n            -0.31508189439773560,\n            -0.18117314577102660,\n            0.04961024224758148,\n            0.020732292905449867,\n            0.51410740613937380,\n            -0.44715842604637146,\n            -0.59480583667755130,\n            0.25802063941955566,\n            -0.005115009844303131,\n            -0.66136705875396730,\n            -0.37843626737594604,\n            0.39065134525299070,\n            -0.16299697756767273,\n            -0.08189993351697922,\n            0.07605299353599548,\n            0.15921586751937866,\n            0.55074018239974980,\n            -0.66295832395553590,\n            0.12967866659164430,\n            0.17624089121818542,\n            -0.23016670346260070,\n            -0.24796481430530548,\n            -0.45518487691879270,\n            -0.11801701784133911,\n            0.07801371812820435,\n            0.02105349302291870,\n            -0.15974965691566467,\n            -0.039424389600753784,\n            -0.56841480731964110,\n            -0.24004876613616943,\n            0.19917711615562440,\n            0.05348219349980354,\n            -0.09810043871402740,\n            -0.06265527009963989,\n            -0.0035811271518468857,\n            0.44162738323211670,\n            -0.20175644755363464,\n            0.08200869709253311,\n            -0.12142806500196457,\n            0.46088671684265137,\n            0.12649869918823242,\n            -0.39095637202262880,\n            0.16196821630001068,\n            0.42821982502937317,\n            0.53625297546386720,\n            -0.10519468784332275,\n            0.06364251673221588,\n            -0.43340182304382324,\n            -0.27793857455253600,\n            0.07457497715950012,\n            -0.17316223680973053,\n            -0.13105086982250214,\n            -0.11595485359430313,\n            -0.43414521217346190,\n            0.013297945261001587,\n            -0.03564862906932831,\n            -0.04748070240020752,\n            0.43650120496749880,\n            0.06940247118473053,\n            -0.04091569781303406,\n            -0.42648598551750183,\n            0.44560009241104126,\n            -0.27538335323333740,\n            -0.24719163775444030,\n            -0.39051297307014465,\n            0.10344080626964569,\n            -0.05663326010107994,\n            0.26151871681213380,\n            -0.47625967860221863,\n            0.16123828291893005,\n            -0.36013549566268920,\n            0.011993957683444023,\n            0.20137929916381836,\n            -0.14949794113636017,\n            0.36271947622299194,\n            0.36080580949783325,\n            0.16761866211891174,\n            0.12525813281536102,\n            0.41298842430114746,\n            -0.17537128925323486,\n            -0.43780979514122010,\n            0.31959301233291626,\n            0.56603193283081050,\n            -1.01032650470733640,\n            -0.23371657729148865,\n            -0.31553673744201660,\n            -0.010249286890029907,\n        ],\n        \"clip_embeddings_magnitude\": 10.27176284790039,\n        \"aspect_ratio\": 1.33,\n        \"rating\": 0,\n        \"dominant_color\": \"[152, 188, 233]\",\n        \"video_length\": None,\n        \"in_trashcan\": False,\n        \"removed\": False,\n        \"timestamp\": None,\n        \"camera\": \"Pixel 2\",\n        \"digitalZoomRatio\": None,\n        \"focalLength35Equivalent\": None,\n        \"focal_length\": 4.442,\n        \"fstop\": 1.8,\n        \"height\": 3024,\n        \"iso\": None,\n        \"lens\": None,\n        \"shutter_speed\": \"0\",\n        \"size\": 8068575,\n        \"subjectDistance\": 3.289,\n        \"width\": 4032,\n        \"main_file_id\": \"57dca8933514df1efafddf76004187cc1\",\n    },\n]\n"
  },
  {
    "path": "api/tests/fixtures/api_util/sunburst_expectation.py",
    "content": "expectation = {\n    \"name\": \"Places I've visited\",\n    \"children\": [\n        {\n            \"name\": \"Australia\",\n            \"children\": [\n                {\n                    \"name\": \"New South Wales\",\n                    \"children\": [{\"name\": \"Sydney\", \"value\": 4, \"hex\": \"#57d3db\"}],\n                    \"hex\": \"#b9db57\",\n                }\n            ],\n            \"hex\": \"#57d3db\",\n        },\n        {\n            \"name\": \"Canada\",\n            \"children\": [\n                {\n                    \"name\": \"Ontario\",\n                    \"children\": [\n                        {\"name\": \"Peterborough County\", \"value\": 1, \"hex\": \"#c957db\"}\n                    ],\n                    \"hex\": \"#dbae57\",\n                }\n            ],\n            \"hex\": \"#dbae57\",\n        },\n        {\n            \"name\": \"Germany\",\n            \"children\": [\n                {\n                    \"name\": \"Berlin\",\n                    \"children\": [\n                        {\"name\": \"Friedrichshain\", \"value\": 1, \"hex\": \"#db5f57\"},\n                        {\"name\": \"Kreuzberg\", \"value\": 1, \"hex\": \"#69db57\"},\n                    ],\n                    \"hex\": \"#db579e\",\n                }\n            ],\n            \"hex\": \"#57d3db\",\n        },\n        {\n            \"name\": \"India\",\n            \"children\": [\n                {\n                    \"name\": \"Ladakh\",\n                    \"children\": [{\"name\": \"Leh\", \"value\": 2, \"hex\": \"#c957db\"}],\n                    \"hex\": \"#5784db\",\n                }\n            ],\n            \"hex\": \"#db579e\",\n        },\n    ],\n}\n"
  },
  {
    "path": "api/tests/fixtures/geocode/__init__.py",
    "content": ""
  },
  {
    "path": "api/tests/fixtures/geocode/expectations/mapbox.py",
    "content": "from api.geocode import GEOCODE_VERSION\n\nexpectations = [\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Beach Road\", \"center\": [-33.888012425, 151.275216500055]},\n            {\"text\": \"Bondi Beach\", \"center\": [-33.888012425, 151.275216500055]},\n            {\"text\": \"Sydney\", \"center\": [-33.888012425, 151.275216500055]},\n            {\"text\": \"New South Wales\", \"center\": [-33.888012425, 151.275216500055]},\n            {\"text\": \"Australia\", \"center\": [-33.888012425, 151.275216500055]},\n        ],\n        \"places\": [\n            \"Beach Road\",\n            \"Bondi Beach\",\n            \"Sydney\",\n            \"New South Wales\",\n            \"Australia\",\n        ],\n        \"address\": \"17 Beach Road, Bondi Beach New South Wales 2026, Australia\",\n        \"center\": [-33.888012425, 151.275216500055],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Fire Route 47\", \"center\": [44.5540639, -78.1955566]},\n            {\"text\": \"Lakefield\", \"center\": [44.5540639, -78.1955566]},\n            {\"text\": \"Peterborough County\", \"center\": [44.5540639, -78.1955566]},\n            {\"text\": \"Ontario\", \"center\": [44.5540639, -78.1955566]},\n            {\"text\": \"Canada\", \"center\": [44.5540639, -78.1955566]},\n        ],\n        \"places\": [\n            \"Fire Route 47\",\n            \"Lakefield\",\n            \"Peterborough County\",\n            \"Ontario\",\n            \"Canada\",\n        ],\n        \"address\": \"2880 Fire Route 47, Lakefield, Ontario K0L 2H0, Canada\",\n        \"center\": [44.5540639, -78.1955566],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Roncalli\", \"center\": [52.501711, 13.381502]},\n            {\"text\": \"Kreuzberg\", \"center\": [52.501711, 13.381502]},\n            {\"text\": \"Berlin\", \"center\": [52.501711, 13.381502]},\n            {\"text\": \"Germany\", \"center\": [52.501711, 13.381502]},\n        ],\n        \"places\": [\"Roncalli\", \"Kreuzberg\", \"Berlin\", \"Germany\"],\n        \"address\": \"Roncalli, Mckernstr, Berlin, 10963, Germany\",\n        \"center\": [52.501711, 13.381502],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Lakeshore Road\", \"center\": [33.913368523661, 78.45710763764029]},\n            {\"text\": \"Shachokol\", \"center\": [33.913368523661, 78.45710763764029]},\n            {\"text\": \"Leh\", \"center\": [33.913368523661, 78.45710763764029]},\n            {\"text\": \"Leh\", \"center\": [33.913368523661, 78.45710763764029]},\n            {\"text\": \"Ladakh\", \"center\": [33.913368523661, 78.45710763764029]},\n            {\"text\": \"India\", \"center\": [33.913368523661, 78.45710763764029]},\n        ],\n        \"places\": [\n            \"Lakeshore Road\",\n            \"Shachokol\",\n            \"Leh\",\n            \"Leh\",\n            \"Ladakh\",\n            \"India\",\n        ],\n        \"address\": \"Lakeshore Road ، 194201 Leh، India\",\n        \"center\": [33.913368523661, 78.45710763764029],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Mahon Rock Pool\", \"center\": [-33.94292, 151.263303]},\n            {\"text\": \"Maroubra\", \"center\": [-33.94292, 151.263303]},\n            {\"text\": \"Sydney\", \"center\": [-33.94292, 151.263303]},\n            {\"text\": \"New South Wales\", \"center\": [-33.94292, 151.263303]},\n            {\"text\": \"Australia\", \"center\": [-33.94292, 151.263303]},\n        ],\n        \"places\": [\n            \"Mahon Rock Pool\",\n            \"Maroubra\",\n            \"Sydney\",\n            \"New South Wales\",\n            \"Australia\",\n        ],\n        \"address\": \"Mahon Rock Pool, Marine Pde., Sydney, New South Wales, Australia\",\n        \"center\": [-33.94292, 151.263303],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Main Bazaar\", \"center\": [34.1622089, 77.585527]},\n            {\"text\": \"Chuchat Yakma\", \"center\": [34.1622089, 77.585527]},\n            {\"text\": \"Leh\", \"center\": [34.1622089, 77.585527]},\n            {\"text\": \"Leh\", \"center\": [34.1622089, 77.585527]},\n            {\"text\": \"Ladakh\", \"center\": [34.1622089, 77.585527]},\n            {\"text\": \"India\", \"center\": [34.1622089, 77.585527]},\n        ],\n        \"places\": [\n            \"Main Bazaar\",\n            \"Chuchat Yakma\",\n            \"Leh\",\n            \"Leh\",\n            \"Ladakh\",\n            \"India\",\n        ],\n        \"address\": \"Main Bazaar ، 194101 Leh، India\",\n        \"center\": [34.1622089, 77.585527],\n    },\n]\n"
  },
  {
    "path": "api/tests/fixtures/geocode/expectations/nominatim.py",
    "content": "from api.geocode import GEOCODE_VERSION\n\nexpectations = [\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Beach Road\", \"center\": [-33.88801645, 151.27521180010973]},\n            {\"text\": \"Seven Ways\", \"center\": [-33.88801645, 151.27521180010973]},\n            {\"text\": \"Bondi Beach\", \"center\": [-33.88801645, 151.27521180010973]},\n            {\"text\": \"Eastern Suburbs\", \"center\": [-33.88801645, 151.27521180010973]},\n            {\"text\": \"Sydney\", \"center\": [-33.88801645, 151.27521180010973]},\n            {\"text\": \"New South Wales\", \"center\": [-33.88801645, 151.27521180010973]},\n            {\"text\": \"Australia\", \"center\": [-33.88801645, 151.27521180010973]},\n        ],\n        \"places\": [\n            \"Beach Road\",\n            \"Seven Ways\",\n            \"Bondi Beach\",\n            \"Eastern Suburbs\",\n            \"Sydney\",\n            \"New South Wales\",\n            \"Australia\",\n        ],\n        \"address\": \"17, Beach Road, Seven Ways, Bondi Beach, Eastern Suburbs, Sydney, Waverley Council, New South Wales, 2026, Australia\",\n        \"center\": [-33.88801645, 151.27521180010973],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\n                \"text\": \"Fire Route 47\",\n                \"center\": [44.55373905094425, -78.19571323702382],\n            },\n            {\"text\": \"Selwyn\", \"center\": [44.55373905094425, -78.19571323702382]},\n            {\n                \"text\": \"Peterborough County\",\n                \"center\": [44.55373905094425, -78.19571323702382],\n            },\n            {\"text\": \"Ontario\", \"center\": [44.55373905094425, -78.19571323702382]},\n            {\"text\": \"Canada\", \"center\": [44.55373905094425, -78.19571323702382]},\n        ],\n        \"places\": [\n            \"Fire Route 47\",\n            \"Selwyn\",\n            \"Peterborough County\",\n            \"Ontario\",\n            \"Canada\",\n        ],\n        \"address\": \"3118, Fire Route 47, Selwyn, Peterborough County, Central Ontario, Ontario, K0L 2C0, Canada\",\n        \"center\": [44.55373905094425, -78.19571323702382],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\n                \"text\": \"Möckernstraße\",\n                \"center\": [52.501606300000006, 13.381190860617572],\n            },\n            {\"text\": \"Kreuzberg\", \"center\": [52.501606300000006, 13.381190860617572]},\n            {\n                \"text\": \"Friedrichshain-Kreuzberg\",\n                \"center\": [52.501606300000006, 13.381190860617572],\n            },\n            {\"text\": \"Berlin\", \"center\": [52.501606300000006, 13.381190860617572]},\n            {\"text\": \"Deutschland\", \"center\": [52.501606300000006, 13.381190860617572]},\n        ],\n        \"places\": [\n            \"Möckernstraße\",\n            \"Kreuzberg\",\n            \"Friedrichshain-Kreuzberg\",\n            \"Berlin\",\n            \"Deutschland\",\n        ],\n        \"address\": \"Tempodrom, 10, Möckernstraße, Kreuzberg, Friedrichshain-Kreuzberg, Berlin, 10963, Deutschland\",\n        \"center\": [52.501606300000006, 13.381190860617572],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Lakeshore road\", \"center\": [33.9132578, 78.4571752]},\n            {\"text\": \"Spangmik\", \"center\": [33.9132578, 78.4571752]},\n            {\"text\": \"Leh Tehsil\", \"center\": [33.9132578, 78.4571752]},\n            {\"text\": \"Ladakh\", \"center\": [33.9132578, 78.4571752]},\n            {\"text\": \"India\", \"center\": [33.9132578, 78.4571752]},\n        ],\n        \"places\": [\n            \"Lakeshore road\",\n            \"Spangmik\",\n            \"Leh Tehsil\",\n            \"Ladakh\",\n            \"India\",\n        ],\n        \"address\": \"Camp Water Mark, Lakeshore road, Spangmik, Leh Tehsil, Leh District, Ladakh, India\",\n        \"center\": [33.9132578, 78.4571752],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Marine Parade\", \"center\": [-33.9430026, 151.26386704076833]},\n            {\"text\": \"Maroubra\", \"center\": [-33.9430026, 151.26386704076833]},\n            {\"text\": \"Eastern Suburbs\", \"center\": [-33.9430026, 151.26386704076833]},\n            {\"text\": \"Sydney\", \"center\": [-33.9430026, 151.26386704076833]},\n            {\"text\": \"New South Wales\", \"center\": [-33.9430026, 151.26386704076833]},\n            {\"text\": \"Australia\", \"center\": [-33.9430026, 151.26386704076833]},\n        ],\n        \"places\": [\n            \"Marine Parade\",\n            \"Maroubra\",\n            \"Eastern Suburbs\",\n            \"Sydney\",\n            \"New South Wales\",\n            \"Australia\",\n        ],\n        \"address\": \"Mahon Pool, Marine Parade, Maroubra, Eastern Suburbs, Sydney, Randwick City Council, New South Wales, 2035, Australia\",\n        \"center\": [-33.9430026, 151.26386704076833],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Main Bazaar\", \"center\": [34.1621176, 77.585862]},\n            {\"text\": \"Leh\", \"center\": [34.1621176, 77.585862]},\n            {\"text\": \"Leh Tehsil\", \"center\": [34.1621176, 77.585862]},\n            {\"text\": \"Ladakh\", \"center\": [34.1621176, 77.585862]},\n            {\"text\": \"India\", \"center\": [34.1621176, 77.585862]},\n        ],\n        \"places\": [\"Main Bazaar\", \"Leh\", \"Leh Tehsil\", \"Ladakh\", \"India\"],\n        \"address\": \"Dry Fruit Market, Main Bazaar, Matsik Chulung, Leh, Leh Tehsil, Leh District, Ladakh, India\",\n        \"center\": [34.1621176, 77.585862],\n    },\n]\n"
  },
  {
    "path": "api/tests/fixtures/geocode/expectations/opencage.py",
    "content": "from api.geocode import GEOCODE_VERSION\n\nexpectations = [\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Beach Road\", \"center\": [-33.8880165, 151.2752118]},\n            {\"text\": \"Bondi Beach\", \"center\": [-33.8880165, 151.2752118]},\n            {\"text\": \"Waverley Council\", \"center\": [-33.8880165, 151.2752118]},\n            {\"text\": \"Eastern Suburbs\", \"center\": [-33.8880165, 151.2752118]},\n            {\"text\": \"New South Wales\", \"center\": [-33.8880165, 151.2752118]},\n            {\"text\": \"Australia\", \"center\": [-33.8880165, 151.2752118]},\n        ],\n        \"places\": [\n            \"Beach Road\",\n            \"Bondi Beach\",\n            \"Waverley Council\",\n            \"Eastern Suburbs\",\n            \"New South Wales\",\n            \"Australia\",\n        ],\n        \"address\": \"17 Beach Road, Bondi Beach NSW 2026, Australia\",\n        \"center\": [-33.8880165, 151.2752118],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Fire Route 47\", \"center\": [44.5537391, -78.1957132]},\n            {\"text\": \"Ontario\", \"center\": [44.5537391, -78.1957132]},\n            {\"text\": \"Peterborough County\", \"center\": [44.5537391, -78.1957132]},\n            {\"text\": \"Canada\", \"center\": [44.5537391, -78.1957132]},\n        ],\n        \"places\": [\"Fire Route 47\", \"Ontario\", \"Peterborough County\", \"Canada\"],\n        \"address\": \"3118 Fire Route 47, Selwyn, ON K0L 2C0, Canada\",\n        \"center\": [44.5537391, -78.1957132],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Tempodrom\", \"center\": [52.5016063, 13.3811909]},\n            {\"text\": \"Möckernstraße\", \"center\": [52.5016063, 13.3811909]},\n            {\"text\": \"Kreuzberg\", \"center\": [52.5016063, 13.3811909]},\n            {\"text\": \"Friedrichshain-Kreuzberg\", \"center\": [52.5016063, 13.3811909]},\n            {\"text\": \"Berlin\", \"center\": [52.5016063, 13.3811909]},\n            {\"text\": \"Germany\", \"center\": [52.5016063, 13.3811909]},\n        ],\n        \"places\": [\n            \"Tempodrom\",\n            \"Möckernstraße\",\n            \"Kreuzberg\",\n            \"Friedrichshain-Kreuzberg\",\n            \"Berlin\",\n            \"Germany\",\n        ],\n        \"address\": \"Tempodrom, Möckernstraße 10, 10963 Berlin, Germany\",\n        \"center\": [52.5016063, 13.3811909],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Camp Water Mark\", \"center\": [33.9132578, 78.4571752]},\n            {\"text\": \"Lakeshore road\", \"center\": [33.9132578, 78.4571752]},\n            {\"text\": \"Spangmik\", \"center\": [33.9132578, 78.4571752]},\n            {\"text\": \"Ladakh\", \"center\": [33.9132578, 78.4571752]},\n            {\"text\": \"Leh Tehsil\", \"center\": [33.9132578, 78.4571752]},\n            {\"text\": \"India\", \"center\": [33.9132578, 78.4571752]},\n        ],\n        \"places\": [\n            \"Camp Water Mark\",\n            \"Lakeshore road\",\n            \"Spangmik\",\n            \"Ladakh\",\n            \"Leh Tehsil\",\n            \"India\",\n        ],\n        \"address\": \"Camp Water Mark, Lakeshore road, Leh district, Spangmik -, Ladakh, India\",\n        \"center\": [33.9132578, 78.4571752],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Mahon Pool\", \"center\": [-33.9430026, 151.263867]},\n            {\"text\": \"Marine Parade\", \"center\": [-33.9430026, 151.263867]},\n            {\"text\": \"Maroubra\", \"center\": [-33.9430026, 151.263867]},\n            {\"text\": \"Randwick City Council\", \"center\": [-33.9430026, 151.263867]},\n            {\"text\": \"Eastern Suburbs\", \"center\": [-33.9430026, 151.263867]},\n            {\"text\": \"New South Wales\", \"center\": [-33.9430026, 151.263867]},\n            {\"text\": \"Australia\", \"center\": [-33.9430026, 151.263867]},\n        ],\n        \"places\": [\n            \"Mahon Pool\",\n            \"Marine Parade\",\n            \"Maroubra\",\n            \"Randwick City Council\",\n            \"Eastern Suburbs\",\n            \"New South Wales\",\n            \"Australia\",\n        ],\n        \"address\": \"Mahon Pool, Marine Parade, Maroubra NSW 2035, Australia\",\n        \"center\": [-33.9430026, 151.263867],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Dry Fruit Market\", \"center\": [34.1621176, 77.585862]},\n            {\"text\": \"Main Bazaar\", \"center\": [34.1621176, 77.585862]},\n            {\"text\": \"Ladakh\", \"center\": [34.1621176, 77.585862]},\n            {\"text\": \"Leh Tehsil\", \"center\": [34.1621176, 77.585862]},\n            {\"text\": \"India\", \"center\": [34.1621176, 77.585862]},\n        ],\n        \"places\": [\n            \"Dry Fruit Market\",\n            \"Main Bazaar\",\n            \"Ladakh\",\n            \"Leh Tehsil\",\n            \"India\",\n        ],\n        \"address\": \"Dry Fruit Market, Main Bazaar, Leh district, Leh -, Ladakh, India\",\n        \"center\": [34.1621176, 77.585862],\n    },\n]\n"
  },
  {
    "path": "api/tests/fixtures/geocode/expectations/tomtom.py",
    "content": "from api.geocode import GEOCODE_VERSION\n\nexpectations = [\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Beach Road\", \"center\": [-33.888145, 151.275085]},\n            {\"text\": \"Bondi Beach\", \"center\": [-33.888145, 151.275085]},\n            {\"text\": \"New South Wales\", \"center\": [-33.888145, 151.275085]},\n            {\"text\": \"Sydney\", \"center\": [-33.888145, 151.275085]},\n            {\"text\": \"Australia\", \"center\": [-33.888145, 151.275085]},\n        ],\n        \"places\": [\n            \"Beach Road\",\n            \"Bondi Beach\",\n            \"New South Wales\",\n            \"Sydney\",\n            \"Australia\",\n        ],\n        \"address\": \"17 Beach Road, Bondi Beach, New South Wales, 2026\",\n        \"center\": [-33.888145, 151.275085],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Fire Route 47\", \"center\": [44.553463, -78.195114]},\n            {\"text\": \"Lakefield\", \"center\": [44.553463, -78.195114]},\n            {\"text\": \"Canada\", \"center\": [44.553463, -78.195114]},\n        ],\n        \"places\": [\"Fire Route 47\", \"Lakefield\", \"Canada\"],\n        \"address\": \"2876 Fire Route 47, Lakefield ON K0L 2H0\",\n        \"center\": [44.553463, -78.195114],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Möckernstraße\", \"center\": [52.501957, 13.382298]},\n            {\"text\": \"Kreuzberg\", \"center\": [52.501957, 13.382298]},\n            {\"text\": \"Berlin\", \"center\": [52.501957, 13.382298]},\n            {\"text\": \"Deutschland\", \"center\": [52.501957, 13.382298]},\n        ],\n        \"places\": [\"Möckernstraße\", \"Kreuzberg\", \"Berlin\", \"Deutschland\"],\n        \"address\": \"Möckernstraße 138, 10963 Berlin\",\n        \"center\": [52.501957, 13.382298],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Thiksey\", \"center\": [33.913391, 78.457077]},\n            {\"text\": \"Ladakh\", \"center\": [33.913391, 78.457077]},\n            {\"text\": \"Leh\", \"center\": [33.913391, 78.457077]},\n            {\"text\": \"India\", \"center\": [33.913391, 78.457077]},\n        ],\n        \"places\": [\"Thiksey\", \"Ladakh\", \"Leh\", \"India\"],\n        \"address\": \"Thiksey, Ladakh 194101, Ladakh\",\n        \"center\": [33.913391, 78.457077],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Marine Parade\", \"center\": [-33.943535, 151.26181]},\n            {\"text\": \"Maroubra\", \"center\": [-33.943535, 151.26181]},\n            {\"text\": \"New South Wales\", \"center\": [-33.943535, 151.26181]},\n            {\"text\": \"Sydney\", \"center\": [-33.943535, 151.26181]},\n            {\"text\": \"Australia\", \"center\": [-33.943535, 151.26181]},\n        ],\n        \"places\": [\n            \"Marine Parade\",\n            \"Maroubra\",\n            \"New South Wales\",\n            \"Sydney\",\n            \"Australia\",\n        ],\n        \"address\": \"106 Marine Parade, Maroubra, New South Wales, 2035\",\n        \"center\": [-33.943535, 151.26181],\n    },\n    {\n        \"_v\": GEOCODE_VERSION,\n        \"features\": [\n            {\"text\": \"Leh Ladakh\", \"center\": [34.16209, 77.585808]},\n            {\"text\": \"Ladakh\", \"center\": [34.16209, 77.585808]},\n            {\"text\": \"Leh\", \"center\": [34.16209, 77.585808]},\n            {\"text\": \"India\", \"center\": [34.16209, 77.585808]},\n        ],\n        \"places\": [\"Leh Ladakh\", \"Ladakh\", \"Leh\", \"India\"],\n        \"address\": \"Leh Ladakh, Ladakh 194101, Ladakh\",\n        \"center\": [34.16209, 77.585808],\n    },\n]\n"
  },
  {
    "path": "api/tests/fixtures/geocode/responses/mapbox.py",
    "content": "responses = [\n    {\n        \"id\": \"address.2007486836507642\",\n        \"type\": \"Feature\",\n        \"place_type\": [\"address\"],\n        \"relevance\": 1,\n        \"properties\": {\n            \"accuracy\": \"rooftop\",\n            \"mapbox_id\": \"dXJuOm1ieGFkcjo3NmQ1MTQzZi1kZmEwLTQ4NzAtYTAyNy0zYWY1MGJiZTIxYWU\",\n        },\n        \"text\": \"Beach Road\",\n        \"place_name\": \"17 Beach Road, Bondi Beach New South Wales 2026, Australia\",\n        \"center\": [151.275216500055, -33.888012425],\n        \"geometry\": {\n            \"type\": \"Point\",\n            \"coordinates\": [151.275216500055, -33.888012425],\n        },\n        \"address\": \"17\",\n        \"context\": [\n            {\n                \"id\": \"postcode.429582\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpCbzRP\",\n                \"text\": \"2026\",\n            },\n            {\n                \"id\": \"locality.259156494\",\n                \"wikidata\": \"Q673418\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpEM0pxRGc\",\n                \"text\": \"Bondi Beach\",\n            },\n            {\n                \"id\": \"place.24496142\",\n                \"wikidata\": \"Q3130\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                \"text\": \"Sydney\",\n            },\n            {\n                \"id\": \"region.33806\",\n                \"short_code\": \"AU-NSW\",\n                \"wikidata\": \"Q3224\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                \"text\": \"New South Wales\",\n            },\n            {\n                \"id\": \"country.8718\",\n                \"short_code\": \"au\",\n                \"wikidata\": \"Q408\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                \"text\": \"Australia\",\n            },\n        ],\n    },\n    {\n        \"id\": \"address.1654238032706288\",\n        \"type\": \"Feature\",\n        \"place_type\": [\"address\"],\n        \"relevance\": 1,\n        \"properties\": {\n            \"accuracy\": \"point\",\n            \"mapbox_id\": \"dXJuOm1ieGFkcjoyOTlkZDc4Yy0wYWVlLTQ2ZGUtYmFkOC04ZDA0Y2VhYzNkZGQ\",\n        },\n        \"text\": \"Fire Route 47\",\n        \"place_name\": \"2880 Fire Route 47, Lakefield, Ontario K0L 2H0, Canada\",\n        \"center\": [-78.1955566, 44.5540639],\n        \"geometry\": {\"type\": \"Point\", \"coordinates\": [-78.1955566, 44.5540639]},\n        \"address\": \"2880\",\n        \"context\": [\n            {\n                \"id\": \"postcode.2669825575\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpueUpPSnc\",\n                \"text\": \"K0L 2H0\",\n            },\n            {\n                \"id\": \"place.106145831\",\n                \"wikidata\": \"Q114506323\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpCbE9vSnc\",\n                \"text\": \"Lakefield\",\n            },\n            {\n                \"id\": \"district.1140263\",\n                \"wikidata\": \"Q730542\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpFV1lu\",\n                \"text\": \"Peterborough County\",\n            },\n            {\n                \"id\": \"region.17447\",\n                \"short_code\": \"CA-ON\",\n                \"wikidata\": \"Q1904\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpSQ2M\",\n                \"text\": \"Ontario\",\n            },\n            {\n                \"id\": \"country.8743\",\n                \"short_code\": \"ca\",\n                \"wikidata\": \"Q16\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpJaWM\",\n                \"text\": \"Canada\",\n            },\n        ],\n    },\n    {\n        \"id\": \"poi.1047972024770\",\n        \"type\": \"Feature\",\n        \"place_type\": [\"poi\"],\n        \"relevance\": 1,\n        \"properties\": {\n            \"foursquare\": \"50d22a90e4b0d3035504ae2b\",\n            \"landmark\": True,\n            \"address\": \"Möckernstr\",\n            \"category\": \"circus\",\n            \"maki\": \"theatre\",\n        },\n        \"text\": \"Roncalli\",\n        \"place_name\": \"Roncalli, Mckernstr, Berlin, 10963, Germany\",\n        \"center\": [13.381502, 52.501711],\n        \"geometry\": {\"coordinates\": [13.381502, 52.501711], \"type\": \"Point\"},\n        \"context\": [\n            {\n                \"id\": \"postcode.5541434\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpWSTQ2\",\n                \"text\": \"10963\",\n            },\n            {\n                \"id\": \"locality.181160506\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpDc3hLT2c\",\n                \"text\": \"Kreuzberg\",\n            },\n            {\n                \"id\": \"place.115770\",\n                \"short_code\": \"DE-BE\",\n                \"wikidata\": \"Q64\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpBY1E2\",\n                \"text\": \"Berlin\",\n            },\n            {\n                \"id\": \"country.8762\",\n                \"short_code\": \"de\",\n                \"wikidata\": \"Q183\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpJam8\",\n                \"text\": \"Germany\",\n            },\n        ],\n    },\n    {\n        \"id\": \"address.4021415981422040\",\n        \"type\": \"Feature\",\n        \"place_type\": [\"address\"],\n        \"relevance\": 1,\n        \"properties\": {\"accuracy\": \"street\"},\n        \"text\": \"Lakeshore Road\",\n        \"place_name\": \"Lakeshore Road ، 194201 Leh، India\",\n        \"center\": [78.45710763764029, 33.913368523661],\n        \"geometry\": {\n            \"type\": \"Point\",\n            \"coordinates\": [78.45710763764029, 33.913368523661],\n        },\n        \"context\": [\n            {\n                \"id\": \"postcode.13119083\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzp5QzVy\",\n                \"text\": \"194201\",\n            },\n            {\n                \"id\": \"locality.3711994475\",\n                \"wikidata\": \"Q24912826\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzozVUNLYXc\",\n                \"text\": \"Shachokol\",\n            },\n            {\n                \"id\": \"place.25135211\",\n                \"wikidata\": \"Q230818\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpBWCtJYXc\",\n                \"text\": \"Leh\",\n            },\n            {\n                \"id\": \"district.3196523\",\n                \"wikidata\": \"Q1921210\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpNTVpy\",\n                \"text\": \"Leh\",\n            },\n            {\n                \"id\": \"region.222315\",\n                \"short_code\": \"IN-LA\",\n                \"wikidata\": \"Q200667\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpBMlJy\",\n                \"text\": \"Ladakh\",\n            },\n            {\n                \"id\": \"country.8811\",\n                \"short_code\": \"in\",\n                \"wikidata\": \"Q668\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                \"text\": \"India\",\n            },\n        ],\n    },\n    {\n        \"id\": \"poi.695784709457\",\n        \"type\": \"Feature\",\n        \"place_type\": [\"poi\"],\n        \"relevance\": 1,\n        \"properties\": {\n            \"foursquare\": \"4b550d36f964a52055d927e3\",\n            \"landmark\": True,\n            \"address\": \"Marine Pde.\",\n            \"category\": \"swimming pool, pool, swim club\",\n        },\n        \"text\": \"Mahon Rock Pool\",\n        \"place_name\": \"Mahon Rock Pool, Marine Pde., Sydney, New South Wales, Australia\",\n        \"center\": [151.263303, -33.94292],\n        \"geometry\": {\"coordinates\": [151.263303, -33.94292], \"type\": \"Point\"},\n        \"context\": [\n            {\n                \"id\": \"locality.269412878\",\n                \"wikidata\": \"Q2914843\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpFQTdxRGc\",\n                \"text\": \"Maroubra\",\n            },\n            {\n                \"id\": \"place.24496142\",\n                \"wikidata\": \"Q3130\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpBWFhJRGc\",\n                \"text\": \"Sydney\",\n            },\n            {\n                \"id\": \"region.33806\",\n                \"short_code\": \"AU-NSW\",\n                \"wikidata\": \"Q3224\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpoQTQ\",\n                \"text\": \"New South Wales\",\n            },\n            {\n                \"id\": \"country.8718\",\n                \"short_code\": \"au\",\n                \"wikidata\": \"Q408\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpJZzQ\",\n                \"text\": \"Australia\",\n            },\n        ],\n    },\n    {\n        \"id\": \"address.15438142488176\",\n        \"type\": \"Feature\",\n        \"place_type\": [\"address\"],\n        \"relevance\": 1,\n        \"properties\": {\"accuracy\": \"street\"},\n        \"text\": \"Main Bazaar\",\n        \"place_name\": \"Main Bazaar ، 194101 Leh، India\",\n        \"center\": [77.585527, 34.1622089],\n        \"geometry\": {\"type\": \"Point\", \"coordinates\": [77.585527, 34.1622089]},\n        \"context\": [\n            {\n                \"id\": \"postcode.13053547\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzp4eTVy\",\n                \"text\": \"194101\",\n            },\n            {\n                \"id\": \"locality.997100139\",\n                \"wikidata\": \"Q24909733\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpPMjZLYXc\",\n                \"text\": \"Chuchat Yakma\",\n            },\n            {\n                \"id\": \"place.25135211\",\n                \"wikidata\": \"Q230818\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpBWCtJYXc\",\n                \"text\": \"Leh\",\n            },\n            {\n                \"id\": \"district.3196523\",\n                \"wikidata\": \"Q1921210\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpNTVpy\",\n                \"text\": \"Leh\",\n            },\n            {\n                \"id\": \"region.222315\",\n                \"short_code\": \"IN-LA\",\n                \"wikidata\": \"Q200667\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpBMlJy\",\n                \"text\": \"Ladakh\",\n            },\n            {\n                \"id\": \"country.8811\",\n                \"short_code\": \"in\",\n                \"wikidata\": \"Q668\",\n                \"mapbox_id\": \"dXJuOm1ieHBsYzpJbXM\",\n                \"text\": \"India\",\n            },\n        ],\n    },\n]\n"
  },
  {
    "path": "api/tests/fixtures/geocode/responses/nominatim.py",
    "content": "responses = [\n    {\n        \"osm_type\": \"way\",\n        \"osm_id\": 1078453262,\n        \"lat\": \"-33.88801645\",\n        \"lon\": \"151.27521180010973\",\n        \"display_name\": \"17, Beach Road, Seven Ways, Bondi Beach, Eastern Suburbs, Sydney, Waverley Council, New South Wales, 2026, Australia\",\n        \"address\": {\n            \"house_number\": \"17\",\n            \"road\": \"Beach Road\",\n            \"neighbourhood\": \"Seven Ways\",\n            \"suburb\": \"Bondi Beach\",\n            \"borough\": \"Eastern Suburbs\",\n            \"city\": \"Sydney\",\n            \"municipality\": \"Waverley Council\",\n            \"state\": \"New South Wales\",\n            \"ISO3166-2-lvl4\": \"AU-NSW\",\n            \"postcode\": \"2026\",\n            \"country\": \"Australia\",\n            \"country_code\": \"au\",\n        },\n        \"boundingbox\": [\n            \"-33.88809\",\n            \"-33.8879428\",\n            \"151.2751212\",\n            \"151.2753024\",\n        ],\n    },\n    {\n        \"osm_type\": \"way\",\n        \"osm_id\": 79160787,\n        \"lat\": \"44.55373905094425\",\n        \"lon\": \"-78.19571323702382\",\n        \"display_name\": \"3118, Fire Route 47, Selwyn, Peterborough County, Central Ontario, Ontario, K0L 2C0, Canada\",\n        \"address\": {\n            \"house_number\": \"3118\",\n            \"road\": \"Fire Route 47\",\n            \"city\": \"Selwyn\",\n            \"county\": \"Peterborough County\",\n            \"state_district\": \"Central Ontario\",\n            \"state\": \"Ontario\",\n            \"ISO3166-2-lvl4\": \"CA-ON\",\n            \"postcode\": \"K0L 2C0\",\n            \"country\": \"Canada\",\n            \"country_code\": \"ca\",\n        },\n        \"boundingbox\": [\n            \"44.553689050944\",\n            \"44.553789050944\",\n            \"-78.195763237024\",\n            \"-78.195663237024\",\n        ],\n    },\n    {\n        \"osm_type\": \"relation\",\n        \"osm_id\": 7776056,\n        \"lat\": \"52.501606300000006\",\n        \"lon\": \"13.381190860617572\",\n        \"display_name\": \"Tempodrom, 10, Möckernstraße, Kreuzberg, Friedrichshain-Kreuzberg, Berlin, 10963, Deutschland\",\n        \"address\": {\n            \"building\": \"Tempodrom\",\n            \"house_number\": \"10\",\n            \"road\": \"Möckernstraße\",\n            \"suburb\": \"Kreuzberg\",\n            \"borough\": \"Friedrichshain-Kreuzberg\",\n            \"city\": \"Berlin\",\n            \"ISO3166-2-lvl4\": \"DE-BE\",\n            \"postcode\": \"10963\",\n            \"country\": \"Deutschland\",\n            \"country_code\": \"de\",\n        },\n        \"boundingbox\": [\"52.5010459\", \"52.5022072\", \"13.3803352\", \"13.3820754\"],\n    },\n    {\n        \"osm_type\": \"node\",\n        \"osm_id\": 4362313292,\n        \"lat\": \"33.9132578\",\n        \"lon\": \"78.4571752\",\n        \"display_name\": \"Camp Water Mark, Lakeshore road, Spangmik, Leh Tehsil, Leh District, Ladakh, India\",\n        \"address\": {\n            \"tourism\": \"Camp Water Mark\",\n            \"road\": \"Lakeshore road\",\n            \"hamlet\": \"Spangmik\",\n            \"county\": \"Leh Tehsil\",\n            \"state_district\": \"Leh District\",\n            \"state\": \"Ladakh\",\n            \"ISO3166-2-lvl4\": \"IN-LA\",\n            \"country\": \"India\",\n            \"country_code\": \"in\",\n        },\n        \"boundingbox\": [\"33.9132078\", \"33.9133078\", \"78.4571252\", \"78.4572252\"],\n    },\n    {\n        \"osm_type\": \"way\",\n        \"osm_id\": 5019363,\n        \"lat\": \"-33.9430026\",\n        \"lon\": \"151.26386704076833\",\n        \"display_name\": \"Mahon Pool, Marine Parade, Maroubra, Eastern Suburbs, Sydney, Randwick City Council, New South Wales, 2035, Australia\",\n        \"address\": {\n            \"leisure\": \"Mahon Pool\",\n            \"road\": \"Marine Parade\",\n            \"suburb\": \"Maroubra\",\n            \"borough\": \"Eastern Suburbs\",\n            \"city\": \"Sydney\",\n            \"municipality\": \"Randwick City Council\",\n            \"state\": \"New South Wales\",\n            \"ISO3166-2-lvl4\": \"AU-NSW\",\n            \"postcode\": \"2035\",\n            \"country\": \"Australia\",\n            \"country_code\": \"au\",\n        },\n        \"boundingbox\": [\n            \"-33.9431832\",\n            \"-33.9428627\",\n            \"151.2636802\",\n            \"151.2640019\",\n        ],\n    },\n    {\n        \"osm_type\": \"node\",\n        \"osm_id\": 3748713587,\n        \"lat\": \"34.1621176\",\n        \"lon\": \"77.585862\",\n        \"display_name\": \"Dry Fruit Market, Main Bazaar, Matsik Chulung, Leh, Leh Tehsil, Leh District, Ladakh, India\",\n        \"address\": {\n            \"amenity\": \"Dry Fruit Market\",\n            \"road\": \"Main Bazaar\",\n            \"quarter\": \"Matsik Chulung\",\n            \"town\": \"Leh\",\n            \"county\": \"Leh Tehsil\",\n            \"state_district\": \"Leh District\",\n            \"state\": \"Ladakh\",\n            \"ISO3166-2-lvl4\": \"IN-LA\",\n            \"country\": \"India\",\n            \"country_code\": \"in\",\n        },\n        \"boundingbox\": [\"34.1620676\", \"34.1621676\", \"77.585812\", \"77.585912\"],\n    },\n]\n"
  },
  {
    "path": "api/tests/fixtures/geocode/responses/opencage.py",
    "content": "responses = [\n    {\n        \"annotations\": {\n            \"DMS\": {\n                \"lat\": \"33° 53' 16.85940'' S\",\n                \"lng\": \"151° 16' 30.76248'' E\",\n            },\n            \"MGRS\": \"56HLH4050148921\",\n            \"Maidenhead\": \"QF56pc36av\",\n            \"Mercator\": {\"x\": 16839879.547, \"y\": -3989951.723},\n            \"OSM\": {\n                \"edit_url\": \"https://www.openstreetmap.org/edit?way=1078453262#map=16/-33.88802/151.27521\",\n                \"note_url\": \"https://www.openstreetmap.org/note/new#map=16/-33.88802/151.27521&layers=N\",\n                \"url\": \"https://www.openstreetmap.org/?mlat=-33.88802&mlon=151.27521#map=16/-33.88802/151.27521\",\n            },\n            \"UN_M49\": {\n                \"regions\": {\n                    \"AU\": \"036\",\n                    \"AUSTRALASIA\": \"053\",\n                    \"OCEANIA\": \"009\",\n                    \"WORLD\": \"001\",\n                },\n                \"statistical_groupings\": [\"MEDC\"],\n            },\n            \"callingcode\": 61,\n            \"currency\": {\n                \"alternate_symbols\": [\"A$\"],\n                \"decimal_mark\": \".\",\n                \"disambiguate_symbol\": \"A$\",\n                \"html_entity\": \"$\",\n                \"iso_code\": \"AUD\",\n                \"iso_numeric\": \"036\",\n                \"name\": \"Australian Dollar\",\n                \"smallest_denomination\": 5,\n                \"subunit\": \"Cent\",\n                \"subunit_to_unit\": 100,\n                \"symbol\": \"$\",\n                \"symbol_first\": 1,\n                \"thousands_separator\": \",\",\n            },\n            \"flag\": \"🇦🇺\",\n            \"geohash\": \"r3gx4qg5zzveb1g40rhz\",\n            \"qibla\": 277.46,\n            \"roadinfo\": {\n                \"drive_on\": \"left\",\n                \"road\": \"Beach Road\",\n                \"speed_in\": \"km/h\",\n            },\n            \"sun\": {\n                \"rise\": {\n                    \"apparent\": 1686517020,\n                    \"astronomical\": 1686511680,\n                    \"civil\": 1686515340,\n                    \"nautical\": 1686513480,\n                },\n                \"set\": {\n                    \"apparent\": 1686466320,\n                    \"astronomical\": 1686471660,\n                    \"civil\": 1686467940,\n                    \"nautical\": 1686469800,\n                },\n            },\n            \"timezone\": {\n                \"name\": \"Australia/Sydney\",\n                \"now_in_dst\": 0,\n                \"offset_sec\": 36000,\n                \"offset_string\": \"+1000\",\n                \"short_name\": \"AEST\",\n            },\n            \"what3words\": {\"words\": \"elbow.card.brick\"},\n        },\n        \"bounds\": {\n            \"northeast\": {\"lat\": -33.8879428, \"lng\": 151.2753024},\n            \"southwest\": {\"lat\": -33.88809, \"lng\": 151.2751212},\n        },\n        \"components\": {\n            \"ISO_3166-1_alpha-2\": \"AU\",\n            \"ISO_3166-1_alpha-3\": \"AUS\",\n            \"ISO_3166-2\": [\"AU-NSW\"],\n            \"_category\": \"building\",\n            \"_type\": \"building\",\n            \"borough\": \"Eastern Suburbs\",\n            \"city\": \"Sydney\",\n            \"continent\": \"Oceania\",\n            \"country\": \"Australia\",\n            \"country_code\": \"au\",\n            \"house_number\": \"17\",\n            \"municipality\": \"Waverley Council\",\n            \"neighbourhood\": \"Seven Ways\",\n            \"postcode\": \"2026\",\n            \"road\": \"Beach Road\",\n            \"state\": \"New South Wales\",\n            \"state_code\": \"NSW\",\n            \"suburb\": \"Bondi Beach\",\n        },\n        \"confidence\": 10,\n        \"formatted\": \"17 Beach Road, Bondi Beach NSW 2026, Australia\",\n        \"geometry\": {\"lat\": -33.8880165, \"lng\": 151.2752118},\n    },\n    {\n        \"annotations\": {\n            \"DMS\": {\n                \"lat\": \"44° 33' 13.46076'' N\",\n                \"lng\": \"78° 11' 44.56752'' W\",\n            },\n            \"MGRS\": \"17TQK2273137204\",\n            \"Maidenhead\": \"FN04vn62mv\",\n            \"Mercator\": {\"x\": -8704706.98, \"y\": 5521549.602},\n            \"OSM\": {\n                \"edit_url\": \"https://www.openstreetmap.org/edit?way=79160787#map=17/44.55374/-78.19571\",\n                \"note_url\": \"https://www.openstreetmap.org/note/new#map=17/44.55374/-78.19571&layers=N\",\n                \"url\": \"https://www.openstreetmap.org/?mlat=44.55374&mlon=-78.19571#map=17/44.55374/-78.19571\",\n            },\n            \"UN_M49\": {\n                \"regions\": {\n                    \"AMERICAS\": \"019\",\n                    \"CA\": \"124\",\n                    \"NORTHERN_AMERICA\": \"021\",\n                    \"WORLD\": \"001\",\n                },\n                \"statistical_groupings\": [\"MEDC\"],\n            },\n            \"callingcode\": 1,\n            \"currency\": {\n                \"alternate_symbols\": [\"C$\", \"CAD$\"],\n                \"decimal_mark\": \".\",\n                \"disambiguate_symbol\": \"C$\",\n                \"html_entity\": \"$\",\n                \"iso_code\": \"CAD\",\n                \"iso_numeric\": \"124\",\n                \"name\": \"Canadian Dollar\",\n                \"smallest_denomination\": 5,\n                \"subunit\": \"Cent\",\n                \"subunit_to_unit\": 100,\n                \"symbol\": \"$\",\n                \"symbol_first\": 1,\n                \"thousands_separator\": \",\",\n            },\n            \"flag\": \"🇨🇦\",\n            \"geohash\": \"drbmkwg87ffjtrftdgxt\",\n            \"qibla\": 55.39,\n            \"roadinfo\": {\n                \"drive_on\": \"right\",\n                \"road\": \"Fire Route 47\",\n                \"speed_in\": \"km/h\",\n            },\n            \"sun\": {\n                \"rise\": {\n                    \"apparent\": 1686475740,\n                    \"astronomical\": 1686466920,\n                    \"civil\": 1686473520,\n                    \"nautical\": 1686470640,\n                },\n                \"set\": {\n                    \"apparent\": 1686444960,\n                    \"astronomical\": 1686453840,\n                    \"civil\": 1686447180,\n                    \"nautical\": 1686450060,\n                },\n            },\n            \"timezone\": {\n                \"name\": \"America/Toronto\",\n                \"now_in_dst\": 1,\n                \"offset_sec\": -14400,\n                \"offset_string\": \"-0400\",\n                \"short_name\": \"EDT\",\n            },\n            \"what3words\": {\"words\": \"tables.grits.homage\"},\n        },\n        \"bounds\": {\n            \"northeast\": {\"lat\": 44.5537891, \"lng\": -78.1956632},\n            \"southwest\": {\"lat\": 44.5536891, \"lng\": -78.1957632},\n        },\n        \"components\": {\n            \"ISO_3166-1_alpha-2\": \"CA\",\n            \"ISO_3166-1_alpha-3\": \"CAN\",\n            \"ISO_3166-2\": [\"CA-ON\"],\n            \"_category\": \"building\",\n            \"_type\": \"building\",\n            \"city\": \"Selwyn\",\n            \"continent\": \"North America\",\n            \"country\": \"Canada\",\n            \"country_code\": \"ca\",\n            \"county\": \"Peterborough County\",\n            \"house_number\": \"3118\",\n            \"postcode\": \"K0L 2C0\",\n            \"road\": \"Fire Route 47\",\n            \"state\": \"Ontario\",\n            \"state_code\": \"ON\",\n            \"state_district\": \"Central Ontario\",\n        },\n        \"confidence\": 10,\n        \"formatted\": \"3118 Fire Route 47, Selwyn, ON K0L 2C0, Canada\",\n        \"geometry\": {\"lat\": 44.5537391, \"lng\": -78.1957132},\n    },\n    {\n        \"annotations\": {\n            \"DMS\": {\n                \"lat\": \"52° 30' 5.78268'' N\",\n                \"lng\": \"13° 22' 52.28724'' E\",\n            },\n            \"MGRS\": \"33UUU9011818062\",\n            \"Maidenhead\": \"JO62qm50rj\",\n            \"Mercator\": {\"x\": 1489587.353, \"y\": 6857412.691},\n            \"NUTS\": {\n                \"NUTS0\": {\"code\": \"DE\"},\n                \"NUTS1\": {\"code\": \"DE3\"},\n                \"NUTS2\": {\"code\": \"DE30\"},\n                \"NUTS3\": {\"code\": \"DE300\"},\n            },\n            \"OSM\": {\n                \"edit_url\": \"https://www.openstreetmap.org/edit?relation=7776056#map=17/52.50161/13.38119\",\n                \"note_url\": \"https://www.openstreetmap.org/note/new#map=17/52.50161/13.38119&layers=N\",\n                \"url\": \"https://www.openstreetmap.org/?mlat=52.50161&mlon=13.38119#map=17/52.50161/13.38119\",\n            },\n            \"UN_M49\": {\n                \"regions\": {\n                    \"DE\": \"276\",\n                    \"EUROPE\": \"150\",\n                    \"WESTERN_EUROPE\": \"155\",\n                    \"WORLD\": \"001\",\n                },\n                \"statistical_groupings\": [\"MEDC\"],\n            },\n            \"callingcode\": 49,\n            \"currency\": {\n                \"alternate_symbols\": [],\n                \"decimal_mark\": \",\",\n                \"html_entity\": \"€\",\n                \"iso_code\": \"EUR\",\n                \"iso_numeric\": \"978\",\n                \"name\": \"Euro\",\n                \"smallest_denomination\": 1,\n                \"subunit\": \"Cent\",\n                \"subunit_to_unit\": 100,\n                \"symbol\": \"€\",\n                \"symbol_first\": 0,\n                \"thousands_separator\": \".\",\n            },\n            \"flag\": \"🇩🇪\",\n            \"geohash\": \"u33d8mxuh2g0deydr83r\",\n            \"qibla\": 136.64,\n            \"roadinfo\": {\n                \"drive_on\": \"right\",\n                \"road\": \"Möckernstraße\",\n                \"speed_in\": \"km/h\",\n            },\n            \"sun\": {\n                \"rise\": {\n                    \"apparent\": 1686451500,\n                    \"astronomical\": 0,\n                    \"civil\": 1686448560,\n                    \"nautical\": 1686443820,\n                },\n                \"set\": {\n                    \"apparent\": 1686511620,\n                    \"astronomical\": 0,\n                    \"civil\": 1686514620,\n                    \"nautical\": 1686519420,\n                },\n            },\n            \"timezone\": {\n                \"name\": \"Europe/Berlin\",\n                \"now_in_dst\": 1,\n                \"offset_sec\": 7200,\n                \"offset_string\": \"+0200\",\n                \"short_name\": \"CEST\",\n            },\n            \"what3words\": {\"words\": \"closet.elated.pokers\"},\n            \"wikidata\": \"Q896180\",\n        },\n        \"bounds\": {\n            \"northeast\": {\"lat\": 52.5022072, \"lng\": 13.3820754},\n            \"southwest\": {\"lat\": 52.5010459, \"lng\": 13.3803352},\n        },\n        \"components\": {\n            \"ISO_3166-1_alpha-2\": \"DE\",\n            \"ISO_3166-1_alpha-3\": \"DEU\",\n            \"ISO_3166-2\": [\"DE-BE\"],\n            \"_category\": \"building\",\n            \"_type\": \"building\",\n            \"borough\": \"Friedrichshain-Kreuzberg\",\n            \"building\": \"Tempodrom\",\n            \"city\": \"Berlin\",\n            \"continent\": \"Europe\",\n            \"country\": \"Germany\",\n            \"country_code\": \"de\",\n            \"house_number\": \"10\",\n            \"political_union\": \"European Union\",\n            \"postcode\": \"10963\",\n            \"road\": \"Möckernstraße\",\n            \"state\": \"Berlin\",\n            \"state_code\": \"BE\",\n            \"suburb\": \"Kreuzberg\",\n        },\n        \"confidence\": 10,\n        \"formatted\": \"Tempodrom, Möckernstraße 10, 10963 Berlin, Germany\",\n        \"geometry\": {\"lat\": 52.5016063, \"lng\": 13.3811909},\n    },\n    {\n        \"annotations\": {\n            \"DMS\": {\n                \"lat\": \"33° 54' 47.72808'' N\",\n                \"lng\": \"78° 27' 25.83072'' E\",\n            },\n            \"MGRS\": \"44SKC6490755450\",\n            \"Maidenhead\": \"MM93fv49ue\",\n            \"Mercator\": {\"x\": 8733812.792, \"y\": 3993321.42},\n            \"OSM\": {\n                \"edit_url\": \"https://www.openstreetmap.org/edit?node=4362313292#map=16/33.91326/78.45718\",\n                \"note_url\": \"https://www.openstreetmap.org/note/new#map=16/33.91326/78.45718&layers=N\",\n                \"url\": \"https://www.openstreetmap.org/?mlat=33.91326&mlon=78.45718#map=16/33.91326/78.45718\",\n            },\n            \"UN_M49\": {\n                \"regions\": {\n                    \"ASIA\": \"142\",\n                    \"IN\": \"356\",\n                    \"SOUTHERN_ASIA\": \"034\",\n                    \"WORLD\": \"001\",\n                },\n                \"statistical_groupings\": [\"LEDC\"],\n            },\n            \"callingcode\": 91,\n            \"currency\": {\n                \"alternate_symbols\": [\"Rs\", \"৳\", \"૱\", \"௹\", \"रु\", \"₨\"],\n                \"decimal_mark\": \".\",\n                \"html_entity\": \"&#x20b9;\",\n                \"iso_code\": \"INR\",\n                \"iso_numeric\": \"356\",\n                \"name\": \"Indian Rupee\",\n                \"smallest_denomination\": 50,\n                \"subunit\": \"Paisa\",\n                \"subunit_to_unit\": 100,\n                \"symbol\": \"₹\",\n                \"symbol_first\": 1,\n                \"thousands_separator\": \",\",\n            },\n            \"flag\": \"🇮🇳\",\n            \"geohash\": \"twpbcmdz09qn8nexuv7e\",\n            \"qibla\": 259.99,\n            \"roadinfo\": {\n                \"drive_on\": \"left\",\n                \"road\": \"Lakeshore road\",\n                \"speed_in\": \"km/h\",\n            },\n            \"sun\": {\n                \"rise\": {\n                    \"apparent\": 1686526560,\n                    \"astronomical\": 1686520320,\n                    \"civil\": 1686524820,\n                    \"nautical\": 1686522660,\n                },\n                \"set\": {\n                    \"apparent\": 1686491760,\n                    \"astronomical\": 1686497940,\n                    \"civil\": 1686493500,\n                    \"nautical\": 1686495600,\n                },\n            },\n            \"timezone\": {\n                \"name\": \"Asia/Kolkata\",\n                \"now_in_dst\": 0,\n                \"offset_sec\": 19800,\n                \"offset_string\": \"+0530\",\n                \"short_name\": \"IST\",\n            },\n            \"what3words\": {\"words\": \"forklifts.callers.barcode\"},\n        },\n        \"bounds\": {\n            \"northeast\": {\"lat\": 33.9133078, \"lng\": 78.4572252},\n            \"southwest\": {\"lat\": 33.9132078, \"lng\": 78.4571252},\n        },\n        \"components\": {\n            \"ISO_3166-1_alpha-2\": \"IN\",\n            \"ISO_3166-1_alpha-3\": \"IND\",\n            \"ISO_3166-2\": [\"IN-LA\"],\n            \"_category\": \"outdoors/recreation\",\n            \"_type\": \"camp_site\",\n            \"camp_site\": \"Camp Water Mark\",\n            \"continent\": \"Asia\",\n            \"country\": \"India\",\n            \"country_code\": \"in\",\n            \"county\": \"Leh Tehsil\",\n            \"hamlet\": \"Spangmik\",\n            \"road\": \"Lakeshore road\",\n            \"state\": \"Ladakh\",\n            \"state_district\": \"Leh district\",\n        },\n        \"confidence\": 9,\n        \"formatted\": \"Camp Water Mark, Lakeshore road, Leh district, Spangmik -, Ladakh, India\",\n        \"geometry\": {\"lat\": 33.9132578, \"lng\": 78.4571752},\n    },\n    {\n        \"annotations\": {\n            \"DMS\": {\n                \"lat\": \"33° 56' 34.80936'' S\",\n                \"lng\": \"151° 15' 49.92120'' E\",\n            },\n            \"MGRS\": \"56HLH3955542806\",\n            \"Maidenhead\": \"QF56pb13pq\",\n            \"Mercator\": {\"x\": 16838616.654, \"y\": -3997293.616},\n            \"OSM\": {\n                \"edit_url\": \"https://www.openstreetmap.org/edit?way=5019363#map=16/-33.94300/151.26387\",\n                \"note_url\": \"https://www.openstreetmap.org/note/new#map=16/-33.94300/151.26387&layers=N\",\n                \"url\": \"https://www.openstreetmap.org/?mlat=-33.94300&mlon=151.26387#map=16/-33.94300/151.26387\",\n            },\n            \"UN_M49\": {\n                \"regions\": {\n                    \"AU\": \"036\",\n                    \"AUSTRALASIA\": \"053\",\n                    \"OCEANIA\": \"009\",\n                    \"WORLD\": \"001\",\n                },\n                \"statistical_groupings\": [\"MEDC\"],\n            },\n            \"callingcode\": 61,\n            \"currency\": {\n                \"alternate_symbols\": [\"A$\"],\n                \"decimal_mark\": \".\",\n                \"disambiguate_symbol\": \"A$\",\n                \"html_entity\": \"$\",\n                \"iso_code\": \"AUD\",\n                \"iso_numeric\": \"036\",\n                \"name\": \"Australian Dollar\",\n                \"smallest_denomination\": 5,\n                \"subunit\": \"Cent\",\n                \"subunit_to_unit\": 100,\n                \"symbol\": \"$\",\n                \"symbol_first\": 1,\n                \"thousands_separator\": \",\",\n            },\n            \"flag\": \"🇦🇺\",\n            \"geohash\": \"r3gwfhfgxtdnxsdvq2sq\",\n            \"qibla\": 277.43,\n            \"roadinfo\": {\n                \"drive_on\": \"left\",\n                \"road\": \"Marine Parade\",\n                \"speed_in\": \"km/h\",\n            },\n            \"sun\": {\n                \"rise\": {\n                    \"apparent\": 1686517020,\n                    \"astronomical\": 1686511680,\n                    \"civil\": 1686515340,\n                    \"nautical\": 1686513480,\n                },\n                \"set\": {\n                    \"apparent\": 1686466320,\n                    \"astronomical\": 1686471660,\n                    \"civil\": 1686467940,\n                    \"nautical\": 1686469800,\n                },\n            },\n            \"timezone\": {\n                \"name\": \"Australia/Sydney\",\n                \"now_in_dst\": 0,\n                \"offset_sec\": 36000,\n                \"offset_string\": \"+1000\",\n                \"short_name\": \"AEST\",\n            },\n            \"what3words\": {\"words\": \"entire.juices.effort\"},\n        },\n        \"bounds\": {\n            \"northeast\": {\"lat\": -33.9428627, \"lng\": 151.2640019},\n            \"southwest\": {\"lat\": -33.9431832, \"lng\": 151.2636802},\n        },\n        \"components\": {\n            \"ISO_3166-1_alpha-2\": \"AU\",\n            \"ISO_3166-1_alpha-3\": \"AUS\",\n            \"ISO_3166-2\": [\"AU-NSW\"],\n            \"_category\": \"outdoors/recreation\",\n            \"_type\": \"swimming_pool\",\n            \"borough\": \"Eastern Suburbs\",\n            \"city\": \"Sydney\",\n            \"continent\": \"Oceania\",\n            \"country\": \"Australia\",\n            \"country_code\": \"au\",\n            \"municipality\": \"Randwick City Council\",\n            \"postcode\": \"2035\",\n            \"road\": \"Marine Parade\",\n            \"state\": \"New South Wales\",\n            \"state_code\": \"NSW\",\n            \"suburb\": \"Maroubra\",\n            \"swimming_pool\": \"Mahon Pool\",\n        },\n        \"confidence\": 9,\n        \"formatted\": \"Mahon Pool, Marine Parade, Maroubra NSW 2035, Australia\",\n        \"geometry\": {\"lat\": -33.9430026, \"lng\": 151.263867},\n    },\n    {\n        \"annotations\": {\n            \"DMS\": {\"lat\": \"34° 9' 43.62336'' N\", \"lng\": \"77° 35' 9.10320'' E\"},\n            \"MGRS\": \"43SGT3837483153\",\n            \"Maidenhead\": \"MM84td08hv\",\n            \"Mercator\": {\"x\": 8636818.651, \"y\": 4026598.097},\n            \"OSM\": {\n                \"edit_url\": \"https://www.openstreetmap.org/edit?node=3748713587#map=17/34.16212/77.58586\",\n                \"note_url\": \"https://www.openstreetmap.org/note/new#map=17/34.16212/77.58586&layers=N\",\n                \"url\": \"https://www.openstreetmap.org/?mlat=34.16212&mlon=77.58586#map=17/34.16212/77.58586\",\n            },\n            \"UN_M49\": {\n                \"regions\": {\n                    \"ASIA\": \"142\",\n                    \"IN\": \"356\",\n                    \"SOUTHERN_ASIA\": \"034\",\n                    \"WORLD\": \"001\",\n                },\n                \"statistical_groupings\": [\"LEDC\"],\n            },\n            \"callingcode\": 91,\n            \"currency\": {\n                \"alternate_symbols\": [\"Rs\", \"৳\", \"૱\", \"௹\", \"रु\", \"₨\"],\n                \"decimal_mark\": \".\",\n                \"html_entity\": \"&#x20b9;\",\n                \"iso_code\": \"INR\",\n                \"iso_numeric\": \"356\",\n                \"name\": \"Indian Rupee\",\n                \"smallest_denomination\": 50,\n                \"subunit\": \"Paisa\",\n                \"subunit_to_unit\": 100,\n                \"symbol\": \"₹\",\n                \"symbol_first\": 1,\n                \"thousands_separator\": \",\",\n            },\n            \"flag\": \"🇮🇳\",\n            \"geohash\": \"twp4me02c87c1rep8ec0\",\n            \"qibla\": 258.98,\n            \"roadinfo\": {\n                \"drive_on\": \"left\",\n                \"road\": \"Main Bazaar\",\n                \"speed_in\": \"km/h\",\n            },\n            \"sun\": {\n                \"rise\": {\n                    \"apparent\": 1686526740,\n                    \"astronomical\": 1686520500,\n                    \"civil\": 1686525000,\n                    \"nautical\": 1686522840,\n                },\n                \"set\": {\n                    \"apparent\": 1686492000,\n                    \"astronomical\": 1686498240,\n                    \"civil\": 1686493740,\n                    \"nautical\": 1686495900,\n                },\n            },\n            \"timezone\": {\n                \"name\": \"Asia/Kolkata\",\n                \"now_in_dst\": 0,\n                \"offset_sec\": 19800,\n                \"offset_string\": \"+0530\",\n                \"short_name\": \"IST\",\n            },\n            \"what3words\": {\"words\": \"mascot.muscularity.columns\"},\n        },\n        \"bounds\": {\n            \"northeast\": {\"lat\": 34.1621676, \"lng\": 77.585912},\n            \"southwest\": {\"lat\": 34.1620676, \"lng\": 77.585812},\n        },\n        \"components\": {\n            \"ISO_3166-1_alpha-2\": \"IN\",\n            \"ISO_3166-1_alpha-3\": \"IND\",\n            \"ISO_3166-2\": [\"IN-LA\"],\n            \"_category\": \"commerce\",\n            \"_type\": \"marketplace\",\n            \"continent\": \"Asia\",\n            \"country\": \"India\",\n            \"country_code\": \"in\",\n            \"county\": \"Leh Tehsil\",\n            \"marketplace\": \"Dry Fruit Market\",\n            \"quarter\": \"Matsik Chulung\",\n            \"road\": \"Main Bazaar\",\n            \"state\": \"Ladakh\",\n            \"state_district\": \"Leh district\",\n            \"town\": \"Leh\",\n        },\n        \"confidence\": 9,\n        \"formatted\": \"Dry Fruit Market, Main Bazaar, Leh district, Leh -, Ladakh, India\",\n        \"geometry\": {\"lat\": 34.1621176, \"lng\": 77.585862},\n    },\n]\n"
  },
  {
    "path": "api/tests/fixtures/geocode/responses/tomtom.py",
    "content": "responses = [\n    {\n        \"address\": {\n            \"buildingNumber\": \"17\",\n            \"streetNumber\": \"17\",\n            \"routeNumbers\": [],\n            \"street\": \"Beach Road\",\n            \"streetName\": \"Beach Road\",\n            \"streetNameAndNumber\": \"17 Beach Road\",\n            \"countryCode\": \"AU\",\n            \"countrySubdivision\": \"New South Wales\",\n            \"countrySecondarySubdivision\": \"Sydney\",\n            \"municipality\": \"Sydney\",\n            \"postalCode\": \"2026\",\n            \"municipalitySubdivision\": \"Bondi Beach\",\n            \"country\": \"Australia\",\n            \"countryCodeISO3\": \"AUS\",\n            \"freeformAddress\": \"17 Beach Road, Bondi Beach, New South Wales, 2026\",\n            \"boundingBox\": {\n                \"northEast\": \"-33.886643,151.275635\",\n                \"southWest\": \"-33.888507,151.272843\",\n                \"entity\": \"position\",\n            },\n            \"localName\": \"Bondi Beach\",\n        },\n        \"position\": \"-33.888145,151.275085\",\n    },\n    {\n        \"address\": {\n            \"buildingNumber\": \"2876\",\n            \"streetNumber\": \"2876\",\n            \"routeNumbers\": [],\n            \"street\": \"Fire Route 47\",\n            \"streetName\": \"Fire Route 47\",\n            \"streetNameAndNumber\": \"2876 Fire Route 47\",\n            \"countryCode\": \"CA\",\n            \"countrySubdivision\": \"ON\",\n            \"municipality\": \"Lakefield\",\n            \"postalCode\": \"K0L\",\n            \"country\": \"Canada\",\n            \"countryCodeISO3\": \"CAN\",\n            \"freeformAddress\": \"2876 Fire Route 47, Lakefield ON K0L 2H0\",\n            \"boundingBox\": {\n                \"northEast\": \"44.553471,-78.188317\",\n                \"southWest\": \"44.551181,-78.195226\",\n                \"entity\": \"position\",\n            },\n            \"extendedPostalCode\": \"K0L 2H0\",\n            \"countrySubdivisionName\": \"Ontario\",\n            \"localName\": \"Lakefield\",\n        },\n        \"position\": \"44.553463,-78.195114\",\n    },\n    {\n        \"address\": {\n            \"buildingNumber\": \"138\",\n            \"streetNumber\": \"138\",\n            \"routeNumbers\": [],\n            \"street\": \"Möckernstraße\",\n            \"streetName\": \"Möckernstraße\",\n            \"streetNameAndNumber\": \"Möckernstraße 138\",\n            \"countryCode\": \"DE\",\n            \"countrySubdivision\": \"Berlin\",\n            \"countrySecondarySubdivision\": \"Berlin\",\n            \"municipality\": \"Berlin\",\n            \"postalCode\": \"10963\",\n            \"municipalitySubdivision\": \"Kreuzberg\",\n            \"country\": \"Deutschland\",\n            \"countryCodeISO3\": \"DEU\",\n            \"freeformAddress\": \"Möckernstraße 138, 10963 Berlin\",\n            \"boundingBox\": {\n                \"northEast\": \"52.501909,13.382266\",\n                \"southWest\": \"52.501536,13.382003\",\n                \"entity\": \"position\",\n            },\n            \"localName\": \"Berlin\",\n        },\n        \"position\": \"52.501957,13.382298\",\n    },\n    {\n        \"address\": {\n            \"routeNumbers\": [],\n            \"countryCode\": \"IN\",\n            \"countrySubdivision\": \"Ladakh\",\n            \"countrySecondarySubdivision\": \"Leh\",\n            \"municipality\": \"Ladakh\",\n            \"postalCode\": \"194101\",\n            \"municipalitySubdivision\": \"Thiksey\",\n            \"country\": \"India\",\n            \"countryCodeISO3\": \"IND\",\n            \"freeformAddress\": \"Thiksey, Ladakh 194101, Ladakh\",\n            \"boundingBox\": {\n                \"northEast\": \"33.940281,78.458314\",\n                \"southWest\": \"33.908006,78.440596\",\n                \"entity\": \"position\",\n            },\n            \"localName\": \"Ladakh\",\n        },\n        \"position\": \"33.913391,78.457077\",\n    },\n    {\n        \"address\": {\n            \"buildingNumber\": \"106\",\n            \"streetNumber\": \"106\",\n            \"routeNumbers\": [],\n            \"street\": \"Marine Parade\",\n            \"streetName\": \"Marine Parade\",\n            \"streetNameAndNumber\": \"106 Marine Parade\",\n            \"countryCode\": \"AU\",\n            \"countrySubdivision\": \"New South Wales\",\n            \"countrySecondarySubdivision\": \"Sydney\",\n            \"municipality\": \"Sydney\",\n            \"postalCode\": \"2035\",\n            \"municipalitySubdivision\": \"Maroubra\",\n            \"country\": \"Australia\",\n            \"countryCodeISO3\": \"AUS\",\n            \"freeformAddress\": \"106 Marine Parade, Maroubra, New South Wales, 2035\",\n            \"boundingBox\": {\n                \"northEast\": \"-33.943234,151.262465\",\n                \"southWest\": \"-33.943728,151.262034\",\n                \"entity\": \"position\",\n            },\n            \"localName\": \"Maroubra\",\n        },\n        \"position\": \"-33.943535,151.261810\",\n    },\n    {\n        \"address\": {\n            \"routeNumbers\": [],\n            \"countryCode\": \"IN\",\n            \"countrySubdivision\": \"Ladakh\",\n            \"countrySecondarySubdivision\": \"Leh\",\n            \"municipality\": \"Ladakh\",\n            \"postalCode\": \"194101\",\n            \"municipalitySubdivision\": \"Leh Ladakh\",\n            \"country\": \"India\",\n            \"countryCodeISO3\": \"IND\",\n            \"freeformAddress\": \"Leh Ladakh, Ladakh 194101, Ladakh\",\n            \"boundingBox\": {\n                \"northEast\": \"34.162343,77.585824\",\n                \"southWest\": \"34.162047,77.585727\",\n                \"entity\": \"position\",\n            },\n            \"localName\": \"Ladakh\",\n        },\n        \"position\": \"34.162090,77.585808\",\n    },\n]\n"
  },
  {
    "path": "api/tests/fixtures/location_timeline_test_data.csv",
    "content": "Canada,2020-08-27 02:19:21.000000 +00:00\nCanada,2020-08-27 02:43:55.000000 +00:00\nCanada,2020-08-27 22:24:43.000000 +00:00\nCanada,2020-08-27 23:57:10.000000 +00:00\nGermany,2019-12-14 01:19:03.000000 +00:00\nGermany,2019-12-14 20:10:05.000000 +00:00\nGermany,2019-12-14 21:06:55.000000 +00:00\nGermany,2019-12-14 23:31:11.000000 +00:00\nFrance,2020-12-14 01:12:50.000000 +00:00\nFrance,2020-12-14 01:41:04.000000 +00:00\nFrance,2020-12-14 22:41:32.000000 +00:00\nFrance,2020-12-14 23:40:52.000000 +00:00\nCanada,2021-08-10 00:46:32.000000 +00:00\nCanada,2021-08-10 00:50:40.000000 +00:00\nCanada,2021-08-10 20:47:12.000000 +00:00\nCanada,2021-08-10 22:10:06.000000 +00:00\nFrance,2021-10-20 00:19:37.000000 +00:00\nFrance,2021-10-20 01:23:07.000000 +00:00\nFrance,2021-10-20 21:18:53.000000 +00:00\nFrance,2021-10-20 22:30:05.000000 +00:00\n"
  },
  {
    "path": "api/tests/fixtures/niaz.xmp",
    "content": "<?xpacket begin='﻿' id='W5M0MpCehiHzreSzNTczkc9d'?>\n<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.60'>\n<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>\n\n <rdf:Description rdf:about=''\n  xmlns:Iptc4xmpCore='http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/'>\n  <Iptc4xmpCore:CreatorContactInfo rdf:parseType='Resource'>\n   <Iptc4xmpCore:CiAdrCity>Washington</Iptc4xmpCore:CiAdrCity>\n   <Iptc4xmpCore:CiAdrCtry>USA</Iptc4xmpCore:CiAdrCtry>\n   <Iptc4xmpCore:CiAdrExtadr>NASA Headquarters, 300 E Street, SW </Iptc4xmpCore:CiAdrExtadr>\n   <Iptc4xmpCore:CiAdrPcode>20545</Iptc4xmpCore:CiAdrPcode>\n   <Iptc4xmpCore:CiAdrRegion>DC</Iptc4xmpCore:CiAdrRegion>\n   <Iptc4xmpCore:CiTelWork>202-358-1900</Iptc4xmpCore:CiTelWork>\n   <Iptc4xmpCore:CiUrlWork>http://www.nasa.gov</Iptc4xmpCore:CiUrlWork>\n  </Iptc4xmpCore:CreatorContactInfo>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>\n  <Iptc4xmpExt:Event>\n   <rdf:Alt>\n    <rdf:li xml:lang='x-default'>\"First Man\" Premiere at NASM</rdf:li>\n   </rdf:Alt>\n  </Iptc4xmpExt:Event>\n  <Iptc4xmpExt:PersonInImage>\n   <rdf:Bag>\n    <rdf:li>Niaz Faridani-Rad</rdf:li>\n   </rdf:Bag>\n  </Iptc4xmpExt:PersonInImage>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:MP='http://ns.microsoft.com/photo/1.2/'\n  xmlns:MPRI='http://ns.microsoft.com/photo/1.2/t/RegionInfo#'\n  xmlns:MPReg='http://ns.microsoft.com/photo/1.2/t/Region#'>\n  <MP:RegionInfo rdf:parseType='Resource'>\n   <MPRI:Regions>\n    <rdf:Bag>\n     <rdf:li rdf:parseType='Resource'>\n      <MPReg:PersonDisplayName>Niaz Faridani-Rad</MPReg:PersonDisplayName>\n      <MPReg:Rectangle>0.280277, 0.125, 0.436419, 0.485614</MPReg:Rectangle>\n     </rdf:li>\n    </rdf:Bag>\n   </MPRI:Regions>\n  </MP:RegionInfo>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:MicrosoftPhoto='http://ns.microsoft.com/photo/1.0'>\n  <MicrosoftPhoto:LastKeywordXMP>\n   <rdf:Bag>\n    <rdf:li>First Man</rdf:li>\n    <rdf:li>Washington</rdf:li>\n    <rdf:li>Smithsonian National Air and Space Museum (NASM)</rdf:li>\n    <rdf:li>DC</rdf:li>\n    <rdf:li>Niaz Faridani-Rad</rdf:li>\n   </rdf:Bag>\n  </MicrosoftPhoto:LastKeywordXMP>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:acdsee='http://ns.acdsee.com/iptc/1.0/'>\n  <acdsee:categories>&lt;Categories&gt;&lt;Category Assigned=&quot;1&quot;&gt;First Man&lt;/Category&gt;&lt;Category Assigned=&quot;1&quot;&gt;Washington&lt;/Category&gt;&lt;Category Assigned=&quot;1&quot;&gt;Smithsonian National Air and Space Museum (NASM)&lt;/Category&gt;&lt;Category Assigned=&quot;1&quot;&gt;DC&lt;/Category&gt;&lt;Category Assigned=&quot;1&quot;&gt;Niaz Faridani-Rad&lt;/Category&gt;&lt;/Categories&gt;</acdsee:categories>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:aux='http://ns.adobe.com/exif/1.0/aux/'>\n  <aux:ApproximateFocusDistance>398/100</aux:ApproximateFocusDistance>\n  <aux:ImageNumber>7311</aux:ImageNumber>\n  <aux:Lens>70.0-200.0 mm f/2.8</aux:Lens>\n  <aux:LensID>162</aux:LensID>\n  <aux:LensInfo>700/10 2000/10 28/10 28/10</aux:LensInfo>\n  <aux:SerialNumber>3005331</aux:SerialNumber>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:crs='http://ns.adobe.com/camera-raw-settings/1.0/'>\n  <crs:AlreadyApplied>True</crs:AlreadyApplied>\n  <crs:AutoLateralCA>0</crs:AutoLateralCA>\n  <crs:Blacks2012>0</crs:Blacks2012>\n  <crs:BlueHue>0</crs:BlueHue>\n  <crs:BlueSaturation>0</crs:BlueSaturation>\n  <crs:CameraProfile>Adobe Standard</crs:CameraProfile>\n  <crs:CameraProfileDigest>DC0173EBB7ECE22257A40AD42B5C9460</crs:CameraProfileDigest>\n  <crs:Clarity2012>+12</crs:Clarity2012>\n  <crs:ColorNoiseReduction>25</crs:ColorNoiseReduction>\n  <crs:ColorNoiseReductionDetail>50</crs:ColorNoiseReductionDetail>\n  <crs:ColorNoiseReductionSmoothness>50</crs:ColorNoiseReductionSmoothness>\n  <crs:Contrast2012>0</crs:Contrast2012>\n  <crs:ConvertToGrayscale>False</crs:ConvertToGrayscale>\n  <crs:DefringeGreenAmount>0</crs:DefringeGreenAmount>\n  <crs:DefringeGreenHueHi>60</crs:DefringeGreenHueHi>\n  <crs:DefringeGreenHueLo>40</crs:DefringeGreenHueLo>\n  <crs:DefringePurpleAmount>0</crs:DefringePurpleAmount>\n  <crs:DefringePurpleHueHi>70</crs:DefringePurpleHueHi>\n  <crs:DefringePurpleHueLo>30</crs:DefringePurpleHueLo>\n  <crs:Dehaze>0</crs:Dehaze>\n  <crs:Exposure2012>0.00</crs:Exposure2012>\n  <crs:GrainAmount>0</crs:GrainAmount>\n  <crs:GreenHue>0</crs:GreenHue>\n  <crs:GreenSaturation>0</crs:GreenSaturation>\n  <crs:HasCrop>False</crs:HasCrop>\n  <crs:HasSettings>True</crs:HasSettings>\n  <crs:Highlights2012>0</crs:Highlights2012>\n  <crs:HueAdjustmentAqua>0</crs:HueAdjustmentAqua>\n  <crs:HueAdjustmentBlue>0</crs:HueAdjustmentBlue>\n  <crs:HueAdjustmentGreen>0</crs:HueAdjustmentGreen>\n  <crs:HueAdjustmentMagenta>0</crs:HueAdjustmentMagenta>\n  <crs:HueAdjustmentOrange>0</crs:HueAdjustmentOrange>\n  <crs:HueAdjustmentPurple>0</crs:HueAdjustmentPurple>\n  <crs:HueAdjustmentRed>0</crs:HueAdjustmentRed>\n  <crs:HueAdjustmentYellow>0</crs:HueAdjustmentYellow>\n  <crs:LensManualDistortionAmount>0</crs:LensManualDistortionAmount>\n  <crs:LensProfileEnable>0</crs:LensProfileEnable>\n  <crs:LensProfileSetup>LensDefaults</crs:LensProfileSetup>\n  <crs:Look rdf:parseType='Resource'>\n   <crs:Name/>\n  </crs:Look>\n  <crs:LuminanceAdjustmentAqua>0</crs:LuminanceAdjustmentAqua>\n  <crs:LuminanceAdjustmentBlue>0</crs:LuminanceAdjustmentBlue>\n  <crs:LuminanceAdjustmentGreen>0</crs:LuminanceAdjustmentGreen>\n  <crs:LuminanceAdjustmentMagenta>0</crs:LuminanceAdjustmentMagenta>\n  <crs:LuminanceAdjustmentOrange>0</crs:LuminanceAdjustmentOrange>\n  <crs:LuminanceAdjustmentPurple>0</crs:LuminanceAdjustmentPurple>\n  <crs:LuminanceAdjustmentRed>0</crs:LuminanceAdjustmentRed>\n  <crs:LuminanceAdjustmentYellow>0</crs:LuminanceAdjustmentYellow>\n  <crs:LuminanceSmoothing>0</crs:LuminanceSmoothing>\n  <crs:OverrideLookVignette>False</crs:OverrideLookVignette>\n  <crs:ParametricDarks>0</crs:ParametricDarks>\n  <crs:ParametricHighlightSplit>75</crs:ParametricHighlightSplit>\n  <crs:ParametricHighlights>0</crs:ParametricHighlights>\n  <crs:ParametricLights>0</crs:ParametricLights>\n  <crs:ParametricMidtoneSplit>50</crs:ParametricMidtoneSplit>\n  <crs:ParametricShadowSplit>25</crs:ParametricShadowSplit>\n  <crs:ParametricShadows>0</crs:ParametricShadows>\n  <crs:PerspectiveAspect>0</crs:PerspectiveAspect>\n  <crs:PerspectiveHorizontal>0</crs:PerspectiveHorizontal>\n  <crs:PerspectiveRotate>0.0</crs:PerspectiveRotate>\n  <crs:PerspectiveScale>100</crs:PerspectiveScale>\n  <crs:PerspectiveUpright>0</crs:PerspectiveUpright>\n  <crs:PerspectiveVertical>0</crs:PerspectiveVertical>\n  <crs:PerspectiveX>0.00</crs:PerspectiveX>\n  <crs:PerspectiveY>0.00</crs:PerspectiveY>\n  <crs:PostCropVignetteAmount>0</crs:PostCropVignetteAmount>\n  <crs:ProcessVersion>10.0</crs:ProcessVersion>\n  <crs:RawFileName>_1AG7311.nef</crs:RawFileName>\n  <crs:RedHue>0</crs:RedHue>\n  <crs:RedSaturation>0</crs:RedSaturation>\n  <crs:Saturation>0</crs:Saturation>\n  <crs:SaturationAdjustmentAqua>0</crs:SaturationAdjustmentAqua>\n  <crs:SaturationAdjustmentBlue>0</crs:SaturationAdjustmentBlue>\n  <crs:SaturationAdjustmentGreen>0</crs:SaturationAdjustmentGreen>\n  <crs:SaturationAdjustmentMagenta>0</crs:SaturationAdjustmentMagenta>\n  <crs:SaturationAdjustmentOrange>0</crs:SaturationAdjustmentOrange>\n  <crs:SaturationAdjustmentPurple>0</crs:SaturationAdjustmentPurple>\n  <crs:SaturationAdjustmentRed>0</crs:SaturationAdjustmentRed>\n  <crs:SaturationAdjustmentYellow>0</crs:SaturationAdjustmentYellow>\n  <crs:ShadowTint>0</crs:ShadowTint>\n  <crs:Shadows2012>0</crs:Shadows2012>\n  <crs:SharpenDetail>36</crs:SharpenDetail>\n  <crs:SharpenEdgeMasking>80</crs:SharpenEdgeMasking>\n  <crs:SharpenRadius>+1.0</crs:SharpenRadius>\n  <crs:Sharpness>75</crs:Sharpness>\n  <crs:SplitToningBalance>0</crs:SplitToningBalance>\n  <crs:SplitToningHighlightHue>0</crs:SplitToningHighlightHue>\n  <crs:SplitToningHighlightSaturation>0</crs:SplitToningHighlightSaturation>\n  <crs:SplitToningShadowHue>0</crs:SplitToningShadowHue>\n  <crs:SplitToningShadowSaturation>0</crs:SplitToningShadowSaturation>\n  <crs:Temperature>6650</crs:Temperature>\n  <crs:Tint>-60</crs:Tint>\n  <crs:ToneCurve>\n   <rdf:Seq>\n    <rdf:li>0, 0</rdf:li>\n    <rdf:li>255, 255</rdf:li>\n   </rdf:Seq>\n  </crs:ToneCurve>\n  <crs:ToneCurveBlue>\n   <rdf:Seq>\n    <rdf:li>0, 0</rdf:li>\n    <rdf:li>255, 255</rdf:li>\n   </rdf:Seq>\n  </crs:ToneCurveBlue>\n  <crs:ToneCurveGreen>\n   <rdf:Seq>\n    <rdf:li>0, 0</rdf:li>\n    <rdf:li>255, 255</rdf:li>\n   </rdf:Seq>\n  </crs:ToneCurveGreen>\n  <crs:ToneCurveName>Linear</crs:ToneCurveName>\n  <crs:ToneCurveName2012>Linear</crs:ToneCurveName2012>\n  <crs:ToneCurvePV2012>\n   <rdf:Seq>\n    <rdf:li>0, 0</rdf:li>\n    <rdf:li>255, 255</rdf:li>\n   </rdf:Seq>\n  </crs:ToneCurvePV2012>\n  <crs:ToneCurvePV2012Blue>\n   <rdf:Seq>\n    <rdf:li>0, 0</rdf:li>\n    <rdf:li>255, 255</rdf:li>\n   </rdf:Seq>\n  </crs:ToneCurvePV2012Blue>\n  <crs:ToneCurvePV2012Green>\n   <rdf:Seq>\n    <rdf:li>0, 0</rdf:li>\n    <rdf:li>255, 255</rdf:li>\n   </rdf:Seq>\n  </crs:ToneCurvePV2012Green>\n  <crs:ToneCurvePV2012Red>\n   <rdf:Seq>\n    <rdf:li>0, 0</rdf:li>\n    <rdf:li>255, 255</rdf:li>\n   </rdf:Seq>\n  </crs:ToneCurvePV2012Red>\n  <crs:ToneCurveRed>\n   <rdf:Seq>\n    <rdf:li>0, 0</rdf:li>\n    <rdf:li>255, 255</rdf:li>\n   </rdf:Seq>\n  </crs:ToneCurveRed>\n  <crs:ToneMapStrength>0</crs:ToneMapStrength>\n  <crs:UprightCenterMode>0</crs:UprightCenterMode>\n  <crs:UprightCenterNormX>0.5</crs:UprightCenterNormX>\n  <crs:UprightCenterNormY>0.5</crs:UprightCenterNormY>\n  <crs:UprightFocalLength35mm>35</crs:UprightFocalLength35mm>\n  <crs:UprightFocalMode>0</crs:UprightFocalMode>\n  <crs:UprightFourSegmentsCount>0</crs:UprightFourSegmentsCount>\n  <crs:UprightPreview>False</crs:UprightPreview>\n  <crs:UprightTransformCount>6</crs:UprightTransformCount>\n  <crs:UprightVersion>151388160</crs:UprightVersion>\n  <crs:Version>10.4</crs:Version>\n  <crs:Vibrance>+20</crs:Vibrance>\n  <crs:VignetteAmount>0</crs:VignetteAmount>\n  <crs:WhiteBalance>Custom</crs:WhiteBalance>\n  <crs:Whites2012>0</crs:Whites2012>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:dc='http://purl.org/dc/elements/1.1/'>\n  <dc:creator>\n   <rdf:Seq>\n    <rdf:li>NASA/Aubrey Gemignani</rdf:li>\n   </rdf:Seq>\n  </dc:creator>\n  <dc:description>\n   <rdf:Alt>\n    <rdf:li xml:lang='x-default'>American actor Niaz Faridani-Rad arrives on the red carpet for the premiere of the film \"First Man\" at the Smithsonian National Air and Space Museum Thursday, Oct. 4, 2018 in Washington. The film is based on the book by Jim Hansen, and chronicles the life of NASA astronaut Neil Armstrong from test pilot to his historic Moon landing. Photo Credit: (NASA/Aubrey Gemignani)</rdf:li>\n   </rdf:Alt>\n  </dc:description>\n  <dc:format>image/jpeg</dc:format>\n  <dc:rights>\n   <rdf:Alt>\n    <rdf:li xml:lang='x-default'>(NASA/Aubrey Gemignani)</rdf:li>\n   </rdf:Alt>\n  </dc:rights>\n  <dc:subject>\n   <rdf:Bag>\n    <rdf:li>First Man</rdf:li>\n    <rdf:li>Washington</rdf:li>\n    <rdf:li>Smithsonian National Air and Space Museum (NASM)</rdf:li>\n    <rdf:li>DC</rdf:li>\n    <rdf:li>Niaz Faridani-Rad</rdf:li>\n   </rdf:Bag>\n  </dc:subject>\n  <dc:title>\n   <rdf:Alt>\n    <rdf:li xml:lang='x-default'>\"First Man\" Premiere at NASM</rdf:li>\n   </rdf:Alt>\n  </dc:title>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:digiKam='http://www.digikam.org/ns/1.0/'>\n  <digiKam:TagsList>\n   <rdf:Seq>\n    <rdf:li>First Man</rdf:li>\n    <rdf:li>Washington</rdf:li>\n    <rdf:li>Smithsonian National Air and Space Museum (NASM)</rdf:li>\n    <rdf:li>DC</rdf:li>\n    <rdf:li>Niaz Faridani-Rad</rdf:li>\n   </rdf:Seq>\n  </digiKam:TagsList>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:exif='http://ns.adobe.com/exif/1.0/'>\n  <exif:ApertureValue>433985/100000</exif:ApertureValue>\n  <exif:CFAPattern>2 0 2 0 0 1 1 2</exif:CFAPattern>\n  <exif:ColorSpace>1</exif:ColorSpace>\n  <exif:Contrast>0</exif:Contrast>\n  <exif:CustomRendered>0</exif:CustomRendered>\n  <exif:ExifVersion>0230</exif:ExifVersion>\n  <exif:ExposureBiasValue>0/6</exif:ExposureBiasValue>\n  <exif:ExposureMode>1</exif:ExposureMode>\n  <exif:ExposureProgram>1</exif:ExposureProgram>\n  <exif:ExposureTime>1/320</exif:ExposureTime>\n  <exif:FNumber>45/10</exif:FNumber>\n  <exif:FileSource>3</exif:FileSource>\n  <exif:Flash rdf:parseType='Resource'>\n   <exif:Fired>False</exif:Fired>\n   <exif:Function>False</exif:Function>\n   <exif:Mode>0</exif:Mode>\n   <exif:RedEyeMode>False</exif:RedEyeMode>\n   <exif:Return>0</exif:Return>\n  </exif:Flash>\n  <exif:FocalLength>2000/10</exif:FocalLength>\n  <exif:FocalLengthIn35mmFilm>200</exif:FocalLengthIn35mmFilm>\n  <exif:FocalPlaneResolutionUnit>3</exif:FocalPlaneResolutionUnit>\n  <exif:FocalPlaneXResolution>50857775/32768</exif:FocalPlaneXResolution>\n  <exif:FocalPlaneYResolution>50857775/32768</exif:FocalPlaneYResolution>\n  <exif:GainControl>1</exif:GainControl>\n  <exif:ISOSpeedRatings>\n   <rdf:Seq>\n    <rdf:li>2000</rdf:li>\n   </rdf:Seq>\n  </exif:ISOSpeedRatings>\n  <exif:LightSource>0</exif:LightSource>\n  <exif:MaxApertureValue>30/10</exif:MaxApertureValue>\n  <exif:MeteringMode>5</exif:MeteringMode>\n  <exif:Saturation>0</exif:Saturation>\n  <exif:SceneCaptureType>0</exif:SceneCaptureType>\n  <exif:SceneType>1</exif:SceneType>\n  <exif:SensingMethod>2</exif:SensingMethod>\n  <exif:Sharpness>0</exif:Sharpness>\n  <exif:ShutterSpeedValue>8321928/1000000</exif:ShutterSpeedValue>\n  <exif:SubjectDistanceRange>0</exif:SubjectDistanceRange>\n  <exif:WhiteBalance>0</exif:WhiteBalance>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:lr='http://ns.adobe.com/lightroom/1.0/'>\n  <lr:hierarchicalSubject>\n   <rdf:Bag>\n    <rdf:li>First Man</rdf:li>\n    <rdf:li>Washington</rdf:li>\n    <rdf:li>Smithsonian National Air and Space Museum (NASM)</rdf:li>\n    <rdf:li>DC</rdf:li>\n    <rdf:li>Niaz Faridani-Rad</rdf:li>\n   </rdf:Bag>\n  </lr:hierarchicalSubject>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:mediapro='http://ns.iview-multimedia.com/mediapro/1.0/'>\n  <mediapro:CatalogSets>\n   <rdf:Bag>\n    <rdf:li>First Man</rdf:li>\n    <rdf:li>Washington</rdf:li>\n    <rdf:li>Smithsonian National Air and Space Museum (NASM)</rdf:li>\n    <rdf:li>DC</rdf:li>\n    <rdf:li>Niaz Faridani-Rad</rdf:li>\n   </rdf:Bag>\n  </mediapro:CatalogSets>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:mwg-rs='http://www.metadataworkinggroup.com/schemas/regions/'\n  xmlns:stArea='http://ns.adobe.com/xmp/sType/Area#'>\n  <mwg-rs:Regions rdf:parseType='Resource'>\n   <mwg-rs:RegionList>\n    <rdf:Bag>\n     <rdf:li rdf:parseType='Resource'>\n      <mwg-rs:Area rdf:parseType='Resource'>\n       <stArea:h>0.485614</stArea:h>\n       <stArea:unit>normalized</stArea:unit>\n       <stArea:w>0.436419</stArea:w>\n       <stArea:x>0.498486</stArea:x>\n       <stArea:y>0.367807</stArea:y>\n      </mwg-rs:Area>\n      <mwg-rs:Name>Niaz Faridani-Rad</mwg-rs:Name>\n      <mwg-rs:Type>Face</mwg-rs:Type>\n     </rdf:li>\n    </rdf:Bag>\n   </mwg-rs:RegionList>\n  </mwg-rs:Regions>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:photomechanic='http://ns.camerabits.com/photomechanic/1.0/'>\n  <photomechanic:ColorClass>0</photomechanic:ColorClass>\n  <photomechanic:PMVersion>PM5</photomechanic:PMVersion>\n  <photomechanic:Prefs>0:0:5:007311</photomechanic:Prefs>\n  <photomechanic:Tagged>False</photomechanic:Tagged>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:photoshop='http://ns.adobe.com/photoshop/1.0/'>\n  <photoshop:AuthorsPosition>Photo Archivist/Photographer</photoshop:AuthorsPosition>\n  <photoshop:CaptionWriter>ag</photoshop:CaptionWriter>\n  <photoshop:Credit>(NASA/Aubrey Gemignani)</photoshop:Credit>\n  <photoshop:DateCreated>2018-10-04T19:21:55-04:00</photoshop:DateCreated>\n  <photoshop:Headline>&quot;First Man&quot; Premiere at NASM</photoshop:Headline>\n  <photoshop:Instructions>MANDATORY CREDIT: (NASA/Aubrey Gemignani)</photoshop:Instructions>\n  <photoshop:Source>(NASA/Aubrey Gemignani)</photoshop:Source>\n  <photoshop:TransmissionReference>NHQ201810040124</photoshop:TransmissionReference>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:tiff='http://ns.adobe.com/tiff/1.0/'>\n  <tiff:Make>NIKON CORPORATION</tiff:Make>\n  <tiff:Model>NIKON D5</tiff:Model>\n  <tiff:ResolutionUnit>2</tiff:ResolutionUnit>\n  <tiff:Software>Adobe Photoshop Lightroom Classic 7.4 (Macintosh)</tiff:Software>\n  <tiff:XResolution>240/1</tiff:XResolution>\n  <tiff:YResolution>240/1</tiff:YResolution>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:xmp='http://ns.adobe.com/xap/1.0/'>\n  <xmp:CreateDate>2018-10-04T19:21:55-04:00</xmp:CreateDate>\n  <xmp:CreatorTool>Adobe Photoshop Lightroom Classic 7.4 (Macintosh)</xmp:CreatorTool>\n  <xmp:MetadataDate>2018-10-04T21:53:24-04:00</xmp:MetadataDate>\n  <xmp:ModifyDate>2018-10-04T21:53:24-04:00</xmp:ModifyDate>\n  <xmp:Rating>5</xmp:Rating>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:stEvt='http://ns.adobe.com/xap/1.0/sType/ResourceEvent#'\n  xmlns:stRef='http://ns.adobe.com/xap/1.0/sType/ResourceRef#'\n  xmlns:xmpMM='http://ns.adobe.com/xap/1.0/mm/'>\n  <xmpMM:DerivedFrom rdf:parseType='Resource'>\n   <stRef:documentID>E217B47072178EE0C74E34127F15697D</stRef:documentID>\n   <stRef:originalDocumentID>E217B47072178EE0C74E34127F15697D</stRef:originalDocumentID>\n  </xmpMM:DerivedFrom>\n  <xmpMM:DocumentID>xmp.did:1c7fcbef-0981-466e-a9ea-7c659325f7d4</xmpMM:DocumentID>\n  <xmpMM:History>\n   <rdf:Seq>\n    <rdf:li rdf:parseType='Resource'>\n     <stEvt:action>derived</stEvt:action>\n     <stEvt:parameters>converted from image/x-nikon-nef to image/jpeg, saved to new location</stEvt:parameters>\n    </rdf:li>\n    <rdf:li rdf:parseType='Resource'>\n     <stEvt:action>saved</stEvt:action>\n     <stEvt:changed>/</stEvt:changed>\n     <stEvt:instanceID>xmp.iid:1c7fcbef-0981-466e-a9ea-7c659325f7d4</stEvt:instanceID>\n     <stEvt:softwareAgent>Adobe Photoshop Lightroom Classic 7.4 (Macintosh)</stEvt:softwareAgent>\n     <stEvt:when>2018-10-04T21:53:24-04:00</stEvt:when>\n    </rdf:li>\n   </rdf:Seq>\n  </xmpMM:History>\n  <xmpMM:InstanceID>xmp.iid:1c7fcbef-0981-466e-a9ea-7c659325f7d4</xmpMM:InstanceID>\n  <xmpMM:OriginalDocumentID>E217B47072178EE0C74E34127F15697D</xmpMM:OriginalDocumentID>\n </rdf:Description>\n\n <rdf:Description rdf:about=''\n  xmlns:xmpRights='http://ns.adobe.com/xap/1.0/rights/'>\n  <xmpRights:Marked>False</xmpRights:Marked>\n  <xmpRights:UsageTerms>\n   <rdf:Alt>\n    <rdf:li xml:lang='x-default'>Released to Public</rdf:li>\n   </rdf:Alt>\n  </xmpRights:UsageTerms>\n </rdf:Description>\n</rdf:RDF>\n</x:xmpmeta>\n<?xpacket end='w'?>"
  },
  {
    "path": "api/tests/test_api_robustness.py",
    "content": "\"\"\"\nAPI Robustness and Security Tests\n\nTests designed to break the API with:\n- Malformed inputs\n- Invalid UUIDs and identifiers\n- Extremely long strings\n- Special characters and Unicode\n- Missing required fields\n- Invalid data types\n- Boundary conditions\n\"\"\"\n\nimport uuid\n\nfrom django.test import TestCase\nfrom django.utils import timezone\nfrom rest_framework import status\nfrom rest_framework.test import APIClient\n\nfrom api.models.duplicate import Duplicate\nfrom api.models.file import File\nfrom api.models.photo import Photo\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.user import User\n\n\nclass DuplicatesAPIRobustnessTestCase(TestCase):\n    \"\"\"Robustness tests for the Duplicates API.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and authenticate.\"\"\"\n        self.user = User.objects.create_user(\n            username=\"robusttest\",\n            password=\"testpass123\",\n        )\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_resolve_with_empty_body(self):\n        \"\"\"Should handle empty request body gracefully.\"\"\"\n        # Create a duplicate\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        \n        response = self.client.post(\n            f\"/api/duplicates/{duplicate.id}/resolve/\",\n            data={},\n            format=\"json\",\n        )\n        # Should return error, not crash\n        self.assertIn(response.status_code, [400, 422])\n\n    def test_resolve_with_invalid_photo_id(self):\n        \"\"\"Should handle invalid photo ID gracefully.\"\"\"\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        \n        response = self.client.post(\n            f\"/api/duplicates/{duplicate.id}/resolve/\",\n            data={\"photo_id\": \"not-a-valid-uuid\"},\n            format=\"json\",\n        )\n        self.assertIn(response.status_code, [400, 404])\n\n    def test_resolve_with_nonexistent_photo(self):\n        \"\"\"Should handle nonexistent photo ID.\"\"\"\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        fake_uuid = str(uuid.uuid4())\n        \n        response = self.client.post(\n            f\"/api/duplicates/{duplicate.id}/resolve/\",\n            data={\"photo_id\": fake_uuid},\n            format=\"json\",\n        )\n        self.assertIn(response.status_code, [400, 404])\n\n    def test_access_nonexistent_duplicate(self):\n        \"\"\"Should return 404 for nonexistent duplicate.\"\"\"\n        fake_uuid = str(uuid.uuid4())\n        \n        response = self.client.get(f\"/api/duplicates/{fake_uuid}/\")\n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_delete_nonexistent_duplicate(self):\n        \"\"\"Should return 404 for deleting nonexistent duplicate.\"\"\"\n        fake_uuid = str(uuid.uuid4())\n        \n        # Actual delete URL is /api/duplicates/<id>/delete with DELETE method\n        response = self.client.delete(f\"/api/duplicates/{fake_uuid}/delete\")\n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_list_with_invalid_status_filter(self):\n        \"\"\"Should handle invalid status filter gracefully.\"\"\"\n        response = self.client.get(\"/api/duplicates/?status=invalid_status\")\n        # Should either ignore invalid filter or return error\n        self.assertIn(response.status_code, [200, 400])\n\n    def test_list_with_invalid_type_filter(self):\n        \"\"\"Should handle invalid type filter gracefully.\"\"\"\n        response = self.client.get(\"/api/duplicates/?type=nonexistent_type\")\n        self.assertIn(response.status_code, [200, 400])\n\n    def test_extremely_long_string_in_query(self):\n        \"\"\"Should handle extremely long query strings.\"\"\"\n        long_string = \"a\" * 10000\n        response = self.client.get(f\"/api/duplicates/?status={long_string}\")\n        # Should not crash\n        self.assertIn(response.status_code, [200, 400, 414])\n\n    def test_special_characters_in_query(self):\n        \"\"\"Should handle special characters in query params.\"\"\"\n        response = self.client.get(\"/api/duplicates/?status=<script>alert(1)</script>\")\n        self.assertIn(response.status_code, [200, 400])\n\n    def test_unicode_in_query(self):\n        \"\"\"Should handle unicode characters in query params.\"\"\"\n        response = self.client.get(\"/api/duplicates/?status=状态\")\n        self.assertIn(response.status_code, [200, 400])\n\n    def test_null_bytes_in_request(self):\n        \"\"\"Should handle null bytes in request data.\"\"\"\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        \n        response = self.client.post(\n            f\"/api/duplicates/{duplicate.id}/resolve/\",\n            data={\"photo_id\": \"test\\x00injection\"},\n            format=\"json\",\n        )\n        self.assertIn(response.status_code, [400, 404])\n\n\nclass StacksAPIRobustnessTestCase(TestCase):\n    \"\"\"Robustness tests for the Stacks API.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and authenticate.\"\"\"\n        self.user = User.objects.create_user(\n            username=\"stackrobust\",\n            password=\"testpass123\",\n        )\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def _create_photo(self, suffix):\n        \"\"\"Create a test photo.\"\"\"\n        file = File.objects.create(\n            hash=f\"robust{suffix}\" + \"a\" * 26,\n            path=f\"/photos/robust_{suffix}.jpg\",\n            type=File.IMAGE,\n        )\n        return Photo.objects.create(\n            owner=self.user,\n            main_file=file,\n            image_hash=f\"robust{suffix}\" + \"b\" * 26,\n            added_on=timezone.now(),\n        )\n\n    def test_create_manual_stack_with_empty_photos(self):\n        \"\"\"Should reject creating stack with no photos.\"\"\"\n        # Actual URL is /api/stacks/manual\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            data={\"photo_ids\": []},\n            format=\"json\",\n        )\n        self.assertIn(response.status_code, [400, 422])\n\n    def test_create_manual_stack_with_single_photo(self):\n        \"\"\"Should reject creating stack with only one photo.\"\"\"\n        photo = self._create_photo(\"1\")\n        \n        response = self.client.post(\n            \"/api/stacks/manual\",\n            data={\"photo_ids\": [str(photo.pk)]},\n            format=\"json\",\n        )\n        # Stack needs at least 2 photos\n        self.assertIn(response.status_code, [400, 422])\n\n    def test_create_manual_stack_with_invalid_photo_ids(self):\n        \"\"\"Should handle invalid photo IDs.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            data={\"photo_ids\": [\"not-uuid\", \"also-not-uuid\"]},\n            format=\"json\",\n        )\n        self.assertIn(response.status_code, [400, 404])\n\n    def test_create_manual_stack_with_nonexistent_photos(self):\n        \"\"\"Should handle nonexistent photo IDs.\"\"\"\n        fake_uuids = [str(uuid.uuid4()), str(uuid.uuid4())]\n        \n        response = self.client.post(\n            \"/api/stacks/manual\",\n            data={\"photo_ids\": fake_uuids},\n            format=\"json\",\n        )\n        self.assertIn(response.status_code, [400, 404])\n\n    def test_create_manual_stack_with_other_user_photos(self):\n        \"\"\"Should reject using another user's photos.\"\"\"\n        other_user = User.objects.create_user(\n            username=\"otheruser\",\n            password=\"testpass123\",\n        )\n        other_file = File.objects.create(\n            hash=\"other\" + \"a\" * 28,\n            path=\"/photos/other.jpg\",\n            type=File.IMAGE,\n        )\n        other_photo = Photo.objects.create(\n            owner=other_user,\n            main_file=other_file,\n            image_hash=\"other\" + \"b\" * 28,\n            added_on=timezone.now(),\n        )\n        my_photo = self._create_photo(\"2\")\n        \n        response = self.client.post(\n            \"/api/stacks/manual\",\n            data={\"photo_ids\": [str(my_photo.pk), str(other_photo.pk)]},\n            format=\"json\",\n        )\n        # Should reject or ignore other user's photo\n        self.assertIn(response.status_code, [400, 403, 404])\n\n    def test_set_primary_with_photo_not_in_stack(self):\n        \"\"\"Should reject setting primary to photo not in stack.\"\"\"\n        photo1 = self._create_photo(\"3\")\n        photo2 = self._create_photo(\"4\")\n        photo3 = self._create_photo(\"5\")\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        photo1.stacks.add(stack)\n        photo2.stacks.add(stack)\n        # photo3 is NOT in stack\n        \n        # Actual URL is /api/stacks/<id>/primary\n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/primary\",\n            data={\"photo_id\": str(photo3.pk)},\n            format=\"json\",\n        )\n        self.assertIn(response.status_code, [400, 404])\n\n    def test_add_photo_to_nonexistent_stack(self):\n        \"\"\"Should return 404 for adding to nonexistent stack.\"\"\"\n        photo = self._create_photo(\"6\")\n        fake_uuid = str(uuid.uuid4())\n        \n        # Actual URL is /api/stacks/<id>/add (no trailing slash)\n        response = self.client.post(\n            f\"/api/stacks/{fake_uuid}/add\",\n            data={\"photo_ids\": [str(photo.pk)]},\n            format=\"json\",\n        )\n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_remove_all_photos_from_stack(self):\n        \"\"\"Should handle removing all photos (stack should be deleted or empty).\"\"\"\n        photo1 = self._create_photo(\"7\")\n        photo2 = self._create_photo(\"8\")\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        photo1.stacks.add(stack)\n        photo2.stacks.add(stack)\n        \n        # Actual URL is /api/stacks/<id>/remove\n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/remove\",\n            data={\"photo_ids\": [str(photo1.pk), str(photo2.pk)]},\n            format=\"json\",\n        )\n        # Stack API may reject removing all photos (requires at least 2) or delete stack\n        self.assertIn(response.status_code, [200, 204, 400])\n\n    def test_merge_stacks_with_empty_list(self):\n        \"\"\"Should reject merging with empty stack list.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/merge/\",\n            data={\"stack_ids\": []},\n            format=\"json\",\n        )\n        self.assertIn(response.status_code, [400, 422])\n\n    def test_merge_single_stack(self):\n        \"\"\"Should reject merging with only one stack.\"\"\"\n        photo1 = self._create_photo(\"9\")\n        photo2 = self._create_photo(\"10\")\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        photo1.stacks.add(stack)\n        photo2.stacks.add(stack)\n        \n        response = self.client.post(\n            \"/api/stacks/merge/\",\n            data={\"stack_ids\": [str(stack.id)]},\n            format=\"json\",\n        )\n        self.assertIn(response.status_code, [400, 422])\n\n    def test_list_with_invalid_stack_type_filter(self):\n        \"\"\"Should handle invalid stack type filter.\"\"\"\n        response = self.client.get(\"/api/stacks/?stack_type=invalid_type\")\n        self.assertIn(response.status_code, [200, 400])\n\n\nclass PhotoMetadataAPIRobustnessTestCase(TestCase):\n    \"\"\"Robustness tests for the PhotoMetadata API.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user, photo, and authenticate.\"\"\"\n        self.user = User.objects.create_user(\n            username=\"metarobust\",\n            password=\"testpass123\",\n        )\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        \n        file = File.objects.create(\n            hash=\"meta\" + \"a\" * 28,\n            path=\"/photos/meta.jpg\",\n            type=File.IMAGE,\n        )\n        self.photo = Photo.objects.create(\n            owner=self.user,\n            main_file=file,\n            image_hash=\"meta\" + \"b\" * 28,\n            added_on=timezone.now(),\n        )\n\n    def test_update_with_invalid_field_types(self):\n        \"\"\"Test behavior with invalid field types - API may accept string and try to convert.\"\"\"\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            data={\"iso\": \"not-a-number\"},  # ISO should be int\n            format=\"json\",\n        )\n        # Note: API may accept this (Django models can coerce types) or reject\n        self.assertIn(response.status_code, [200, 400, 422])\n\n    def test_update_with_negative_values(self):\n        \"\"\"Should handle negative values for normally positive fields.\"\"\"\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            data={\"iso\": -100},\n            format=\"json\",\n        )\n        # May accept (no validation) or reject\n        self.assertIn(response.status_code, [200, 400])\n\n    def test_update_with_extremely_large_numbers(self):\n        \"\"\"Should handle extremely large numbers.\"\"\"\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            data={\"iso\": 999999999999999999},\n            format=\"json\",\n        )\n        self.assertIn(response.status_code, [200, 400])\n\n    def test_update_nonexistent_photo_metadata(self):\n        \"\"\"Should return 404 for nonexistent photo.\"\"\"\n        fake_uuid = str(uuid.uuid4())\n        \n        response = self.client.patch(\n            f\"/api/photos/{fake_uuid}/metadata/\",\n            data={\"camera\": \"Test Camera\"},\n            format=\"json\",\n        )\n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_update_other_user_photo_metadata(self):\n        \"\"\"Should reject updating another user's photo metadata.\"\"\"\n        other_user = User.objects.create_user(\n            username=\"othermetauser\",\n            password=\"testpass123\",\n        )\n        other_file = File.objects.create(\n            hash=\"othermeta\" + \"a\" * 23,\n            path=\"/photos/othermeta.jpg\",\n            type=File.IMAGE,\n        )\n        other_photo = Photo.objects.create(\n            owner=other_user,\n            main_file=other_file,\n            image_hash=\"othermeta\" + \"b\" * 23,\n            added_on=timezone.now(),\n        )\n        \n        response = self.client.patch(\n            f\"/api/photos/{other_photo.pk}/metadata/\",\n            data={\"camera\": \"Hacked Camera\"},\n            format=\"json\",\n        )\n        self.assertIn(response.status_code, [403, 404])\n\n    def test_revert_nonexistent_edit(self):\n        \"\"\"Should return 404 for reverting nonexistent edit.\"\"\"\n        fake_uuid = str(uuid.uuid4())\n        \n        response = self.client.post(\n            f\"/api/photos/{self.photo.pk}/metadata/revert/{fake_uuid}/\",\n        )\n        self.assertIn(response.status_code, [404, 405])\n\n\nclass AuthenticationRobustnessTestCase(TestCase):\n    \"\"\"Tests for authentication edge cases.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user.\"\"\"\n        self.user = User.objects.create_user(\n            username=\"authtest\",\n            password=\"testpass123\",\n        )\n        self.client = APIClient()\n\n    def test_unauthenticated_access_to_duplicates(self):\n        \"\"\"Should reject unauthenticated access.\"\"\"\n        response = self.client.get(\"/api/duplicates/\")\n        self.assertIn(response.status_code, [401, 403])\n\n    def test_unauthenticated_access_to_stacks(self):\n        \"\"\"Should reject unauthenticated access.\"\"\"\n        response = self.client.get(\"/api/stacks/\")\n        self.assertIn(response.status_code, [401, 403])\n\n    def test_unauthenticated_detect_duplicates(self):\n        \"\"\"Should reject unauthenticated duplicate detection.\"\"\"\n        response = self.client.post(\"/api/duplicates/detect/\")\n        self.assertIn(response.status_code, [401, 403])\n\n    def test_unauthenticated_detect_stacks(self):\n        \"\"\"Should reject unauthenticated stack detection.\"\"\"\n        response = self.client.post(\"/api/stacks/detect/\")\n        self.assertIn(response.status_code, [401, 403])\n\n\nclass ConcurrentOperationsTestCase(TestCase):\n    \"\"\"Tests for potential race conditions and concurrent operations.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and authenticate.\"\"\"\n        self.user = User.objects.create_user(\n            username=\"concurrent\",\n            password=\"testpass123\",\n        )\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def _create_photo(self, suffix):\n        \"\"\"Create a test photo.\"\"\"\n        file = File.objects.create(\n            hash=f\"conc{suffix}\" + \"a\" * 27,\n            path=f\"/photos/concurrent_{suffix}.jpg\",\n            type=File.IMAGE,\n        )\n        return Photo.objects.create(\n            owner=self.user,\n            main_file=file,\n            image_hash=f\"conc{suffix}\" + \"b\" * 27,\n            added_on=timezone.now(),\n        )\n\n    def test_delete_already_deleted_duplicate(self):\n        \"\"\"Should handle deleting an already-deleted duplicate.\"\"\"\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup_id = duplicate.id\n        \n        # First delete\n        duplicate.delete()\n        \n        # Second delete attempt via API - DELETE method\n        response = self.client.delete(f\"/api/duplicates/{dup_id}/delete\")\n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_delete_already_deleted_stack(self):\n        \"\"\"Should handle deleting an already-deleted stack.\"\"\"\n        photo = self._create_photo(\"1\")\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        photo.stacks.add(stack)\n        stack_id = stack.id\n        \n        # First delete\n        stack.delete()\n        \n        # Second delete attempt via API - DELETE method\n        response = self.client.delete(f\"/api/stacks/{stack_id}/delete\")\n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_resolve_already_resolved_duplicate(self):\n        \"\"\"Should handle resolving an already-resolved duplicate.\"\"\"\n        photo1 = self._create_photo(\"2\")\n        photo2 = self._create_photo(\"3\")\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        photo1.duplicates.add(duplicate)\n        photo2.duplicates.add(duplicate)\n        \n        # Resolve first time\n        duplicate.resolve(photo1, trash_others=True)\n        \n        # Resolve second time via API\n        response = self.client.post(\n            f\"/api/duplicates/{duplicate.id}/resolve/\",\n            data={\"photo_id\": str(photo2.pk)},\n            format=\"json\",\n        )\n        # Should either succeed (re-resolve) or return appropriate error\n        self.assertIn(response.status_code, [200, 400])\n\n\nclass BoundaryConditionsTestCase(TestCase):\n    \"\"\"Tests for boundary conditions and limits.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and authenticate.\"\"\"\n        self.user = User.objects.create_user(\n            username=\"boundary\",\n            password=\"testpass123\",\n        )\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_pagination_with_zero_page(self):\n        \"\"\"Should handle page=0 gracefully.\"\"\"\n        response = self.client.get(\"/api/duplicates/?page=0\")\n        self.assertIn(response.status_code, [200, 400])\n\n    def test_pagination_with_negative_page(self):\n        \"\"\"Should handle negative page number.\"\"\"\n        response = self.client.get(\"/api/duplicates/?page=-1\")\n        self.assertIn(response.status_code, [200, 400])\n\n    def test_pagination_with_very_large_page(self):\n        \"\"\"Should handle very large page number.\"\"\"\n        response = self.client.get(\"/api/duplicates/?page=999999999\")\n        # Should return empty results, not crash\n        self.assertIn(response.status_code, [200, 404])\n\n    def test_pagination_with_invalid_page_size(self):\n        \"\"\"Should handle invalid page size - fixed by clamping to valid range.\"\"\"\n        # Was Bug #10: Negative page_size caused unhandled EmptyPage exception\n        # Fixed by adding max(1, ...) to page_size validation\n        response = self.client.get(\"/api/duplicates/?page_size=-10\")\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n\n    def test_pagination_with_extremely_large_page_size(self):\n        \"\"\"Should limit extremely large page size.\"\"\"\n        response = self.client.get(\"/api/duplicates/?page_size=1000000\")\n        # Should limit or return error\n        self.assertIn(response.status_code, [200, 400])\n\n    def test_stacks_pagination_with_zero_page(self):\n        \"\"\"Should handle page=0 for stacks.\"\"\"\n        response = self.client.get(\"/api/stacks/?page=0\")\n        self.assertIn(response.status_code, [200, 400])\n\n\nclass MalformedRequestTestCase(TestCase):\n    \"\"\"Tests for malformed HTTP requests.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and authenticate.\"\"\"\n        self.user = User.objects.create_user(\n            username=\"malformed\",\n            password=\"testpass123\",\n        )\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_post_with_invalid_json(self):\n        \"\"\"Should handle invalid JSON gracefully.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            data=\"not valid json{{{\",\n            content_type=\"application/json\",\n        )\n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_post_with_wrong_content_type(self):\n        \"\"\"Should handle wrong content type.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            data=\"photo_ids=abc\",\n            content_type=\"text/plain\",\n        )\n        self.assertIn(response.status_code, [400, 415])\n\n    def test_get_with_duplicate_query_params(self):\n        \"\"\"Should handle duplicate query parameters.\"\"\"\n        response = self.client.get(\"/api/duplicates/?status=pending&status=resolved\")\n        # Should not crash - may use first, last, or combine\n        self.assertIn(response.status_code, [200, 400])\n"
  },
  {
    "path": "api/tests/test_api_util.py",
    "content": "from django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.tests.fixtures.api_util.photos import photos\nfrom api.tests.fixtures.api_util.sunburst_expectation import (\n    expectation as sunburst_expectation,\n)\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\ndef create_photos(user):\n    for p in photos:\n        create_test_photo(owner=user, **p)\n\n\ndef compare_objects_with_ignored_props(result, expectation, ignore):\n    if isinstance(result, dict) and isinstance(expectation, dict):\n        result_copy = {k: v for k, v in result.items() if k != ignore}\n        expectation_copy = {k: v for k, v in expectation.items() if k != ignore}\n        return all(\n            compare_objects_with_ignored_props(\n                result_copy[k], expectation_copy[k], ignore\n            )\n            for k in result_copy\n        ) and set(result_copy.keys()) == set(expectation_copy.keys())\n    if isinstance(result, list) and isinstance(expectation, list):\n        return len(result) == len(expectation) and all(\n            compare_objects_with_ignored_props(res, exp, ignore)\n            for res, exp in zip(result, expectation)\n        )\n    return result == expectation\n\n\nclass TestApiUtil(TestCase):\n    def setUp(self) -> None:\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n\n    def test_wordcloud(self):\n        create_photos(self.user)\n        response = self.client.get(\"/api/wordcloud/\")\n        actual = response.json()\n        # Check structure rather than exact values (caption generation may vary)\n        self.assertIn(\"captions\", actual)\n        self.assertIn(\"people\", actual)\n        self.assertIn(\"locations\", actual)\n        self.assertIsInstance(actual[\"captions\"], list)\n        self.assertIsInstance(actual[\"people\"], list)\n        self.assertIsInstance(actual[\"locations\"], list)\n        # Each caption entry should have label and y\n        for caption in actual[\"captions\"]:\n            self.assertIn(\"label\", caption)\n            self.assertIn(\"y\", caption)\n\n    def test_photo_month_count(self):\n        create_photos(self.user)\n        response = self.client.get(\"/api/photomonthcounts/\")\n        actual = response.json()\n        self.assertEqual(\n            actual,\n            [\n                {\"month\": \"2017-8\", \"count\": 6},\n                {\"month\": \"2017-9\", \"count\": 0},\n                {\"month\": \"2017-10\", \"count\": 3},\n            ],\n        )\n\n    def test_photo_month_count_no_photos(self):\n        response = self.client.get(\"/api/photomonthcounts/\")\n        actual = response.json()\n        self.assertEqual(actual, [])\n\n    def test_location_sunburst(self):\n        create_photos(self.user)\n        response = self.client.get(\"/api/locationsunburst/\")\n        actual = response.json()\n        assert compare_objects_with_ignored_props(\n            actual, sunburst_expectation, ignore=\"hex\"\n        )\n"
  },
  {
    "path": "api/tests/test_auto_select_and_savings.py",
    "content": "\"\"\"\nTests for auto-select and potential savings logic.\n\nTests cover:\n- PhotoStack.auto_select_primary() for various stack types\n- Duplicate.auto_select_best_photo() for exact copies and visual duplicates\n- Duplicate.calculate_potential_savings() edge cases\n- Edge cases with null/missing data\n\"\"\"\n\nfrom django.test import TestCase\nfrom django.utils import timezone\nfrom datetime import timedelta\n\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.duplicate import Duplicate\nfrom api.models.photo_metadata import PhotoMetadata\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass PhotoStackAutoSelectPrimaryTestCase(TestCase):\n    \"\"\"Tests for PhotoStack.auto_select_primary().\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_auto_select_empty_stack(self):\n        \"\"\"Test auto_select on empty stack.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        \n        result = stack.auto_select_primary()\n        \n        self.assertIsNone(result)\n\n    def test_auto_select_single_photo(self):\n        \"\"\"Test auto_select with single photo.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo)\n        \n        stack.auto_select_primary()\n        stack.refresh_from_db()\n        \n        self.assertEqual(stack.primary_photo, photo)\n\n    def test_auto_select_raw_jpeg_prefers_jpeg(self):\n        \"\"\"Test that RAW+JPEG stack selects a primary photo.\"\"\"\n        # Create two photos for the stack\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.RAW_JPEG_PAIR,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        # auto_select_primary should select a photo\n        stack.auto_select_primary()\n        stack.refresh_from_db()\n        \n        # Should have selected a primary photo\n        self.assertIsNotNone(stack.primary_photo)\n        # The selected photo should be one of the stack photos\n        self.assertIn(stack.primary_photo, [photo1, photo2])\n\n    def test_auto_select_raw_jpeg_only_raw(self):\n        \"\"\"Test RAW+JPEG fallback when no JPEG available.\"\"\"\n        # Create two photos for the stack\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.RAW_JPEG_PAIR,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        stack.auto_select_primary()\n        stack.refresh_from_db()\n        \n        # Should still select one photo even if no JPEG preference possible\n        self.assertIsNotNone(stack.primary_photo)\n        self.assertIn(stack.primary_photo, [photo1, photo2])\n\n    def test_auto_select_burst_picks_middle(self):\n        \"\"\"Test that burst stack picks middle photo by timestamp.\"\"\"\n        base_time = timezone.now()\n        \n        photos = []\n        for i in range(5):\n            photo = create_test_photo(owner=self.user)\n            photo.exif_timestamp = base_time + timedelta(seconds=i)\n            photo.save()\n            photos.append(photo)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(*photos)\n        \n        stack.auto_select_primary()\n        stack.refresh_from_db()\n        \n        # Should pick middle (index 2 of 5)\n        self.assertEqual(stack.primary_photo, photos[2])\n\n    def test_auto_select_burst_no_timestamps(self):\n        \"\"\"Test burst stack when photos have no timestamps.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(3)]\n        for photo in photos:\n            photo.exif_timestamp = None\n            photo.save()\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(*photos)\n        \n        # Should not crash\n        stack.auto_select_primary()\n        stack.refresh_from_db()\n        \n        # Should still select something\n        self.assertIsNotNone(stack.primary_photo)\n\n    def test_auto_select_manual_highest_resolution(self):\n        \"\"\"Test manual stack picks highest resolution.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        # Set different resolutions via metadata\n        PhotoMetadata.objects.update_or_create(\n            photo=photo1,\n            defaults={'width': 1920, 'height': 1080}\n        )\n        PhotoMetadata.objects.update_or_create(\n            photo=photo2,\n            defaults={'width': 3840, 'height': 2160}  # 4K - highest\n        )\n        PhotoMetadata.objects.update_or_create(\n            photo=photo3,\n            defaults={'width': 1280, 'height': 720}\n        )\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2, photo3)\n        \n        stack.auto_select_primary()\n        stack.refresh_from_db()\n        \n        # Should pick highest resolution (photo2)\n        self.assertEqual(stack.primary_photo, photo2)\n\n    def test_auto_select_manual_no_metadata(self):\n        \"\"\"Test manual stack when photos have no metadata.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(3)]\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*photos)\n        \n        # Should not crash\n        stack.auto_select_primary()\n        stack.refresh_from_db()\n        \n        # Should still select something\n        self.assertIsNotNone(stack.primary_photo)\n\n\nclass DuplicateAutoSelectBestTestCase(TestCase):\n    \"\"\"Tests for Duplicate.auto_select_best_photo().\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_auto_select_empty_duplicate(self):\n        \"\"\"Test auto_select on duplicate with no photos.\"\"\"\n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        \n        result = dup.auto_select_best_photo()\n        \n        self.assertIsNone(result)\n\n    def test_auto_select_exact_copy_shortest_path(self):\n        \"\"\"Test exact copy selects shortest path.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo1.main_file.path = \"/very/long/nested/directory/path/photo1.jpg\"\n        photo1.main_file.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.main_file.path = \"/a.jpg\"  # Much shorter path\n        photo2.main_file.save()\n        \n        # Verify paths are set correctly\n        photo1.main_file.refresh_from_db()\n        photo2.main_file.refresh_from_db()\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        result = dup.auto_select_best_photo()\n        \n        # Should pick shorter path - verify by checking the path of result\n        self.assertIsNotNone(result)\n        # The result should have the shorter path\n        result_path_len = len(result.main_file.path)\n        self.assertLessEqual(result_path_len, len(\"/a.jpg\") + 5)  # Some tolerance\n\n    def test_auto_select_visual_duplicate_highest_resolution(self):\n        \"\"\"Test visual duplicate selects highest resolution.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        PhotoMetadata.objects.update_or_create(\n            photo=photo1,\n            defaults={'width': 1920, 'height': 1080}\n        )\n        \n        photo2 = create_test_photo(owner=self.user)\n        PhotoMetadata.objects.update_or_create(\n            photo=photo2,\n            defaults={'width': 3840, 'height': 2160}  # Higher resolution\n        )\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        result = dup.auto_select_best_photo()\n        \n        # Should pick highest resolution\n        self.assertEqual(result, photo2)\n\n    def test_auto_select_visual_no_metadata(self):\n        \"\"\"Test visual duplicate when no metadata available.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(3)]\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        dup.photos.add(*photos)\n        \n        # Should not crash\n        result = dup.auto_select_best_photo()\n        \n        # Should still return something (or None)\n        # The result depends on database ordering\n        self.assertIsNotNone(result) if dup.photos.exists() else None\n\n    def test_auto_select_visual_partial_metadata(self):\n        \"\"\"Test visual duplicate when some photos have metadata.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        # No metadata for photo1\n        \n        photo2 = create_test_photo(owner=self.user)\n        PhotoMetadata.objects.update_or_create(\n            photo=photo2,\n            defaults={'width': 1920, 'height': 1080}\n        )\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        # Should not crash\n        result = dup.auto_select_best_photo()\n        self.assertIsNotNone(result)\n\n\nclass DuplicatePotentialSavingsTestCase(TestCase):\n    \"\"\"Tests for Duplicate.calculate_potential_savings().\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_savings_empty_duplicate(self):\n        \"\"\"Test potential savings for empty duplicate.\"\"\"\n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        \n        savings = dup.calculate_potential_savings()\n        \n        self.assertEqual(savings, 0)\n\n    def test_savings_single_photo(self):\n        \"\"\"Test potential savings with single photo.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.size = 1000000  # 1MB\n        photo.save()\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(photo)\n        \n        savings = dup.calculate_potential_savings()\n        \n        # Only one photo, no savings possible\n        self.assertEqual(savings, 0)\n\n    def test_savings_two_photos(self):\n        \"\"\"Test potential savings with two photos.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo1.size = 2000000  # 2MB\n        photo1.main_file.path = \"/short.jpg\"\n        photo1.main_file.save()\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.size = 1500000  # 1.5MB\n        photo2.main_file.path = \"/very/long/path/photo.jpg\"\n        photo2.main_file.save()\n        photo2.save()\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        savings = dup.calculate_potential_savings()\n        \n        # Best photo is photo1 (shorter path), savings = photo2.size\n        self.assertEqual(savings, 1500000)\n\n    def test_savings_many_photos(self):\n        \"\"\"Test potential savings with many photos.\"\"\"\n        photos = []\n        total_size = 0\n        for i in range(5):\n            photo = create_test_photo(owner=self.user)\n            photo.size = (i + 1) * 1000000  # 1MB, 2MB, 3MB, 4MB, 5MB\n            total_size += photo.size\n            photo.main_file.path = f\"/{'x' * (i + 1)}/photo{i}.jpg\"\n            photo.main_file.save()\n            photo.save()\n            photos.append(photo)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(*photos)\n        \n        savings = dup.calculate_potential_savings()\n        \n        # Savings should be total - best_photo_size\n        # The actual \"best\" depends on which path is shortest\n        # Just verify savings is reasonable (not 0, less than total)\n        self.assertGreater(savings, 0)\n        self.assertLess(savings, total_size)\n\n    def test_savings_zero_sizes(self):\n        \"\"\"Test potential savings when photos have zero sizes.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo1.size = 0\n        photo1.main_file.path = \"/a.jpg\"  # Short path (will be kept)\n        photo1.main_file.save()\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.size = 0\n        photo2.main_file.path = \"/longer/path/photo.jpg\"\n        photo2.main_file.save()\n        photo2.save()\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        # Should not crash, savings = 0 since both sizes are 0\n        savings = dup.calculate_potential_savings()\n        self.assertEqual(savings, 0)\n\n    def test_savings_updates_model_field(self):\n        \"\"\"Test that calculate_potential_savings updates the model field.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo1.size = 2000000\n        photo1.main_file.path = \"/short.jpg\"\n        photo1.main_file.save()\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.size = 3000000\n        photo2.main_file.path = \"/longer/path.jpg\"\n        photo2.main_file.save()\n        photo2.save()\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            potential_savings=0,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        dup.calculate_potential_savings()\n        \n        # Reload from database\n        dup.refresh_from_db()\n        \n        # Field should be updated\n        self.assertEqual(dup.potential_savings, 3000000)\n\n\nclass DuplicateResolveRevertTestCase(TestCase):\n    \"\"\"Tests for Duplicate.resolve() and Duplicate.revert() methods.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_resolve_marks_status(self):\n        \"\"\"Test that resolve() sets review_status to RESOLVED.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.PENDING,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        dup.resolve(kept_photo=photo1, trash_others=False)\n        dup.refresh_from_db()\n        \n        self.assertEqual(dup.review_status, Duplicate.ReviewStatus.RESOLVED)\n        self.assertEqual(dup.kept_photo, photo1)\n\n    def test_resolve_trash_others(self):\n        \"\"\"Test that resolve() with trash_others=True moves photos to trash.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(photo1, photo2, photo3)\n        \n        dup.resolve(kept_photo=photo1, trash_others=True)\n        \n        # Refresh all\n        photo1.refresh_from_db()\n        photo2.refresh_from_db()\n        photo3.refresh_from_db()\n        \n        # Kept photo should NOT be trashed\n        self.assertFalse(photo1.in_trashcan)\n        \n        # Others should be trashed\n        self.assertTrue(photo2.in_trashcan)\n        self.assertTrue(photo3.in_trashcan)\n\n    def test_resolve_no_trash(self):\n        \"\"\"Test that resolve() with trash_others=False doesn't trash anything.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        dup.resolve(kept_photo=photo1, trash_others=False)\n        \n        photo2.refresh_from_db()\n        \n        # Should NOT be trashed\n        self.assertFalse(photo2.in_trashcan)\n\n    def test_revert_restores_trashed(self):\n        \"\"\"Test that revert() restores trashed photos.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        # Resolve (trashes photo2)\n        dup.resolve(kept_photo=photo1, trash_others=True)\n        \n        photo2.refresh_from_db()\n        self.assertTrue(photo2.in_trashcan)\n        \n        # Revert\n        dup.revert()\n        \n        photo2.refresh_from_db()\n        dup.refresh_from_db()\n        \n        # Photo should be restored\n        self.assertFalse(photo2.in_trashcan)\n        \n        # Status should be back to pending\n        self.assertEqual(dup.review_status, Duplicate.ReviewStatus.PENDING)\n\n    def test_revert_clears_kept_photo(self):\n        \"\"\"Test that revert() clears the kept_photo field.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        dup.resolve(kept_photo=photo1, trash_others=False)\n        self.assertIsNotNone(dup.kept_photo)\n        \n        dup.revert()\n        dup.refresh_from_db()\n        \n        self.assertIsNone(dup.kept_photo)\n\n\nclass DuplicateDismissTestCase(TestCase):\n    \"\"\"Tests for Duplicate.dismiss() method.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_dismiss_sets_status(self):\n        \"\"\"Test that dismiss() sets review_status to DISMISSED.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n            review_status=Duplicate.ReviewStatus.PENDING,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        dup.dismiss()\n        dup.refresh_from_db()\n        \n        self.assertEqual(dup.review_status, Duplicate.ReviewStatus.DISMISSED)\n\n    def test_dismiss_doesnt_trash(self):\n        \"\"\"Test that dismiss() doesn't trash any photos.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        dup.dismiss()\n        \n        photo1.refresh_from_db()\n        photo2.refresh_from_db()\n        \n        # Neither should be trashed\n        self.assertFalse(photo1.in_trashcan)\n        self.assertFalse(photo2.in_trashcan)\n"
  },
  {
    "path": "api/tests/test_background_tasks.py",
    "content": "import importlib.util\nimport pathlib\nimport sys\nimport unittest\nfrom types import ModuleType, SimpleNamespace\nfrom unittest.mock import MagicMock, patch\n\n\nclass _FakeQuerySet(list):\n    def count(self):\n        return len(self)\n\n\nclass GeolocateLoggingTests(unittest.TestCase):\n    def test_geolocate_logs_exception_without_crash(self):\n        fake_logger = MagicMock()\n        fake_api_module = ModuleType(\"api\")\n        fake_api_module.__path__ = []\n\n        fake_models_module = ModuleType(\"api.models\")\n        fake_models_module.Photo = MagicMock()\n\n        fake_photo_caption_module = ModuleType(\"api.models.photo_caption\")\n        fake_photo_caption_module.PhotoCaption = MagicMock()\n\n        fake_util_module = ModuleType(\"api.util\")\n        fake_util_module.logger = fake_logger\n\n        fake_django_module = ModuleType(\"django\")\n        fake_django_apps = ModuleType(\"django.apps\")\n        fake_django_apps.AppConfig = object\n        fake_django_module.apps = fake_django_apps\n\n        fake_django_db_module = ModuleType(\"django.db\")\n        fake_django_db_module.models = SimpleNamespace(Q=MagicMock())\n\n        fake_tqdm_module = ModuleType(\"tqdm\")\n        fake_tqdm_module.tqdm = MagicMock()\n\n        module_path = pathlib.Path(__file__).resolve().parents[1] / \"background_tasks.py\"\n\n        exception_mock = None\n\n        with patch.dict(\n            sys.modules,\n            {\n                \"api\": fake_api_module,\n                \"api.models\": fake_models_module,\n                \"api.models.photo_caption\": fake_photo_caption_module,\n                \"api.util\": fake_util_module,\n                \"django\": fake_django_module,\n                \"django.apps\": fake_django_apps,\n                \"django.db\": fake_django_db_module,\n                \"tqdm\": fake_tqdm_module,\n            },\n        ):\n            spec = importlib.util.spec_from_file_location(\n                \"api.background_tasks\", module_path\n            )\n            module = importlib.util.module_from_spec(spec)\n            sys.modules[\"api.background_tasks\"] = module\n            spec.loader.exec_module(module)\n\n            photo = MagicMock()\n            photo._geolocate.side_effect = RuntimeError(\"boom\")\n            photo.main_file = SimpleNamespace(path=\"fake-path\")\n\n            photos = _FakeQuerySet([photo])\n\n            fake_models_module.Photo.objects.filter.return_value = photos\n\n            with patch(\"api.background_tasks.logger.exception\") as mock_exception:\n                module.geolocate()\n                exception_mock = mock_exception\n                logged_args = mock_exception.call_args[0]\n\n        self.assertIsNotNone(exception_mock)\n        exception_mock.assert_called_once()\n        self.assertEqual(logged_args[0], \"could not geolocate photo: %s\")\n        self.assertIs(logged_args[1], photo)\n\n"
  },
  {
    "path": "api/tests/test_bktree_and_duplicate_detection.py",
    "content": "\"\"\"\nTests for BK-Tree data structure and duplicate detection logic.\n\nTests cover:\n- BK-Tree operations (add, search, edge cases)\n- Union-Find operations\n- Exact copy detection\n- Visual duplicate detection with threshold\n- Batch detection orchestration\n\"\"\"\n\nfrom django.test import TestCase\n\nfrom api.models.duplicate import Duplicate\nfrom api.models.file import File\nfrom api.duplicate_detection import (\n    BKTree,\n    UnionFind,\n    detect_exact_copies,\n    detect_visual_duplicates,\n    batch_detect_duplicates,\n)\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass BKTreeTestCase(TestCase):\n    \"\"\"Tests for BK-Tree data structure.\"\"\"\n\n    def test_empty_tree_search(self):\n        \"\"\"Test searching an empty tree.\"\"\"\n        tree = BKTree(lambda a, b: abs(a - b))\n        \n        results = tree.search(5, 2)\n        \n        self.assertEqual(results, [])\n\n    def test_add_single_item(self):\n        \"\"\"Test adding a single item to tree.\"\"\"\n        tree = BKTree(lambda a, b: abs(a - b))\n        tree.add(\"item1\", 10)\n        \n        self.assertEqual(tree.size, 1)\n        self.assertIsNotNone(tree.root)\n\n    def test_add_multiple_items(self):\n        \"\"\"Test adding multiple items to tree.\"\"\"\n        tree = BKTree(lambda a, b: abs(a - b))\n        tree.add(\"item1\", 10)\n        tree.add(\"item2\", 15)\n        tree.add(\"item3\", 20)\n        \n        self.assertEqual(tree.size, 3)\n\n    def test_search_exact_match(self):\n        \"\"\"Test searching for exact match.\"\"\"\n        tree = BKTree(lambda a, b: abs(a - b))\n        tree.add(\"item1\", 10)\n        tree.add(\"item2\", 20)\n        \n        results = tree.search(10, 0)\n        \n        self.assertEqual(len(results), 1)\n        self.assertEqual(results[0][0], \"item1\")\n        self.assertEqual(results[0][1], 0)\n\n    def test_search_within_threshold(self):\n        \"\"\"Test searching within threshold.\"\"\"\n        tree = BKTree(lambda a, b: abs(a - b))\n        tree.add(\"item1\", 10)\n        tree.add(\"item2\", 12)\n        tree.add(\"item3\", 20)\n        \n        results = tree.search(11, 2)\n        \n        # Should find item1 (distance 1) and item2 (distance 1)\n        ids = [r[0] for r in results]\n        self.assertIn(\"item1\", ids)\n        self.assertIn(\"item2\", ids)\n        self.assertNotIn(\"item3\", ids)\n\n    def test_search_no_matches(self):\n        \"\"\"Test searching with no matches.\"\"\"\n        tree = BKTree(lambda a, b: abs(a - b))\n        tree.add(\"item1\", 10)\n        tree.add(\"item2\", 20)\n        \n        results = tree.search(100, 5)\n        \n        self.assertEqual(results, [])\n\n    def test_hamming_distance_search(self):\n        \"\"\"Test BK-Tree with hamming distance (simulated).\"\"\"\n        def simple_hamming(a, b):\n            \"\"\"Simple hamming distance for integers.\"\"\"\n            xor = a ^ b\n            return bin(xor).count('1')\n        \n        tree = BKTree(simple_hamming)\n        tree.add(\"photo1\", 0b11110000)\n        tree.add(\"photo2\", 0b11110001)  # 1 bit different\n        tree.add(\"photo3\", 0b00001111)  # 8 bits different\n        \n        results = tree.search(0b11110000, 2)\n        \n        ids = [r[0] for r in results]\n        self.assertIn(\"photo1\", ids)\n        self.assertIn(\"photo2\", ids)\n        self.assertNotIn(\"photo3\", ids)\n\n\nclass UnionFindTestCase(TestCase):\n    \"\"\"Tests for Union-Find data structure.\"\"\"\n\n    def test_initial_state(self):\n        \"\"\"Test that items start in their own set.\"\"\"\n        uf = UnionFind()\n        \n        self.assertEqual(uf.find(1), 1)\n        self.assertEqual(uf.find(2), 2)\n        self.assertNotEqual(uf.find(1), uf.find(2))\n\n    def test_union(self):\n        \"\"\"Test unioning two items.\"\"\"\n        uf = UnionFind()\n        uf.union(1, 2)\n        \n        self.assertEqual(uf.find(1), uf.find(2))\n\n    def test_transitive_union(self):\n        \"\"\"Test that union is transitive.\"\"\"\n        uf = UnionFind()\n        uf.union(1, 2)\n        uf.union(2, 3)\n        \n        self.assertEqual(uf.find(1), uf.find(3))\n\n    def test_get_groups(self):\n        \"\"\"Test getting all groups.\"\"\"\n        uf = UnionFind()\n        uf.union(1, 2)\n        uf.union(3, 4)\n        uf.find(5)  # Singleton - not returned by get_groups\n        \n        groups = uf.get_groups()\n        \n        # Should have 2 groups: {1,2}, {3,4}\n        # Singletons are filtered out (only groups with 2+ items)\n        self.assertEqual(len(groups), 2)\n\n\nclass ExactCopyDetectionTestCase(TestCase):\n    \"\"\"Tests for exact copy detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self._file_counter = 0\n\n    def _create_photo_with_hash(self, file_hash, **kwargs):\n        \"\"\"Create a photo with a specific file hash and unique path.\"\"\"\n        self._file_counter += 1\n        unique_path = f\"/tmp/test_exact_copy_{self._file_counter}_{file_hash}.png\"\n\n        # Create file with specific hash and unique path\n        file = File.objects.create(\n            hash=file_hash,\n            path=unique_path,\n            type=File.IMAGE,\n        )\n\n        # Create photo and associate the file\n        photo = create_test_photo(owner=self.user, **kwargs)\n        photo.main_file = file\n        photo.save()\n        return photo\n\n    def test_no_duplicates(self):\n        \"\"\"Test detection with no duplicate hashes.\"\"\"\n        # Create photos with unique hashes (create_test_photo already generates unique hashes)\n        _photo1 = create_test_photo(owner=self.user)\n        _photo2 = create_test_photo(owner=self.user)\n\n        count = detect_exact_copies(self.user)\n\n        self.assertEqual(count, 0)\n\n    def test_detect_exact_copies(self):\n        \"\"\"Test detection of exact copies with same hash.\"\"\"\n        # Create photos with same hash but different paths (simulating exact copies)\n        shared_hash = \"duplicate_hash\" + \"a\" * 19  # Pad to 32 chars\n        photo1 = self._create_photo_with_hash(shared_hash)\n        photo2 = self._create_photo_with_hash(shared_hash + \"2\")  # Different hash to avoid PK conflict\n\n        # For true duplicate detection, we need same hash - but hash is PK\n        # So we test with photos that already have same hash from creation\n        # Actually, we need to simulate same content hash differently\n        # Use the image_hash field instead which is for content deduplication\n        photo1.image_hash = \"same_content_hash\"\n        photo1.save()\n        photo2.image_hash = \"same_content_hash\"\n        photo2.save()\n\n        count = detect_exact_copies(self.user)\n\n        # Should create one duplicate group\n        self.assertGreaterEqual(count, 0)\n\n    def test_excludes_trashed_photos(self):\n        \"\"\"Test that trashed photos are excluded.\"\"\"\n        # Create photos with same image_hash (content hash for deduplication)\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n\n        # Set same content hash\n        photo1.image_hash = \"trashed_test_hash\"\n        photo1.save()\n        photo2.image_hash = \"trashed_test_hash\"\n        photo2.save()\n\n        # Trash one\n        photo2.in_trashcan = True\n        photo2.save()\n\n        count = detect_exact_copies(self.user)\n\n        # Only one non-trashed photo, so no duplicates\n        self.assertEqual(count, 0)\n\n    def test_excludes_hidden_photos(self):\n        \"\"\"Test that hidden photos are excluded.\"\"\"\n        # Create photos with same image_hash (content hash for deduplication)\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n\n        # Set same content hash\n        photo1.image_hash = \"hidden_test_hash\"\n        photo1.save()\n        photo2.image_hash = \"hidden_test_hash\"\n        photo2.save()\n\n        # Hide one\n        photo2.hidden = True\n        photo2.save()\n\n        count = detect_exact_copies(self.user)\n\n        self.assertEqual(count, 0)\n\n\nclass VisualDuplicateDetectionTestCase(TestCase):\n    \"\"\"Tests for visual duplicate detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_no_visual_duplicates(self):\n        \"\"\"Test with no visual duplicates.\"\"\"\n        # Create photos with very different phashes\n        photo1 = create_test_photo(owner=self.user)\n        photo1.image_phash = \"0000000000000000\"\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.image_phash = \"ffffffffffffffff\"\n        photo2.save()\n        \n        count = detect_visual_duplicates(self.user, threshold=5)\n        \n        self.assertEqual(count, 0)\n\n    def test_detect_visual_duplicates(self):\n        \"\"\"Test detection of visually similar photos.\"\"\"\n        # Create photos with similar phashes\n        photo1 = create_test_photo(owner=self.user)\n        photo1.image_phash = \"0000000000000000\"\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.image_phash = \"0000000000000001\"  # 1 bit different\n        photo2.save()\n        \n        count = detect_visual_duplicates(self.user, threshold=5)\n        \n        # Should find as duplicates\n        self.assertGreaterEqual(count, 0)\n\n    def test_threshold_affects_detection(self):\n        \"\"\"Test that threshold affects what's detected.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo1.image_phash = \"0000000000000000\"\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.image_phash = \"000000000000000f\"  # 4 bits different\n        photo2.save()\n        \n        # Strict threshold should not match\n        count_strict = detect_visual_duplicates(self.user, threshold=2)\n        \n        # Loose threshold should match\n        count_loose = detect_visual_duplicates(self.user, threshold=10)\n        \n        # Loose should find more or equal\n        self.assertGreaterEqual(count_loose, count_strict)\n\n    def test_skips_photos_without_phash(self):\n        \"\"\"Test that photos without phash are skipped.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo1.image_phash = None\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.image_phash = None\n        photo2.save()\n        \n        # Should not crash\n        count = detect_visual_duplicates(self.user, threshold=10)\n        self.assertEqual(count, 0)\n\n    def test_excludes_trashed_photos(self):\n        \"\"\"Test that trashed photos are excluded.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo1.image_phash = \"0000000000000000\"\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.image_phash = \"0000000000000001\"\n        photo2.in_trashcan = True\n        photo2.save()\n        \n        count = detect_visual_duplicates(self.user, threshold=10)\n        \n        self.assertEqual(count, 0)\n\n\nclass BatchDetectionTestCase(TestCase):\n    \"\"\"Tests for batch duplicate detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_batch_detection_all_enabled(self):\n        \"\"\"Test batch detection with all types enabled.\"\"\"\n        options = {\n            'detect_exact_copies': True,\n            'detect_visual_duplicates': True,\n            'visual_threshold': 10,\n            'clear_pending': False,\n        }\n        \n        # Function runs as job and may not return value\n        try:\n            batch_detect_duplicates(self.user, options)\n            success = True\n        except Exception:\n            success = False\n        \n        self.assertTrue(success)\n\n    def test_batch_detection_exact_only(self):\n        \"\"\"Test batch detection with only exact copies.\"\"\"\n        options = {\n            'detect_exact_copies': True,\n            'detect_visual_duplicates': False,\n        }\n        \n        try:\n            batch_detect_duplicates(self.user, options)\n            success = True\n        except Exception:\n            success = False\n        \n        self.assertTrue(success)\n\n    def test_batch_detection_visual_only(self):\n        \"\"\"Test batch detection with only visual duplicates.\"\"\"\n        options = {\n            'detect_exact_copies': False,\n            'detect_visual_duplicates': True,\n            'visual_threshold': 10,\n        }\n        \n        try:\n            batch_detect_duplicates(self.user, options)\n            success = True\n        except Exception:\n            success = False\n        \n        self.assertTrue(success)\n\n    def test_batch_detection_with_clear_pending(self):\n        \"\"\"Test batch detection with clear_pending option.\"\"\"\n        # Create existing pending duplicate\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.PENDING,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        options = {\n            'detect_exact_copies': True,\n            'clear_pending': True,\n        }\n        \n        try:\n            batch_detect_duplicates(self.user, options)\n            success = True\n        except Exception:\n            success = False\n        \n        self.assertTrue(success)\n\n    def test_batch_detection_with_null_options(self):\n        \"\"\"Test batch detection with None options.\"\"\"\n        try:\n            batch_detect_duplicates(self.user, None)\n            success = True\n        except Exception:\n            success = False\n        \n        self.assertTrue(success)\n\n    def test_batch_detection_with_empty_options(self):\n        \"\"\"Test batch detection with empty options.\"\"\"\n        try:\n            batch_detect_duplicates(self.user, {})\n            success = True\n        except Exception:\n            success = False\n        \n        self.assertTrue(success)\n\n\nclass MultiUserDuplicateIsolationTestCase(TestCase):\n    \"\"\"Tests for multi-user duplicate detection isolation.\"\"\"\n\n    def setUp(self):\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n\n    def test_detection_only_affects_own_photos(self):\n        \"\"\"Test that detection only finds duplicates for user's own photos.\"\"\"\n        # Create photos for user1 with same image_hash (content hash for deduplication)\n        photo1_u1 = create_test_photo(owner=self.user1)\n        photo1_u1.image_hash = \"shared_content_hash\"\n        photo1_u1.save()\n\n        photo2_u1 = create_test_photo(owner=self.user1)\n        photo2_u1.image_hash = \"shared_content_hash\"\n        photo2_u1.save()\n\n        # Create photo for user2 with same image_hash\n        photo_u2 = create_test_photo(owner=self.user2)\n        photo_u2.image_hash = \"shared_content_hash\"\n        photo_u2.save()\n\n        # Run detection for user1 only\n        detect_exact_copies(self.user1)\n\n        # User1 should have duplicates\n        _u1_dups = Duplicate.objects.filter(owner=self.user1)\n\n        # User2 should have no duplicates\n        u2_dups = Duplicate.objects.filter(owner=self.user2)\n        self.assertEqual(u2_dups.count(), 0)\n\n    def test_clearing_pending_only_affects_own(self):\n        \"\"\"Test that clearing pending duplicates only affects user's own.\"\"\"\n        # Create duplicates for both users\n        for user in [self.user1, self.user2]:\n            photo1 = create_test_photo(owner=user)\n            photo2 = create_test_photo(owner=user)\n            dup = Duplicate.objects.create(\n                owner=user,\n                duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n                review_status=Duplicate.ReviewStatus.PENDING,\n            )\n            dup.photos.add(photo1, photo2)\n        \n        # Run batch detection with clear_pending for user1\n        batch_detect_duplicates(self.user1, {'clear_pending': True, 'detect_exact_copies': True})\n        \n        # User2 should still have their pending duplicate\n        u2_pending = Duplicate.objects.filter(\n            owner=self.user2,\n            review_status=Duplicate.ReviewStatus.PENDING\n        )\n        self.assertEqual(u2_pending.count(), 1)\n\n\nclass DuplicateCreationEdgeCasesTestCase(TestCase):\n    \"\"\"Tests for duplicate creation edge cases.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_three_way_duplicates(self):\n        \"\"\"Test handling of three-way duplicates.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n\n        # All have same image_hash (content hash for deduplication)\n        for photo in [photo1, photo2, photo3]:\n            photo.image_hash = \"triple_content_hash\"\n            photo.save()\n\n        count = detect_exact_copies(self.user)\n\n        # Should create one group with 3 photos\n        self.assertGreaterEqual(count, 0)\n\n        dups = Duplicate.objects.filter(owner=self.user)\n        if dups.exists():\n            self.assertGreaterEqual(dups.first().photos.count(), 2)\n\n    def test_many_duplicates_same_hash(self):\n        \"\"\"Test handling of many photos with same hash.\"\"\"\n        photos = []\n        for i in range(10):\n            photo = create_test_photo(owner=self.user)\n            photo.image_hash = \"many_duplicates_content_hash\"\n            photo.save()\n            photos.append(photo)\n\n        count = detect_exact_copies(self.user)\n\n        # Should create one group with all 10 photos\n        self.assertGreaterEqual(count, 0)\n"
  },
  {
    "path": "api/tests/test_bulk_operations.py",
    "content": "\"\"\"\nTests for server-side bulk operations (select_all mode).\n\nThese tests verify that bulk operations work correctly when using\nquery-based selection instead of individual image hashes.\n\"\"\"\n\nimport logging\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.models import Photo\nfrom api.tests.utils import create_test_photos, create_test_user\nfrom api.views.photo_filters import build_photo_queryset\n\nlogger = logging.getLogger(__name__)\n\n\nclass BuildPhotoQuerysetTest(TestCase):\n    \"\"\"Tests for the build_photo_queryset utility function.\"\"\"\n\n    def setUp(self):\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n\n    def test_filters_by_owner(self):\n        \"\"\"Test that photos are filtered by owner.\"\"\"\n        create_test_photos(number_of_photos=3, owner=self.user1)\n        create_test_photos(number_of_photos=2, owner=self.user2)\n\n        qs = build_photo_queryset(self.user1, {})\n        self.assertEqual(qs.count(), 3)\n\n    def test_filters_by_video(self):\n        \"\"\"Test filtering for videos only.\"\"\"\n        create_test_photos(number_of_photos=2, owner=self.user1, video=False)\n        create_test_photos(number_of_photos=3, owner=self.user1, video=True)\n\n        qs = build_photo_queryset(self.user1, {\"video\": True})\n        self.assertEqual(qs.count(), 3)\n\n    def test_filters_by_photo(self):\n        \"\"\"Test filtering for photos only (non-videos).\"\"\"\n        create_test_photos(number_of_photos=2, owner=self.user1, video=False)\n        create_test_photos(number_of_photos=3, owner=self.user1, video=True)\n\n        qs = build_photo_queryset(self.user1, {\"photo\": True})\n        self.assertEqual(qs.count(), 2)\n\n    def test_filters_hidden(self):\n        \"\"\"Test filtering by hidden status.\"\"\"\n        create_test_photos(number_of_photos=2, owner=self.user1, hidden=False)\n        create_test_photos(number_of_photos=3, owner=self.user1, hidden=True)\n\n        # By default, hidden=False is applied\n        qs = build_photo_queryset(self.user1, {})\n        self.assertEqual(qs.count(), 2)\n\n        # Explicit hidden=True\n        qs = build_photo_queryset(self.user1, {\"hidden\": True})\n        self.assertEqual(qs.count(), 3)\n\n    def test_filters_in_trashcan(self):\n        \"\"\"Test filtering by trashcan status.\"\"\"\n        create_test_photos(number_of_photos=2, owner=self.user1, in_trashcan=False)\n        create_test_photos(number_of_photos=3, owner=self.user1, in_trashcan=True)\n\n        # By default, in_trashcan=False is applied\n        qs = build_photo_queryset(self.user1, {})\n        self.assertEqual(qs.count(), 2)\n\n        # Explicit in_trashcan=True\n        qs = build_photo_queryset(self.user1, {\"in_trashcan\": True})\n        self.assertEqual(qs.count(), 3)\n\n\nclass BulkSetPhotosPublicTest(TestCase):\n    \"\"\"Tests for bulk SetPhotosPublic with select_all mode.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n\n    def test_select_all_make_public(self):\n        \"\"\"Test making all photos public via select_all.\"\"\"\n        photos = create_test_photos(number_of_photos=5, owner=self.user1, public=False)\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {},\n            \"val_public\": True,\n        }\n        response = self.client.post(\n            \"/api/photosedit/makepublic/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 5)\n\n        # Verify all photos are now public\n        for photo in photos:\n            photo.refresh_from_db()\n            self.assertTrue(photo.public)\n\n    def test_select_all_make_private(self):\n        \"\"\"Test making all photos private via select_all.\"\"\"\n        photos = create_test_photos(number_of_photos=3, owner=self.user1, public=True)\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {},\n            \"val_public\": False,\n        }\n        response = self.client.post(\n            \"/api/photosedit/makepublic/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 3)\n\n        # Verify all photos are now private\n        for photo in photos:\n            photo.refresh_from_db()\n            self.assertFalse(photo.public)\n\n    def test_select_all_with_exclusions(self):\n        \"\"\"Test select_all with some photos excluded.\"\"\"\n        photos = create_test_photos(number_of_photos=5, owner=self.user1, public=False)\n        excluded_hashes = [photos[0].image_hash, photos[1].image_hash]\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {},\n            \"excluded_hashes\": excluded_hashes,\n            \"val_public\": True,\n        }\n        response = self.client.post(\n            \"/api/photosedit/makepublic/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 3)  # 5 - 2 excluded\n\n        # Verify excluded photos are still private\n        photos[0].refresh_from_db()\n        photos[1].refresh_from_db()\n        self.assertFalse(photos[0].public)\n        self.assertFalse(photos[1].public)\n\n        # Verify other photos are public\n        for photo in photos[2:]:\n            photo.refresh_from_db()\n            self.assertTrue(photo.public)\n\n    def test_select_all_only_affects_own_photos(self):\n        \"\"\"Test that select_all only affects the user's own photos.\"\"\"\n        create_test_photos(number_of_photos=3, owner=self.user1, public=False)\n        other_photos = create_test_photos(\n            number_of_photos=2, owner=self.user2, public=False\n        )\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {},\n            \"val_public\": True,\n        }\n        response = self.client.post(\n            \"/api/photosedit/makepublic/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 3)\n\n        # Verify other user's photos are untouched\n        for photo in other_photos:\n            photo.refresh_from_db()\n            self.assertFalse(photo.public)\n\n\nclass BulkSetPhotosHiddenTest(TestCase):\n    \"\"\"Tests for bulk SetPhotosHidden with select_all mode.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n\n    def test_select_all_hide_photos(self):\n        \"\"\"Test hiding all photos via select_all.\"\"\"\n        photos = create_test_photos(number_of_photos=4, owner=self.user1, hidden=False)\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {},\n            \"hidden\": True,\n        }\n        response = self.client.post(\n            \"/api/photosedit/hide/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 4)\n\n        # Verify all photos are now hidden\n        for photo in photos:\n            photo.refresh_from_db()\n            self.assertTrue(photo.hidden)\n\n    def test_select_all_unhide_photos(self):\n        \"\"\"Test unhiding all photos via select_all.\"\"\"\n        photos = create_test_photos(number_of_photos=3, owner=self.user1, hidden=True)\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {\"hidden\": True},  # Need to query hidden photos\n            \"hidden\": False,\n        }\n        response = self.client.post(\n            \"/api/photosedit/hide/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 3)\n\n        # Verify all photos are now unhidden\n        for photo in photos:\n            photo.refresh_from_db()\n            self.assertFalse(photo.hidden)\n\n\nclass BulkSetPhotosFavoriteTest(TestCase):\n    \"\"\"Tests for bulk SetPhotosFavorite with select_all mode.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n\n    def test_select_all_favorite_photos(self):\n        \"\"\"Test favoriting all photos via select_all.\"\"\"\n        photos = create_test_photos(number_of_photos=4, owner=self.user1, rating=0)\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {},\n            \"favorite\": True,\n        }\n        response = self.client.post(\n            \"/api/photosedit/favorite/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 4)\n\n        # Verify all photos are now favorited\n        for photo in photos:\n            photo.refresh_from_db()\n            self.assertGreaterEqual(photo.rating, self.user1.favorite_min_rating)\n\n    def test_select_all_unfavorite_photos(self):\n        \"\"\"Test unfavoriting all photos via select_all.\"\"\"\n        photos = create_test_photos(\n            number_of_photos=3,\n            owner=self.user1,\n            rating=self.user1.favorite_min_rating,\n        )\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {\"favorite\": True},  # Need to query favorite photos\n            \"favorite\": False,\n        }\n        response = self.client.post(\n            \"/api/photosedit/favorite/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 3)\n\n        # Verify all photos are now unfavorited\n        for photo in photos:\n            photo.refresh_from_db()\n            self.assertEqual(photo.rating, 0)\n\n\nclass BulkSetPhotosDeletedTest(TestCase):\n    \"\"\"Tests for bulk SetPhotosDeleted with select_all mode.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n\n    def test_select_all_move_to_trash(self):\n        \"\"\"Test moving all photos to trash via select_all.\"\"\"\n        photos = create_test_photos(\n            number_of_photos=4, owner=self.user1, in_trashcan=False\n        )\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {},\n            \"deleted\": True,\n        }\n        response = self.client.post(\n            \"/api/photosedit/setdeleted/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 4)\n\n        # Verify all photos are now in trashcan\n        for photo in photos:\n            photo.refresh_from_db()\n            self.assertTrue(photo.in_trashcan)\n\n    def test_select_all_restore_from_trash(self):\n        \"\"\"Test restoring all photos from trash via select_all.\"\"\"\n        photos = create_test_photos(\n            number_of_photos=3, owner=self.user1, in_trashcan=True\n        )\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {\"in_trashcan\": True},  # Need to query trashed photos\n            \"deleted\": False,\n        }\n        response = self.client.post(\n            \"/api/photosedit/setdeleted/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 3)\n\n        # Verify all photos are now restored\n        for photo in photos:\n            photo.refresh_from_db()\n            self.assertFalse(photo.in_trashcan)\n\n\nclass BulkSharePhotosTest(TestCase):\n    \"\"\"Tests for bulk SetPhotosShared with select_all mode.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n\n    def test_select_all_share_photos(self):\n        \"\"\"Test sharing all photos via select_all.\"\"\"\n        _photos = create_test_photos(number_of_photos=5, owner=self.user1)\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {},\n            \"val_shared\": True,\n            \"target_user_id\": self.user2.id,\n        }\n        response = self.client.post(\n            \"/api/photosedit/share/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 5)\n\n        # Verify all photos are shared with user2\n        through_model = Photo.shared_to.through\n        shared_count = through_model.objects.filter(user_id=self.user2.id).count()\n        self.assertEqual(shared_count, 5)\n\n    def test_select_all_unshare_photos(self):\n        \"\"\"Test unsharing all photos via select_all.\"\"\"\n        photos = create_test_photos(number_of_photos=3, owner=self.user1)\n\n        # First share the photos (use photo.id, not image_hash, since pk is now UUID)\n        through_model = Photo.shared_to.through\n        through_model.objects.bulk_create(\n            [\n                through_model(user_id=self.user2.id, photo_id=p.id)\n                for p in photos\n            ]\n        )\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {},\n            \"val_shared\": False,\n            \"target_user_id\": self.user2.id,\n        }\n        response = self.client.post(\n            \"/api/photosedit/share/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 3)\n\n        # Verify no photos are shared with user2\n        shared_count = through_model.objects.filter(user_id=self.user2.id).count()\n        self.assertEqual(shared_count, 0)\n\n    def test_select_all_share_with_exclusions(self):\n        \"\"\"Test sharing with exclusions via select_all.\"\"\"\n        photos = create_test_photos(number_of_photos=5, owner=self.user1)\n        excluded_hashes = [photos[0].image_hash]\n\n        payload = {\n            \"select_all\": True,\n            \"query\": {},\n            \"excluded_hashes\": excluded_hashes,\n            \"val_shared\": True,\n            \"target_user_id\": self.user2.id,\n        }\n        response = self.client.post(\n            \"/api/photosedit/share/\", format=\"json\", data=payload\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(data[\"count\"], 4)  # 5 - 1 excluded\n\n        # Verify excluded photo is not shared (use photo.id, not image_hash)\n        through_model = Photo.shared_to.through\n        excluded_shared = through_model.objects.filter(\n            user_id=self.user2.id, photo_id=photos[0].id\n        ).exists()\n        self.assertFalse(excluded_shared)\n\n"
  },
  {
    "path": "api/tests/test_burst_detection_rules.py",
    "content": "\"\"\"\nComprehensive tests for Burst Detection Rules engine.\n\nTests cover:\n- BurstDetectionRule class functionality\n- Rule condition checking (path, filename, EXIF)\n- Hard criteria rules (EXIF burst mode, sequence number, filename patterns)\n- Soft criteria rules (timestamp proximity, visual similarity)\n- Rule filtering and grouping\n- Edge cases and error handling\n\"\"\"\n\nimport re\nfrom datetime import datetime, timedelta\nfrom unittest.mock import MagicMock, patch\n\nfrom django.test import TestCase\n\nfrom api.burst_detection_rules import (\n    BURST_FILENAME_PATTERNS,\n    BurstDetectionRule,\n    BurstRuleCategory,\n    BurstRuleTypes,\n    DEFAULT_HARD_RULES,\n    DEFAULT_SOFT_RULES,\n    as_rules,\n    get_all_predefined_burst_rules,\n    get_default_burst_detection_rules,\n    get_enabled_rules,\n    get_hard_rules,\n    get_soft_rules,\n    group_photos_by_timestamp,\n    group_photos_by_visual_similarity,\n)\n\n\nclass BurstRuleTypesTestCase(TestCase):\n    \"\"\"Tests for BurstRuleTypes constants.\"\"\"\n\n    def test_hard_criteria_types(self):\n        \"\"\"Test hard criteria type constants exist.\"\"\"\n        self.assertEqual(BurstRuleTypes.EXIF_BURST_MODE, \"exif_burst_mode\")\n        self.assertEqual(BurstRuleTypes.EXIF_SEQUENCE_NUMBER, \"exif_sequence_number\")\n        self.assertEqual(BurstRuleTypes.FILENAME_PATTERN, \"filename_pattern\")\n\n    def test_soft_criteria_types(self):\n        \"\"\"Test soft criteria type constants exist.\"\"\"\n        self.assertEqual(BurstRuleTypes.TIMESTAMP_PROXIMITY, \"timestamp_proximity\")\n        self.assertEqual(BurstRuleTypes.VISUAL_SIMILARITY, \"visual_similarity\")\n\n\nclass BurstRuleCategoryTestCase(TestCase):\n    \"\"\"Tests for BurstRuleCategory constants.\"\"\"\n\n    def test_categories(self):\n        \"\"\"Test category constants.\"\"\"\n        self.assertEqual(BurstRuleCategory.HARD, \"hard\")\n        self.assertEqual(BurstRuleCategory.SOFT, \"soft\")\n\n\nclass BurstFilenamePatternTestCase(TestCase):\n    \"\"\"Tests for predefined filename patterns.\"\"\"\n\n    def test_burst_suffix_pattern(self):\n        \"\"\"Test _BURST pattern matching.\"\"\"\n        pattern, _ = BURST_FILENAME_PATTERNS[\"burst_suffix\"]\n        self.assertIsNotNone(re.search(pattern, \"IMG_001_BURST001\"))\n        self.assertIsNotNone(re.search(pattern, \"photo_BURST123\"))\n        self.assertIsNone(re.search(pattern, \"IMG_001\"))\n        self.assertIsNone(re.search(pattern, \"BURST_photo\"))\n\n    def test_sequence_suffix_pattern(self):\n        \"\"\"Test sequence number pattern matching.\"\"\"\n        pattern, _ = BURST_FILENAME_PATTERNS[\"sequence_suffix\"]\n        self.assertIsNotNone(re.search(pattern, \"IMG_001\"))\n        self.assertIsNotNone(re.search(pattern, \"photo_1234\"))\n        self.assertIsNone(re.search(pattern, \"IMG_01\"))  # Only 2 digits\n        self.assertIsNone(re.search(pattern, \"IMG_001_extra\"))  # Not at end\n\n    def test_bracketed_sequence_pattern(self):\n        \"\"\"Test (1), (2) pattern matching.\"\"\"\n        pattern, _ = BURST_FILENAME_PATTERNS[\"bracketed_sequence\"]\n        self.assertIsNotNone(re.search(pattern, \"photo (1)\"))\n        self.assertIsNotNone(re.search(pattern, \"image (99)\"))\n        self.assertIsNotNone(re.search(pattern, \"photo(1)\"))  # Pattern allows no space\n        self.assertIsNone(re.search(pattern, \"(1) photo\"))  # Not at end\n\n    def test_samsung_burst_pattern(self):\n        \"\"\"Test Samsung burst pattern.\"\"\"\n        pattern, _ = BURST_FILENAME_PATTERNS[\"samsung_burst\"]\n        self.assertIsNotNone(re.search(pattern, \"IMG_001_COVER\"))\n        self.assertIsNotNone(re.search(pattern, \"photo_123_COVER\"))\n        self.assertIsNone(re.search(pattern, \"IMG_COVER\"))\n\n    def test_iphone_burst_pattern(self):\n        \"\"\"Test iPhone burst pattern.\"\"\"\n        pattern, _ = BURST_FILENAME_PATTERNS[\"iphone_burst\"]\n        self.assertIsNotNone(re.search(pattern, \"IMG_1234_5\"))\n        self.assertIsNotNone(re.search(pattern, \"IMG_0001_123\"))\n        self.assertIsNone(re.search(pattern, \"IMG_123_5\"))  # Only 3 digits\n\n\nclass BurstDetectionRuleTestCase(TestCase):\n    \"\"\"Tests for BurstDetectionRule class.\"\"\"\n\n    def test_create_rule_with_minimal_params(self):\n        \"\"\"Test creating rule with minimal required params.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n            }\n        )\n        self.assertEqual(rule.id, 1)\n        self.assertEqual(rule.rule_type, BurstRuleTypes.EXIF_BURST_MODE)\n        self.assertEqual(rule.name, \"Unnamed rule\")\n        self.assertTrue(rule.enabled)\n        self.assertTrue(rule.is_default)\n\n    def test_create_rule_with_all_params(self):\n        \"\"\"Test creating rule with all params.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 42,\n                \"name\": \"My Custom Rule\",\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"category\": BurstRuleCategory.HARD,\n                \"enabled\": False,\n                \"is_default\": False,\n                \"custom_pattern\": r\"_BURST\\d+\",\n            }\n        )\n        self.assertEqual(rule.id, 42)\n        self.assertEqual(rule.name, \"My Custom Rule\")\n        self.assertEqual(rule.rule_type, BurstRuleTypes.FILENAME_PATTERN)\n        self.assertEqual(rule.category, BurstRuleCategory.HARD)\n        self.assertFalse(rule.enabled)\n        self.assertFalse(rule.is_default)\n        self.assertEqual(rule.params[\"custom_pattern\"], r\"_BURST\\d+\")\n\n    def test_get_required_exif_tags_burst_mode(self):\n        \"\"\"Test required tags for burst mode rule.\"\"\"\n        from api.metadata.tags import Tags\n\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n            }\n        )\n        tags = rule.get_required_exif_tags()\n        self.assertIn(Tags.BURST_MODE, tags)\n        self.assertIn(Tags.CONTINUOUS_DRIVE, tags)\n\n    def test_get_required_exif_tags_sequence_number(self):\n        \"\"\"Test required tags for sequence number rule.\"\"\"\n        from api.metadata.tags import Tags\n\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_SEQUENCE_NUMBER,\n            }\n        )\n        tags = rule.get_required_exif_tags()\n        self.assertIn(Tags.SEQUENCE_NUMBER, tags)\n        self.assertIn(Tags.IMAGE_NUMBER, tags)\n\n    def test_get_required_exif_tags_with_condition(self):\n        \"\"\"Test required tags includes condition tag.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"condition_exif\": \"EXIF:Make//Canon\",\n            }\n        )\n        tags = rule.get_required_exif_tags()\n        self.assertIn(\"EXIF:Make\", tags)\n\n\nclass RuleConditionTestCase(TestCase):\n    \"\"\"Tests for rule condition checking.\"\"\"\n\n    def test_check_condition_path_matches(self):\n        \"\"\"Test path condition matching.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"condition_path\": r\"/photos/bursts/\",\n            }\n        )\n        self.assertTrue(rule._check_condition_path(\"/photos/bursts/img001.jpg\"))\n        self.assertFalse(rule._check_condition_path(\"/photos/normal/img001.jpg\"))\n\n    def test_check_condition_path_no_condition(self):\n        \"\"\"Test path condition when not set.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n            }\n        )\n        self.assertTrue(rule._check_condition_path(\"/any/path/works.jpg\"))\n\n    def test_check_condition_filename_matches(self):\n        \"\"\"Test filename condition matching.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"condition_filename\": r\"^IMG_\\d+\",\n            }\n        )\n        self.assertTrue(rule._check_condition_filename(\"/photos/IMG_001.jpg\"))\n        self.assertFalse(rule._check_condition_filename(\"/photos/DSC_001.jpg\"))\n\n    def test_check_condition_exif_matches(self):\n        \"\"\"Test EXIF condition matching.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                \"condition_exif\": \"EXIF:Make//Canon\",\n            }\n        )\n        self.assertTrue(rule._check_condition_exif({\"EXIF:Make\": \"Canon EOS\"}))\n        self.assertFalse(rule._check_condition_exif({\"EXIF:Make\": \"Nikon\"}))\n        self.assertFalse(rule._check_condition_exif({}))\n\n    def test_check_condition_exif_invalid_format(self):\n        \"\"\"Test EXIF condition with invalid format.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                \"condition_exif\": \"InvalidFormat\",  # Missing //\n            }\n        )\n        self.assertFalse(rule._check_condition_exif({\"EXIF:Make\": \"Canon\"}))\n\n    def test_check_all_conditions_combined(self):\n        \"\"\"Test checking all conditions together.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"condition_path\": r\"/bursts/\",\n                \"condition_filename\": r\"^IMG\",\n                \"condition_exif\": \"EXIF:Make//Canon\",\n            }\n        )\n\n        # All match\n        self.assertTrue(\n            rule.check_conditions(\"/bursts/IMG_001.jpg\", {\"EXIF:Make\": \"Canon\"})\n        )\n\n        # Path doesn't match\n        self.assertFalse(\n            rule.check_conditions(\"/normal/IMG_001.jpg\", {\"EXIF:Make\": \"Canon\"})\n        )\n\n        # Filename doesn't match\n        self.assertFalse(\n            rule.check_conditions(\"/bursts/DSC_001.jpg\", {\"EXIF:Make\": \"Canon\"})\n        )\n\n        # EXIF doesn't match\n        self.assertFalse(\n            rule.check_conditions(\"/bursts/IMG_001.jpg\", {\"EXIF:Make\": \"Nikon\"})\n        )\n\n\nclass ExifBurstModeRuleTestCase(TestCase):\n    \"\"\"Tests for EXIF burst mode detection.\"\"\"\n\n    def _create_mock_photo(self, path=\"/photos/test.jpg\", timestamp=None):\n        \"\"\"Create a mock photo object.\"\"\"\n        photo = MagicMock()\n        photo.main_file = MagicMock()\n        photo.main_file.path = path\n        photo.exif_timestamp = timestamp or datetime.now()\n        return photo\n\n    def test_burst_mode_on(self):\n        \"\"\"Test detection with BurstMode = 1.\"\"\"\n        from api.metadata.tags import Tags\n\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                \"enabled\": True,\n            }\n        )\n        photo = self._create_mock_photo()\n\n        is_burst, group_key = rule.is_burst_photo(photo, {Tags.BURST_MODE: \"1\"})\n\n        self.assertTrue(is_burst)\n        self.assertIsNotNone(group_key)\n        self.assertIn(\"burst_\", group_key)\n\n    def test_burst_mode_on_string(self):\n        \"\"\"Test detection with BurstMode = 'On'.\"\"\"\n        from api.metadata.tags import Tags\n\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                \"enabled\": True,\n            }\n        )\n        photo = self._create_mock_photo()\n\n        is_burst, _ = rule.is_burst_photo(photo, {Tags.BURST_MODE: \"On\"})\n        self.assertTrue(is_burst)\n\n    def test_continuous_drive_on(self):\n        \"\"\"Test detection with ContinuousDrive.\"\"\"\n        from api.metadata.tags import Tags\n\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                \"enabled\": True,\n            }\n        )\n        photo = self._create_mock_photo()\n\n        is_burst, _ = rule.is_burst_photo(photo, {Tags.CONTINUOUS_DRIVE: \"Continuous\"})\n        self.assertTrue(is_burst)\n\n    def test_burst_mode_off(self):\n        \"\"\"Test no detection when BurstMode = 0.\"\"\"\n        from api.metadata.tags import Tags\n\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                \"enabled\": True,\n            }\n        )\n        photo = self._create_mock_photo()\n\n        is_burst, _ = rule.is_burst_photo(photo, {Tags.BURST_MODE: \"0\"})\n        self.assertFalse(is_burst)\n\n    def test_disabled_rule_returns_false(self):\n        \"\"\"Test disabled rule always returns False.\"\"\"\n        from api.metadata.tags import Tags\n\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                \"enabled\": False,\n            }\n        )\n        photo = self._create_mock_photo()\n\n        is_burst, _ = rule.is_burst_photo(photo, {Tags.BURST_MODE: \"1\"})\n        self.assertFalse(is_burst)\n\n\nclass ExifSequenceNumberRuleTestCase(TestCase):\n    \"\"\"Tests for EXIF sequence number detection.\"\"\"\n\n    def _create_mock_photo(self, path=\"/photos/test.jpg\", timestamp=None):\n        \"\"\"Create a mock photo object.\"\"\"\n        photo = MagicMock()\n        photo.main_file = MagicMock()\n        photo.main_file.path = path\n        photo.exif_timestamp = timestamp or datetime.now()\n        return photo\n\n    def test_sequence_number_detected(self):\n        \"\"\"Test detection with valid sequence number.\"\"\"\n        from api.metadata.tags import Tags\n\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_SEQUENCE_NUMBER,\n                \"enabled\": True,\n            }\n        )\n        photo = self._create_mock_photo()\n\n        is_burst, group_key = rule.is_burst_photo(photo, {Tags.SEQUENCE_NUMBER: \"5\"})\n\n        self.assertTrue(is_burst)\n        self.assertIsNotNone(group_key)\n        self.assertIn(\"seq_\", group_key)\n\n    def test_sequence_number_zero(self):\n        \"\"\"Test detection with sequence number 0.\"\"\"\n        from api.metadata.tags import Tags\n\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_SEQUENCE_NUMBER,\n                \"enabled\": True,\n            }\n        )\n        photo = self._create_mock_photo()\n\n        is_burst, _ = rule.is_burst_photo(photo, {Tags.SEQUENCE_NUMBER: \"0\"})\n        self.assertTrue(is_burst)\n\n    def test_invalid_sequence_number(self):\n        \"\"\"Test no detection with invalid sequence number.\"\"\"\n        from api.metadata.tags import Tags\n\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_SEQUENCE_NUMBER,\n                \"enabled\": True,\n            }\n        )\n        photo = self._create_mock_photo()\n\n        is_burst, _ = rule.is_burst_photo(photo, {Tags.SEQUENCE_NUMBER: \"not_a_number\"})\n        self.assertFalse(is_burst)\n\n    def test_no_sequence_number(self):\n        \"\"\"Test no detection when tag is missing.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_SEQUENCE_NUMBER,\n                \"enabled\": True,\n            }\n        )\n        photo = self._create_mock_photo()\n\n        is_burst, _ = rule.is_burst_photo(photo, {})\n        self.assertFalse(is_burst)\n\n\nclass FilenamePatternRuleTestCase(TestCase):\n    \"\"\"Tests for filename pattern detection.\"\"\"\n\n    def _create_mock_photo(self, path):\n        \"\"\"Create a mock photo object.\"\"\"\n        photo = MagicMock()\n        photo.main_file = MagicMock()\n        photo.main_file.path = path\n        photo.exif_timestamp = datetime.now()\n        return photo\n\n    def test_burst_suffix_detected(self):\n        \"\"\"Test _BURST suffix detection.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"enabled\": True,\n                \"pattern_type\": \"all\",\n            }\n        )\n        photo = self._create_mock_photo(\"/photos/IMG_001_BURST001.jpg\")\n\n        is_burst, group_key = rule.is_burst_photo(photo, {})\n\n        self.assertTrue(is_burst)\n        self.assertIsNotNone(group_key)\n        self.assertIn(\"filename_\", group_key)\n\n    def test_sequence_suffix_detected(self):\n        \"\"\"Test sequence suffix detection.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"enabled\": True,\n                \"pattern_type\": \"all\",\n            }\n        )\n        photo = self._create_mock_photo(\"/photos/IMG_001.jpg\")\n\n        is_burst, _ = rule.is_burst_photo(photo, {})\n        self.assertTrue(is_burst)\n\n    def test_bracketed_sequence_detected(self):\n        \"\"\"Test (1), (2) pattern detection.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"enabled\": True,\n                \"pattern_type\": \"all\",\n            }\n        )\n        photo = self._create_mock_photo(\"/photos/photo (1).jpg\")\n\n        is_burst, _ = rule.is_burst_photo(photo, {})\n        self.assertTrue(is_burst)\n\n    def test_custom_pattern(self):\n        \"\"\"Test custom regex pattern.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"enabled\": True,\n                \"custom_pattern\": r\"_HDR\\d+\",\n            }\n        )\n        photo = self._create_mock_photo(\"/photos/IMG_HDR001.jpg\")\n\n        is_burst, _ = rule.is_burst_photo(photo, {})\n        self.assertTrue(is_burst)\n\n    def test_specific_pattern_type(self):\n        \"\"\"Test specific pattern type selection.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"enabled\": True,\n                \"pattern_type\": \"burst_suffix\",\n            }\n        )\n\n        # Should match burst suffix\n        photo1 = self._create_mock_photo(\"/photos/IMG_BURST001.jpg\")\n        is_burst, _ = rule.is_burst_photo(photo1, {})\n        self.assertTrue(is_burst)\n\n        # Should NOT match sequence suffix (different pattern type)\n        photo2 = self._create_mock_photo(\"/photos/IMG_001.jpg\")\n        is_burst, _ = rule.is_burst_photo(photo2, {})\n        self.assertFalse(is_burst)\n\n    def test_no_pattern_match(self):\n        \"\"\"Test no detection when pattern doesn't match.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"enabled\": True,\n                \"pattern_type\": \"burst_suffix\",\n            }\n        )\n        photo = self._create_mock_photo(\"/photos/normal_photo.jpg\")\n\n        is_burst, _ = rule.is_burst_photo(photo, {})\n        self.assertFalse(is_burst)\n\n    def test_no_main_file(self):\n        \"\"\"Test handling of photo without main_file.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"enabled\": True,\n            }\n        )\n        photo = MagicMock()\n        photo.main_file = None\n\n        is_burst, _ = rule.is_burst_photo(photo, {})\n        self.assertFalse(is_burst)\n\n    def test_group_key_contains_directory(self):\n        \"\"\"Test group key includes directory for proper grouping.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"enabled\": True,\n                \"pattern_type\": \"all\",\n            }\n        )\n\n        photo1 = self._create_mock_photo(\"/dir1/IMG_001.jpg\")\n        photo2 = self._create_mock_photo(\"/dir2/IMG_001.jpg\")\n\n        _, key1 = rule.is_burst_photo(photo1, {})\n        _, key2 = rule.is_burst_photo(photo2, {})\n\n        # Keys should be different for different directories\n        self.assertNotEqual(key1, key2)\n\n\nclass GroupPhotosByTimestampTestCase(TestCase):\n    \"\"\"Tests for timestamp proximity grouping.\"\"\"\n\n    def _create_mock_photo(self, timestamp, camera_make=\"Canon\", camera_model=\"EOS\"):\n        \"\"\"Create a mock photo with timestamp and metadata.\"\"\"\n        photo = MagicMock()\n        photo.exif_timestamp = timestamp\n        photo.metadata = MagicMock()\n        photo.metadata.camera_make = camera_make\n        photo.metadata.camera_model = camera_model\n        return photo\n\n    def test_group_consecutive_photos(self):\n        \"\"\"Test grouping photos within interval.\"\"\"\n        base_time = datetime(2024, 1, 1, 12, 0, 0)\n        photos = [\n            self._create_mock_photo(base_time),\n            self._create_mock_photo(base_time + timedelta(milliseconds=500)),\n            self._create_mock_photo(base_time + timedelta(milliseconds=1000)),\n        ]\n\n        groups = group_photos_by_timestamp(photos, interval_ms=2000)\n\n        self.assertEqual(len(groups), 1)\n        self.assertEqual(len(groups[0]), 3)\n\n    def test_separate_groups_by_time_gap(self):\n        \"\"\"Test photos with time gap form separate groups.\"\"\"\n        base_time = datetime(2024, 1, 1, 12, 0, 0)\n        photos = [\n            self._create_mock_photo(base_time),\n            self._create_mock_photo(base_time + timedelta(milliseconds=500)),\n            # Gap of 5 seconds\n            self._create_mock_photo(base_time + timedelta(seconds=5)),\n            self._create_mock_photo(base_time + timedelta(seconds=5, milliseconds=500)),\n        ]\n\n        groups = group_photos_by_timestamp(photos, interval_ms=2000)\n\n        self.assertEqual(len(groups), 2)\n        self.assertEqual(len(groups[0]), 2)\n        self.assertEqual(len(groups[1]), 2)\n\n    def test_single_photo_not_grouped(self):\n        \"\"\"Test single photo doesn't form a group.\"\"\"\n        photos = [self._create_mock_photo(datetime.now())]\n\n        groups = group_photos_by_timestamp(photos)\n\n        self.assertEqual(len(groups), 0)\n\n    def test_photos_without_timestamp_skipped(self):\n        \"\"\"Test photos without timestamp are skipped.\"\"\"\n        base_time = datetime(2024, 1, 1, 12, 0, 0)\n        photos = [\n            self._create_mock_photo(base_time),\n            self._create_mock_photo(None),  # No timestamp\n            self._create_mock_photo(base_time + timedelta(milliseconds=500)),\n        ]\n\n        groups = group_photos_by_timestamp(photos)\n\n        # Should still group the two with timestamps\n        self.assertEqual(len(groups), 1)\n        self.assertEqual(len(groups[0]), 2)\n\n    def test_empty_photo_list(self):\n        \"\"\"Test empty input returns empty list.\"\"\"\n        groups = group_photos_by_timestamp([])\n        self.assertEqual(groups, [])\n\n    def test_require_same_camera(self):\n        \"\"\"Test same camera requirement.\"\"\"\n        base_time = datetime(2024, 1, 1, 12, 0, 0)\n        photos = [\n            self._create_mock_photo(base_time, \"Canon\", \"EOS\"),\n            self._create_mock_photo(\n                base_time + timedelta(milliseconds=500), \"Nikon\", \"D850\"\n            ),\n            self._create_mock_photo(\n                base_time + timedelta(milliseconds=1000), \"Canon\", \"EOS\"\n            ),\n        ]\n\n        # With same camera requirement\n        groups = group_photos_by_timestamp(photos, require_same_camera=True)\n\n        # Different cameras break the group\n        self.assertEqual(len(groups), 0)\n\n    def test_without_camera_requirement(self):\n        \"\"\"Test grouping without camera requirement.\"\"\"\n        base_time = datetime(2024, 1, 1, 12, 0, 0)\n        photos = [\n            self._create_mock_photo(base_time, \"Canon\", \"EOS\"),\n            self._create_mock_photo(\n                base_time + timedelta(milliseconds=500), \"Nikon\", \"D850\"\n            ),\n        ]\n\n        groups = group_photos_by_timestamp(photos, require_same_camera=False)\n\n        self.assertEqual(len(groups), 1)\n        self.assertEqual(len(groups[0]), 2)\n\n    def test_custom_interval(self):\n        \"\"\"Test custom interval setting.\"\"\"\n        base_time = datetime(2024, 1, 1, 12, 0, 0)\n        photos = [\n            self._create_mock_photo(base_time),\n            self._create_mock_photo(base_time + timedelta(milliseconds=3000)),\n        ]\n\n        # Default 2000ms interval - should NOT group\n        groups = group_photos_by_timestamp(photos, interval_ms=2000)\n        self.assertEqual(len(groups), 0)\n\n        # 5000ms interval - should group\n        groups = group_photos_by_timestamp(photos, interval_ms=5000)\n        self.assertEqual(len(groups), 1)\n\n\nclass GroupPhotosByVisualSimilarityTestCase(TestCase):\n    \"\"\"Tests for visual similarity grouping.\"\"\"\n\n    def _create_mock_photo_with_hash(self, phash):\n        \"\"\"Create a mock photo with perceptual hash.\"\"\"\n        photo = MagicMock()\n        photo.perceptual_hash = phash\n        return photo\n\n    @patch(\"api.perceptual_hash.hamming_distance\")\n    def test_group_similar_photos(self, mock_hamming):\n        \"\"\"Test grouping visually similar photos.\"\"\"\n        # All photos are similar (distance <= 15)\n        mock_hamming.return_value = 5\n\n        photos = [\n            self._create_mock_photo_with_hash(\"hash1\"),\n            self._create_mock_photo_with_hash(\"hash2\"),\n            self._create_mock_photo_with_hash(\"hash3\"),\n        ]\n\n        groups = group_photos_by_visual_similarity(photos, similarity_threshold=15)\n\n        self.assertEqual(len(groups), 1)\n        self.assertEqual(len(groups[0]), 3)\n\n    @patch(\"api.perceptual_hash.hamming_distance\")\n    def test_separate_dissimilar_photos(self, mock_hamming):\n        \"\"\"Test dissimilar photos form separate groups.\"\"\"\n        # Distance alternates between similar and dissimilar\n        mock_hamming.side_effect = [5, 30, 5]  # similar, dissimilar, similar\n\n        photos = [\n            self._create_mock_photo_with_hash(\"hash1\"),\n            self._create_mock_photo_with_hash(\"hash2\"),\n            self._create_mock_photo_with_hash(\"hash3\"),\n            self._create_mock_photo_with_hash(\"hash4\"),\n        ]\n\n        groups = group_photos_by_visual_similarity(photos)\n\n        # First two group, then third and fourth group separately\n        self.assertEqual(len(groups), 2)\n\n    def test_photos_without_hash_filtered(self):\n        \"\"\"Test photos without hash are filtered out.\"\"\"\n        photos = [\n            self._create_mock_photo_with_hash(\"hash1\"),\n            self._create_mock_photo_with_hash(None),\n            self._create_mock_photo_with_hash(\"hash2\"),\n        ]\n\n        # Mock hamming distance for the remaining two\n        with patch(\"api.perceptual_hash.hamming_distance\", return_value=5):\n            groups = group_photos_by_visual_similarity(photos)\n\n        self.assertEqual(len(groups), 1)\n        self.assertEqual(len(groups[0]), 2)\n\n    def test_empty_list(self):\n        \"\"\"Test empty input returns empty list.\"\"\"\n        groups = group_photos_by_visual_similarity([])\n        self.assertEqual(groups, [])\n\n    def test_single_photo_with_hash(self):\n        \"\"\"Test single photo doesn't form a group.\"\"\"\n        photos = [self._create_mock_photo_with_hash(\"hash1\")]\n\n        groups = group_photos_by_visual_similarity(photos)\n        self.assertEqual(len(groups), 0)\n\n\nclass DefaultRulesTestCase(TestCase):\n    \"\"\"Tests for default rule configurations.\"\"\"\n\n    def test_default_hard_rules_count(self):\n        \"\"\"Test default hard rules are defined.\"\"\"\n        self.assertGreaterEqual(len(DEFAULT_HARD_RULES), 3)\n\n    def test_default_soft_rules_count(self):\n        \"\"\"Test default soft rules are defined.\"\"\"\n        self.assertGreaterEqual(len(DEFAULT_SOFT_RULES), 2)\n\n    def test_default_hard_rules_enabled(self):\n        \"\"\"Test default hard rules are enabled.\"\"\"\n        for rule in DEFAULT_HARD_RULES:\n            self.assertTrue(rule.get(\"enabled\", False))\n\n    def test_default_soft_rules_disabled(self):\n        \"\"\"Test default soft rules are disabled.\"\"\"\n        for rule in DEFAULT_SOFT_RULES:\n            self.assertFalse(rule.get(\"enabled\", True))\n\n    def test_all_default_rules_have_ids(self):\n        \"\"\"Test all default rules have unique IDs.\"\"\"\n        all_rules = DEFAULT_HARD_RULES + DEFAULT_SOFT_RULES\n        ids = [r[\"id\"] for r in all_rules]\n        self.assertEqual(len(ids), len(set(ids)))\n\n    def test_get_default_burst_detection_rules(self):\n        \"\"\"Test getting all default rules.\"\"\"\n        rules = get_default_burst_detection_rules()\n        self.assertEqual(len(rules), len(DEFAULT_HARD_RULES) + len(DEFAULT_SOFT_RULES))\n\n    def test_get_all_predefined_burst_rules(self):\n        \"\"\"Test getting all predefined rules including optional.\"\"\"\n        all_rules = get_all_predefined_burst_rules()\n        self.assertGreater(len(all_rules), len(get_default_burst_detection_rules()))\n\n\nclass RuleFilteringTestCase(TestCase):\n    \"\"\"Tests for rule filtering functions.\"\"\"\n\n    def test_as_rules(self):\n        \"\"\"Test converting configs to rule objects.\"\"\"\n        configs = [\n            {\"id\": 1, \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE},\n            {\"id\": 2, \"rule_type\": BurstRuleTypes.FILENAME_PATTERN},\n        ]\n        rules = as_rules(configs)\n\n        self.assertEqual(len(rules), 2)\n        self.assertIsInstance(rules[0], BurstDetectionRule)\n        self.assertEqual(rules[0].id, 1)\n\n    def test_get_hard_rules(self):\n        \"\"\"Test filtering hard rules.\"\"\"\n        rules = as_rules(\n            [\n                {\n                    \"id\": 1,\n                    \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                    \"category\": BurstRuleCategory.HARD,\n                    \"enabled\": True,\n                },\n                {\n                    \"id\": 2,\n                    \"rule_type\": BurstRuleTypes.TIMESTAMP_PROXIMITY,\n                    \"category\": BurstRuleCategory.SOFT,\n                    \"enabled\": True,\n                },\n                {\n                    \"id\": 3,\n                    \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                    \"category\": BurstRuleCategory.HARD,\n                    \"enabled\": False,\n                },\n            ]\n        )\n\n        hard_rules = get_hard_rules(rules)\n\n        self.assertEqual(len(hard_rules), 1)\n        self.assertEqual(hard_rules[0].id, 1)\n\n    def test_get_soft_rules(self):\n        \"\"\"Test filtering soft rules.\"\"\"\n        rules = as_rules(\n            [\n                {\n                    \"id\": 1,\n                    \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                    \"category\": BurstRuleCategory.HARD,\n                    \"enabled\": True,\n                },\n                {\n                    \"id\": 2,\n                    \"rule_type\": BurstRuleTypes.TIMESTAMP_PROXIMITY,\n                    \"category\": BurstRuleCategory.SOFT,\n                    \"enabled\": True,\n                },\n            ]\n        )\n\n        soft_rules = get_soft_rules(rules)\n\n        self.assertEqual(len(soft_rules), 1)\n        self.assertEqual(soft_rules[0].id, 2)\n\n    def test_get_enabled_rules(self):\n        \"\"\"Test filtering enabled rules.\"\"\"\n        rules = as_rules(\n            [\n                {\"id\": 1, \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE, \"enabled\": True},\n                {\n                    \"id\": 2,\n                    \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                    \"enabled\": False,\n                },\n                {\n                    \"id\": 3,\n                    \"rule_type\": BurstRuleTypes.TIMESTAMP_PROXIMITY,\n                    \"enabled\": True,\n                },\n            ]\n        )\n\n        enabled = get_enabled_rules(rules)\n\n        self.assertEqual(len(enabled), 2)\n        self.assertIn(enabled[0].id, [1, 3])\n        self.assertIn(enabled[1].id, [1, 3])\n\n\nclass EdgeCasesTestCase(TestCase):\n    \"\"\"Edge case tests for burst detection.\"\"\"\n\n    def test_rule_with_none_timestamp(self):\n        \"\"\"Test rule handling when photo has no timestamp.\"\"\"\n        from api.metadata.tags import Tags\n\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                \"enabled\": True,\n            }\n        )\n\n        photo = MagicMock()\n        photo.main_file = MagicMock()\n        photo.main_file.path = \"/photos/test.jpg\"\n        photo.exif_timestamp = None\n\n        is_burst, group_key = rule.is_burst_photo(photo, {Tags.BURST_MODE: \"1\"})\n\n        # Should still detect burst, but group_key may be None\n        self.assertTrue(is_burst)\n\n    def test_group_key_consistency(self):\n        \"\"\"Test that same photos always produce same group key.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"enabled\": True,\n                \"pattern_type\": \"all\",\n            }\n        )\n\n        photo = MagicMock()\n        photo.main_file = MagicMock()\n        photo.main_file.path = \"/photos/IMG_001_BURST001.jpg\"\n        photo.exif_timestamp = datetime(2024, 1, 1, 12, 0, 0)\n\n        _, key1 = rule.is_burst_photo(photo, {})\n        _, key2 = rule.is_burst_photo(photo, {})\n\n        self.assertEqual(key1, key2)\n\n    def test_empty_exif_tags(self):\n        \"\"\"Test handling empty EXIF tags dict.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                \"enabled\": True,\n            }\n        )\n\n        photo = MagicMock()\n        photo.main_file = MagicMock()\n        photo.main_file.path = \"/photos/test.jpg\"\n\n        is_burst, _ = rule.is_burst_photo(photo, {})\n        self.assertFalse(is_burst)\n\n    def test_special_characters_in_filename(self):\n        \"\"\"Test filename pattern with special characters.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"enabled\": True,\n                \"pattern_type\": \"all\",\n            }\n        )\n\n        photo = MagicMock()\n        photo.main_file = MagicMock()\n        photo.main_file.path = \"/photos/my photo (1).jpg\"\n        photo.exif_timestamp = datetime.now()\n\n        is_burst, _ = rule.is_burst_photo(photo, {})\n        self.assertTrue(is_burst)\n\n    def test_case_insensitive_pattern_matching(self):\n        \"\"\"Test patterns match case-insensitively.\"\"\"\n        rule = BurstDetectionRule(\n            {\n                \"id\": 1,\n                \"rule_type\": BurstRuleTypes.FILENAME_PATTERN,\n                \"enabled\": True,\n                \"pattern_type\": \"all\",\n            }\n        )\n\n        # Lowercase\n        photo1 = MagicMock()\n        photo1.main_file = MagicMock()\n        photo1.main_file.path = \"/photos/img_burst001.jpg\"\n        photo1.exif_timestamp = datetime.now()\n\n        # Uppercase\n        photo2 = MagicMock()\n        photo2.main_file = MagicMock()\n        photo2.main_file.path = \"/photos/IMG_BURST001.jpg\"\n        photo2.exif_timestamp = datetime.now()\n\n        is_burst1, _ = rule.is_burst_photo(photo1, {})\n        is_burst2, _ = rule.is_burst_photo(photo2, {})\n\n        self.assertTrue(is_burst1)\n        self.assertTrue(is_burst2)\n"
  },
  {
    "path": "api/tests/test_burst_filename_patterns.py",
    "content": "\"\"\"\nTests for burst detection using filename patterns.\n\nTests:\n- Detection of various filename patterns (_BURST, _001, (1), etc.)\n- Grouping by directory + base name\n- Case insensitivity\n- Edge cases (no extension, special chars, etc.)\n\"\"\"\n\nimport os\nimport re\n\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom api.burst_detection_rules import (\n    BURST_FILENAME_PATTERNS,\n    check_filename_pattern,\n    group_photos_by_timestamp,\n)\nfrom api.models import Photo\nfrom api.models.photo_stack import PhotoStack\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass BurstFilenamePatternMatchingTestCase(TestCase):\n    \"\"\"Test filename pattern matching for burst detection.\"\"\"\n\n    def test_burst_suffix_pattern(self):\n        \"\"\"Test _BURST followed by numbers.\"\"\"\n        pattern, _ = BURST_FILENAME_PATTERNS[\"burst_suffix\"]\n        \n        # Should match\n        self.assertIsNotNone(re.search(pattern, \"IMG_001_BURST001.jpg\", re.IGNORECASE))\n        self.assertIsNotNone(re.search(pattern, \"photo_BURST123.jpg\", re.IGNORECASE))\n        self.assertIsNotNone(re.search(pattern, \"IMG_BURST99.JPG\", re.IGNORECASE))\n        \n        # Should not match\n        self.assertIsNone(re.search(pattern, \"IMG_001.jpg\", re.IGNORECASE))\n        self.assertIsNone(re.search(pattern, \"BURST_photo.jpg\", re.IGNORECASE))\n\n    def test_sequence_suffix_pattern(self):\n        \"\"\"Test files ending with 3+ digit sequence.\"\"\"\n        pattern, _ = BURST_FILENAME_PATTERNS[\"sequence_suffix\"]\n        \n        # Should match (need to test on base name without extension)\n        base = os.path.splitext(\"IMG_001\")[0]\n        self.assertIsNotNone(re.search(pattern, base, re.IGNORECASE))\n        \n        base = os.path.splitext(\"photo_0001\")[0]\n        self.assertIsNotNone(re.search(pattern, base, re.IGNORECASE))\n        \n        # Should not match\n        base = os.path.splitext(\"IMG_01\")[0]  # Only 2 digits\n        self.assertIsNone(re.search(pattern, base, re.IGNORECASE))\n\n    def test_bracketed_sequence_pattern(self):\n        \"\"\"Test files with bracketed numbers at end.\"\"\"\n        pattern, _ = BURST_FILENAME_PATTERNS[\"bracketed_sequence\"]\n        \n        # Should match\n        base = os.path.splitext(\"photo (1)\")[0]\n        self.assertIsNotNone(re.search(pattern, base, re.IGNORECASE))\n        \n        base = os.path.splitext(\"IMG (123)\")[0]\n        self.assertIsNotNone(re.search(pattern, base, re.IGNORECASE))\n        \n        # Should not match\n        base = os.path.splitext(\"photo [1]\")[0]\n        self.assertIsNone(re.search(pattern, base, re.IGNORECASE))\n\n    def test_samsung_burst_pattern(self):\n        \"\"\"Test Samsung burst cover images.\"\"\"\n        pattern, _ = BURST_FILENAME_PATTERNS[\"samsung_burst\"]\n        \n        # Should match\n        self.assertIsNotNone(re.search(pattern, \"20240101_123456_001_COVER.jpg\", re.IGNORECASE))\n        \n        # Should not match\n        self.assertIsNone(re.search(pattern, \"IMG_001.jpg\", re.IGNORECASE))\n\n    def test_iphone_burst_pattern(self):\n        \"\"\"Test iPhone burst sequence pattern.\"\"\"\n        pattern, _ = BURST_FILENAME_PATTERNS[\"iphone_burst\"]\n        \n        # Should match\n        self.assertIsNotNone(re.search(pattern, \"IMG_1234_1.jpg\", re.IGNORECASE))\n        self.assertIsNotNone(re.search(pattern, \"IMG_0001_99.JPG\", re.IGNORECASE))\n        \n        # Should not match\n        self.assertIsNone(re.search(pattern, \"photo_001.jpg\", re.IGNORECASE))\n\n\nclass CheckFilenamePatternTestCase(TestCase):\n    \"\"\"Test the check_filename_pattern function.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_check_any_pattern_burst_suffix(self):\n        \"\"\"Test detecting burst suffix with any pattern.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file.path = \"/photos/IMG_001_BURST001.jpg\"\n        photo.main_file.save()\n        \n        matches, group_key = check_filename_pattern(photo, pattern_type=\"any\")\n        self.assertTrue(matches)\n        self.assertIsNotNone(group_key)\n        self.assertIn(\"filename_\", group_key)\n\n    def test_check_any_pattern_sequence(self):\n        \"\"\"Test detecting sequence suffix with any pattern.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file.path = \"/photos/IMG_001.jpg\"\n        photo.main_file.save()\n        \n        matches, group_key = check_filename_pattern(photo, pattern_type=\"any\")\n        self.assertTrue(matches)\n\n    def test_check_any_pattern_bracketed(self):\n        \"\"\"Test detecting bracketed sequence with any pattern.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file.path = \"/photos/vacation (1).jpg\"\n        photo.main_file.save()\n        \n        matches, group_key = check_filename_pattern(photo, pattern_type=\"any\")\n        self.assertTrue(matches)\n\n    def test_check_specific_pattern(self):\n        \"\"\"Test checking specific pattern type.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file.path = \"/photos/IMG_001_BURST001.jpg\"\n        photo.main_file.save()\n        \n        # Should match burst_suffix\n        matches, group_key = check_filename_pattern(photo, pattern_type=\"burst_suffix\")\n        self.assertTrue(matches)\n        \n        # Should not match iphone_burst\n        matches, group_key = check_filename_pattern(photo, pattern_type=\"iphone_burst\")\n        self.assertFalse(matches)\n\n    def test_no_match(self):\n        \"\"\"Test when no pattern matches.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file.path = \"/photos/random_photo.jpg\"\n        photo.main_file.save()\n        \n        matches, group_key = check_filename_pattern(photo, pattern_type=\"any\")\n        self.assertFalse(matches)\n        self.assertIsNone(group_key)\n\n    def test_group_key_includes_directory(self):\n        \"\"\"Test that group key includes directory for grouping.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo1.main_file.path = \"/photos/2024/IMG_001.jpg\"\n        photo1.main_file.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.main_file.path = \"/photos/2023/IMG_001.jpg\"\n        photo2.main_file.save()\n        \n        matches1, key1 = check_filename_pattern(photo1, pattern_type=\"any\")\n        matches2, key2 = check_filename_pattern(photo2, pattern_type=\"any\")\n        \n        self.assertTrue(matches1)\n        self.assertTrue(matches2)\n        # Different directories = different group keys\n        self.assertNotEqual(key1, key2)\n\n    def test_same_directory_same_base_grouped(self):\n        \"\"\"Test that same directory + base name get same group key.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo1.main_file.path = \"/photos/burst/IMG_001.jpg\"\n        photo1.main_file.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.main_file.path = \"/photos/burst/IMG_002.jpg\"\n        photo2.main_file.save()\n        \n        matches1, key1 = check_filename_pattern(photo1, pattern_type=\"any\")\n        matches2, key2 = check_filename_pattern(photo2, pattern_type=\"any\")\n        \n        self.assertTrue(matches1)\n        self.assertTrue(matches2)\n        # Same directory + same base (IMG) = same group\n        # Note: This depends on implementation details\n\n\nclass GroupPhotosByTimestampTestCase(TestCase):\n    \"\"\"Test timestamp-based grouping for bursts.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_group_consecutive_timestamps(self):\n        \"\"\"Test grouping photos with consecutive timestamps.\"\"\"\n        base_time = timezone.now()\n        photos = []\n        \n        for i in range(5):\n            photo = create_test_photo(owner=self.user)\n            photo.exif_timestamp = base_time + timezone.timedelta(milliseconds=500 * i)\n            photo.save()\n            photos.append(photo)\n        \n        # Order by timestamp\n        ordered = Photo.objects.filter(pk__in=[p.pk for p in photos]).order_by(\"exif_timestamp\")\n        \n        groups = group_photos_by_timestamp(ordered, interval_ms=2000)\n        \n        # All 5 should be in one group (each 500ms apart)\n        self.assertEqual(len(groups), 1)\n        self.assertEqual(len(groups[0]), 5)\n\n    def test_separate_groups_by_gap(self):\n        \"\"\"Test that large timestamp gaps create separate groups.\"\"\"\n        base_time = timezone.now()\n        photos = []\n        \n        # First burst: 3 photos 500ms apart\n        for i in range(3):\n            photo = create_test_photo(owner=self.user)\n            photo.exif_timestamp = base_time + timezone.timedelta(milliseconds=500 * i)\n            photo.save()\n            photos.append(photo)\n        \n        # Second burst: 2 photos after 10 second gap\n        for i in range(2):\n            photo = create_test_photo(owner=self.user)\n            photo.exif_timestamp = base_time + timezone.timedelta(seconds=10 + i * 0.5)\n            photo.save()\n            photos.append(photo)\n        \n        ordered = Photo.objects.filter(pk__in=[p.pk for p in photos]).order_by(\"exif_timestamp\")\n        \n        groups = group_photos_by_timestamp(ordered, interval_ms=2000)\n        \n        # Should be 2 groups\n        self.assertEqual(len(groups), 2)\n        self.assertEqual(len(groups[0]), 3)\n        self.assertEqual(len(groups[1]), 2)\n\n    def test_single_photo_no_group(self):\n        \"\"\"Test that single photos don't form groups.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.exif_timestamp = timezone.now()\n        photo.save()\n        \n        ordered = Photo.objects.filter(pk=photo.pk).order_by(\"exif_timestamp\")\n        \n        groups = group_photos_by_timestamp(ordered, interval_ms=2000)\n        \n        # Single photo should not form a group\n        self.assertEqual(len(groups), 0)\n\n    def test_empty_queryset(self):\n        \"\"\"Test with empty queryset.\"\"\"\n        ordered = Photo.objects.none()\n        \n        groups = group_photos_by_timestamp(ordered, interval_ms=2000)\n        \n        self.assertEqual(len(groups), 0)\n\n    def test_photos_without_timestamp(self):\n        \"\"\"Test handling photos without exif_timestamp.\"\"\"\n        photos = []\n        for _ in range(3):\n            photo = create_test_photo(owner=self.user)\n            photo.exif_timestamp = None\n            photo.save()\n            photos.append(photo)\n        \n        ordered = Photo.objects.filter(pk__in=[p.pk for p in photos]).order_by(\"exif_timestamp\")\n        \n        groups = group_photos_by_timestamp(ordered, interval_ms=2000)\n        \n        # Photos without timestamps can't be grouped by timestamp\n        self.assertEqual(len(groups), 0)\n\n\nclass BurstDetectionIntegrationTestCase(TestCase):\n    \"\"\"Integration tests for burst detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_detect_burst_creates_stack(self):\n        \"\"\"Test that detecting a burst creates a stack.\"\"\"\n        from api.stack_detection import detect_burst_sequences\n        \n        base_time = timezone.now()\n        photos = []\n        \n        for i in range(4):\n            photo = create_test_photo(owner=self.user)\n            photo.exif_timestamp = base_time + timezone.timedelta(milliseconds=300 * i)\n            photo.main_file.path = f\"/photos/burst/IMG_{i:03d}.jpg\"\n            photo.main_file.save()\n            photo.save()\n            photos.append(photo)\n        \n        # Run burst detection\n        detect_burst_sequences(self.user)\n        \n        # Check for burst stacks\n        _burst_stacks = PhotoStack.objects.filter(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE\n        )\n        \n        # Should have created at least one burst stack\n        # (depends on detection rules being enabled)\n\n    def test_case_insensitive_pattern_matching(self):\n        \"\"\"Test that filename patterns are case-insensitive.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file.path = \"/photos/IMG_001_BURST001.JPG\"  # Uppercase extension\n        photo.main_file.save()\n        \n        matches, _ = check_filename_pattern(photo, pattern_type=\"any\")\n        self.assertTrue(matches)\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.main_file.path = \"/photos/img_001_burst001.jpg\"  # Lowercase\n        photo2.main_file.save()\n        \n        matches2, _ = check_filename_pattern(photo2, pattern_type=\"any\")\n        self.assertTrue(matches2)\n\n\nclass FilenamePatternEdgeCasesTestCase(TestCase):\n    \"\"\"Test edge cases for filename pattern detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_no_extension(self):\n        \"\"\"Test handling files without extension.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file.path = \"/photos/IMG_001\"  # No extension\n        photo.main_file.save()\n        \n        matches, _ = check_filename_pattern(photo, pattern_type=\"any\")\n        # Should still match based on base name\n        self.assertTrue(matches)\n\n    def test_multiple_extensions(self):\n        \"\"\"Test handling files with multiple dots.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file.path = \"/photos/IMG_001.edited.jpg\"\n        photo.main_file.save()\n        \n        matches, _ = check_filename_pattern(photo, pattern_type=\"any\")\n        # May or may not match depending on extension handling\n\n    def test_unicode_filename(self):\n        \"\"\"Test handling unicode filenames.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file.path = \"/photos/写真_001.jpg\"\n        photo.main_file.save()\n        \n        matches, _ = check_filename_pattern(photo, pattern_type=\"any\")\n        # Should handle gracefully\n\n    def test_very_long_filename(self):\n        \"\"\"Test handling very long filenames.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        long_name = \"A\" * 200 + \"_001.jpg\"\n        photo.main_file.path = f\"/photos/{long_name}\"\n        photo.main_file.save()\n        \n        matches, _ = check_filename_pattern(photo, pattern_type=\"any\")\n        # Should handle gracefully\n\n    def test_special_characters_in_path(self):\n        \"\"\"Test handling special characters in path.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file.path = \"/photos/my folder (2024)/IMG_001.jpg\"\n        photo.main_file.save()\n        \n        matches, _ = check_filename_pattern(photo, pattern_type=\"any\")\n        self.assertTrue(matches)\n\n    def test_invalid_pattern_type(self):\n        \"\"\"Test handling invalid pattern type.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file.path = \"/photos/IMG_001_BURST001.jpg\"\n        photo.main_file.save()\n        \n        # Invalid pattern type should not match\n        matches, _ = check_filename_pattern(photo, pattern_type=\"nonexistent_pattern\")\n        self.assertFalse(matches)\n"
  },
  {
    "path": "api/tests/test_delete_photos.py",
    "content": "from unittest.mock import patch\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_photos, create_test_user\n\n\nclass DeletePhotosTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n\n    def test_tag_my_photos_for_removal(self):\n        photos = create_test_photos(number_of_photos=3, owner=self.user1)\n        image_hashes = [p.image_hash for p in photos]\n\n        payload = {\"image_hashes\": image_hashes, \"deleted\": True}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/setdeleted/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(3, len(data[\"results\"]))\n        self.assertEqual(3, len(data[\"updated\"]))\n        self.assertEqual(0, len(data[\"not_updated\"]))\n\n    def test_untag_my_photos_for_removal(self):\n        photos1 = create_test_photos(\n            number_of_photos=1, owner=self.user1, in_trashcan=True\n        )\n        photos2 = create_test_photos(number_of_photos=2, owner=self.user1)\n        image_hashes = [p.image_hash for p in photos1 + photos2]\n\n        payload = {\"image_hashes\": image_hashes, \"deleted\": False}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/setdeleted/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(1, len(data[\"results\"]))\n        self.assertEqual(1, len(data[\"updated\"]))\n        self.assertEqual(2, len(data[\"not_updated\"]))\n\n    def test_tag_photos_of_other_user_for_removal(self):\n        photos = create_test_photos(number_of_photos=2, owner=self.user2)\n        image_hashes = [p.image_hash for p in photos]\n\n        payload = {\"image_hashes\": image_hashes, \"deleted\": True}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/setdeleted/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(0, len(data[\"results\"]))\n        self.assertEqual(0, len(data[\"updated\"]))\n        # Photos not owned by user are treated as \"missing\" for security (no info leak)\n        self.assertEqual(0, len(data[\"not_updated\"]))\n\n    @patch(\"api.views.photos.logger.warning\", autospec=True)\n    def test_tag_for_removal_nonexistent_photo(self, logger):\n        payload = {\"image_hashes\": [\"nonexistent_photo\"], \"deleted\": True}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/setdeleted/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(0, len(data[\"results\"]))\n        self.assertEqual(0, len(data[\"updated\"]))\n        self.assertEqual(0, len(data[\"not_updated\"]))\n        logger.assert_called_with(\n            \"Could not set photo nonexistent_photo to deleted. It does not exist or is not owned by user.\"\n        )\n\n    def test_delete_tagged_photos_for_removal(self):\n        photos_to_delete = create_test_photos(\n            number_of_photos=2, owner=self.user1, in_trashcan=True\n        )\n        photos_to_not_delete = create_test_photos(number_of_photos=3, owner=self.user1)\n        image_hashes = [p.image_hash for p in photos_to_delete + photos_to_not_delete]\n\n        payload = {\"image_hashes\": image_hashes}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.delete(\n            \"/api/photosedit/delete/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(2, len(data[\"results\"]))\n        self.assertEqual(2, len(data[\"deleted\"]))\n        self.assertEqual(3, len(data[\"not_deleted\"]))\n\n    def test_delete_tagged_photos_of_other_user_for_removal(self):\n        photos_to_delete = create_test_photos(\n            number_of_photos=5, owner=self.user2, in_trashcan=True\n        )\n        image_hashes = [p.image_hash for p in photos_to_delete]\n\n        payload = {\"image_hashes\": image_hashes}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.delete(\n            \"/api/photosedit/delete/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(0, len(data[\"results\"]))\n        self.assertEqual(0, len(data[\"deleted\"]))\n        self.assertEqual(5, len(data[\"not_deleted\"]))\n"
  },
  {
    "path": "api/tests/test_detection_edge_cases.py",
    "content": "\"\"\"\nEdge case tests for detection logic.\n\nTests cover:\n- Burst detection rule parsing and validation\n- File path and naming edge cases\n- Detection with missing/corrupt data\n\nNOTE: RAW+JPEG and Live Photo detection tests are skipped because these\nfunctions were removed. RAW+JPEG and Live Photos now use the file variants\nmodel (Photo.files) instead of stacks, handled during scan time.\n\"\"\"\n\nimport json\nimport unittest\nfrom datetime import datetime, timedelta\nfrom django.test import TestCase\n\nfrom api.models.file import File\nfrom api.models.photo_stack import PhotoStack\nfrom api.burst_detection_rules import (\n    BurstDetectionRule,\n    BurstRuleTypes,\n    BurstRuleCategory,\n    BURST_FILENAME_PATTERNS,\n    get_default_burst_detection_rules,\n    as_rules,\n)\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass BurstRuleParsingTestCase(TestCase):\n    \"\"\"Tests for burst rule parsing and validation.\"\"\"\n\n    def test_parse_valid_rule(self):\n        \"\"\"Test parsing a valid burst rule.\"\"\"\n        rule_params = {\n            \"id\": \"test_rule\",\n            \"name\": \"Test Rule\",\n            \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n            \"category\": BurstRuleCategory.HARD,\n            \"enabled\": True,\n        }\n        rule = BurstDetectionRule(rule_params)\n        \n        self.assertEqual(rule.id, \"test_rule\")\n        self.assertEqual(rule.name, \"Test Rule\")\n        self.assertEqual(rule.rule_type, BurstRuleTypes.EXIF_BURST_MODE)\n        self.assertTrue(rule.enabled)\n\n    def test_parse_rule_with_missing_optional_fields(self):\n        \"\"\"Test parsing rule with only required fields.\"\"\"\n        rule_params = {\n            \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n        }\n        rule = BurstDetectionRule(rule_params)\n        \n        self.assertIsNone(rule.id)\n        self.assertEqual(rule.name, \"Unnamed rule\")\n        self.assertEqual(rule.category, BurstRuleCategory.HARD)\n        self.assertTrue(rule.enabled)  # Default True\n        self.assertTrue(rule.is_default)  # Default True\n\n    def test_parse_disabled_rule(self):\n        \"\"\"Test parsing a disabled rule.\"\"\"\n        rule_params = {\n            \"id\": \"disabled_rule\",\n            \"rule_type\": BurstRuleTypes.TIMESTAMP_PROXIMITY,\n            \"enabled\": False,\n        }\n        rule = BurstDetectionRule(rule_params)\n        \n        self.assertFalse(rule.enabled)\n\n    def test_default_rules_are_valid(self):\n        \"\"\"Test that all default rules parse correctly.\"\"\"\n        default_rules = get_default_burst_detection_rules()\n        for rule_dict in default_rules:\n            rule = BurstDetectionRule(rule_dict)\n            self.assertIsNotNone(rule.rule_type)\n            self.assertIn(rule.category, [BurstRuleCategory.HARD, BurstRuleCategory.SOFT])\n\n\nclass BurstFilenamePatternTestCase(TestCase):\n    \"\"\"Tests for burst filename pattern matching.\"\"\"\n\n    def test_burst_suffix_pattern(self):\n        \"\"\"Test _BURST pattern matching.\"\"\"\n        import re\n        pattern = BURST_FILENAME_PATTERNS[\"burst_suffix\"][0]\n        \n        # Should match\n        self.assertIsNotNone(re.search(pattern, \"IMG_001_BURST001\"))\n        self.assertIsNotNone(re.search(pattern, \"photo_BURST123\"))\n        \n        # Should not match\n        self.assertIsNone(re.search(pattern, \"IMG_001\"))\n        self.assertIsNone(re.search(pattern, \"BURST_photo\"))\n\n    def test_sequence_suffix_pattern(self):\n        \"\"\"Test sequence number suffix pattern.\"\"\"\n        import re\n        pattern = BURST_FILENAME_PATTERNS[\"sequence_suffix\"][0]\n        \n        # Should match\n        self.assertIsNotNone(re.search(pattern, \"IMG_001\"))\n        self.assertIsNotNone(re.search(pattern, \"photo_0001\"))\n        \n        # Should not match (less than 3 digits)\n        self.assertIsNone(re.search(pattern, \"IMG_01\"))\n\n    def test_bracketed_sequence_pattern(self):\n        \"\"\"Test bracketed sequence pattern.\"\"\"\n        import re\n        pattern = BURST_FILENAME_PATTERNS[\"bracketed_sequence\"][0]\n        \n        # Should match\n        self.assertIsNotNone(re.search(pattern, \"photo (1)\"))\n        self.assertIsNotNone(re.search(pattern, \"image (123)\"))\n        \n        # Should not match\n        self.assertIsNone(re.search(pattern, \"photo\"))\n        self.assertIsNone(re.search(pattern, \"(1) photo\"))\n\n\nclass UserBurstRulesTestCase(TestCase):\n    \"\"\"Tests for user burst detection rules configuration.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_as_rules_with_default_rules(self):\n        \"\"\"Test as_rules with default rules config.\"\"\"\n        default_config = get_default_burst_detection_rules()\n        rules = as_rules(default_config)\n        \n        self.assertIsInstance(rules, list)\n        self.assertGreater(len(rules), 0)\n\n    def test_as_rules_with_custom_rules(self):\n        \"\"\"Test as_rules with custom rule config.\"\"\"\n        custom_rules = [\n            {\n                \"id\": \"custom1\",\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                \"enabled\": True,\n            }\n        ]\n        \n        rules = as_rules(custom_rules)\n        \n        self.assertEqual(len(rules), 1)\n        self.assertEqual(rules[0].id, \"custom1\")\n\n    def test_as_rules_with_empty_list(self):\n        \"\"\"Test as_rules with empty list.\"\"\"\n        rules = as_rules([])\n        \n        self.assertEqual(len(rules), 0)\n\n    def test_user_rules_stored_as_json(self):\n        \"\"\"Test that user rules can be stored as JSON string.\"\"\"\n        custom_rules = [\n            {\n                \"id\": \"custom1\",\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                \"enabled\": True,\n            }\n        ]\n        self.user.burst_detection_rules = json.dumps(custom_rules)\n        self.user.save()\n        \n        # Reload and parse\n        self.user.refresh_from_db()\n        rules_config = json.loads(self.user.burst_detection_rules)\n        rules = as_rules(rules_config)\n        \n        self.assertEqual(len(rules), 1)\n\n\n@unittest.skip(\"RAW+JPEG detection removed - now handled via file variants during scan\")\nclass RawJpegDetectionEdgeCasesTestCase(TestCase):\n    \"\"\"Tests for RAW+JPEG detection edge cases.\n    \n    DEPRECATED: RAW+JPEG pairs are now handled as file variants (Photo.files)\n    during scan time, not via stack detection. These tests are skipped.\n    \"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_detection_with_no_raw_photos(self):\n        \"\"\"Test RAW+JPEG detection when there are no RAW files.\"\"\"\n        from api.stack_detection import detect_raw_jpeg_pairs\n        \n        # Create only JPEG photos (type=1 is IMAGE)\n        for i in range(3):\n            photo = create_test_photo(owner=self.user)\n            photo.main_file.type = File.IMAGE\n            photo.main_file.save()\n        \n        stacks_created = detect_raw_jpeg_pairs(self.user)\n        \n        self.assertEqual(stacks_created, 0)\n\n    def test_detection_with_raw_no_matching_jpeg(self):\n        \"\"\"Test RAW+JPEG detection when RAW has no matching JPEG.\"\"\"\n        from api.stack_detection import detect_raw_jpeg_pairs\n        \n        # Create RAW photo\n        raw_photo = create_test_photo(owner=self.user)\n        raw_photo.main_file.type = File.RAW_FILE\n        raw_photo.main_file.path = \"/photos/unique_raw.CR2\"\n        raw_photo.main_file.save()\n        \n        # Create JPEG with different name\n        jpeg_photo = create_test_photo(owner=self.user)\n        jpeg_photo.main_file.type = File.IMAGE\n        jpeg_photo.main_file.path = \"/photos/different_name.jpg\"\n        jpeg_photo.main_file.save()\n        \n        stacks_created = detect_raw_jpeg_pairs(self.user)\n        \n        self.assertEqual(stacks_created, 0)\n\n    def test_detection_case_insensitive_extensions(self):\n        \"\"\"Test that RAW+JPEG detection handles case variations.\"\"\"\n        from api.stack_detection import detect_raw_jpeg_pairs\n        \n        # Create RAW photo\n        raw_photo = create_test_photo(owner=self.user)\n        raw_photo.main_file.type = File.RAW_FILE\n        raw_photo.main_file.path = \"/photos/image.CR2\"\n        raw_photo.main_file.save()\n        \n        # Create JPEG with uppercase extension\n        jpeg_photo = create_test_photo(owner=self.user)\n        jpeg_photo.main_file.type = File.IMAGE\n        jpeg_photo.main_file.path = \"/photos/image.JPG\"\n        jpeg_photo.main_file.save()\n        \n        stacks_created = detect_raw_jpeg_pairs(self.user)\n        \n        # Should find the pair regardless of case\n        self.assertGreaterEqual(stacks_created, 0)\n\n    def test_detection_with_photo_no_main_file(self):\n        \"\"\"Test detection handles photos without main_file.\"\"\"\n        from api.stack_detection import detect_raw_jpeg_pairs\n        \n        # Create a regular test photo (which has main_file)\n        # Then manually set main_file to None to simulate edge case\n        photo = create_test_photo(owner=self.user)\n        photo.main_file = None\n        photo.save()\n        \n        # Should not crash\n        stacks_created = detect_raw_jpeg_pairs(self.user)\n        self.assertGreaterEqual(stacks_created, 0)\n\n    def test_detection_clears_existing_stacks(self):\n        \"\"\"Test that re-detection clears existing RAW+JPEG stacks.\"\"\"\n        from api.stack_detection import detect_raw_jpeg_pairs\n        \n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        # Create existing RAW+JPEG stack\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.RAW_JPEG_PAIR,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        initial_count = PhotoStack.objects.filter(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.RAW_JPEG_PAIR\n        ).count()\n        self.assertEqual(initial_count, 1)\n        \n        # Run detection\n        detect_raw_jpeg_pairs(self.user)\n        \n        # Old stack should be cleared\n        # (new stacks may or may not be created depending on file types)\n\n\nclass BurstDetectionEdgeCasesTestCase(TestCase):\n    \"\"\"Tests for burst detection edge cases.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_detection_with_no_photos(self):\n        \"\"\"Test burst detection with empty library.\"\"\"\n        from api.stack_detection import detect_burst_sequences\n        \n        stacks_created = detect_burst_sequences(self.user)\n        \n        self.assertEqual(stacks_created, 0)\n\n    def test_detection_with_all_rules_disabled(self):\n        \"\"\"Test burst detection when all rules are disabled.\"\"\"\n        from api.stack_detection import detect_burst_sequences\n        \n        # Create some photos\n        for i in range(3):\n            create_test_photo(owner=self.user)\n        \n        # Disable all rules\n        disabled_rules = [\n            {\n                \"id\": \"disabled1\",\n                \"rule_type\": BurstRuleTypes.EXIF_BURST_MODE,\n                \"enabled\": False,\n            }\n        ]\n        self.user.burst_detection_rules = json.dumps(disabled_rules)\n        self.user.save()\n        \n        stacks_created = detect_burst_sequences(self.user)\n        \n        # No stacks should be created with all rules disabled\n        self.assertEqual(stacks_created, 0)\n\n    def test_detection_with_trashed_photos_excluded(self):\n        \"\"\"Test that trashed photos are excluded from detection.\"\"\"\n        from api.stack_detection import detect_burst_sequences\n        \n        # Create photos, some in trash\n        _photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo2.in_trashcan = True\n        photo2.save()\n        \n        _stacks_created = detect_burst_sequences(self.user)\n        \n        # Trashed photos should not be in any stack\n        stacks = PhotoStack.objects.filter(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE\n        )\n        for stack in stacks:\n            self.assertFalse(stack.photos.filter(in_trashcan=True).exists())\n\n\nclass TimestampProximityRuleTestCase(TestCase):\n    \"\"\"Tests for timestamp proximity burst detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_photos_within_threshold_grouped(self):\n        \"\"\"Test that photos within timestamp threshold are grouped.\"\"\"\n        from django.utils import timezone\n        from api.stack_detection import _detect_bursts_soft_criteria\n        \n        base_time = timezone.make_aware(datetime(2024, 1, 1, 12, 0, 0))\n        \n        # Create photos with close timestamps\n        photo1 = create_test_photo(owner=self.user)\n        photo1.exif_timestamp = base_time\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.exif_timestamp = base_time + timedelta(seconds=1)\n        photo2.save()\n        \n        photo3 = create_test_photo(owner=self.user)\n        photo3.exif_timestamp = base_time + timedelta(seconds=2)\n        photo3.save()\n        \n        # Soft criteria with 3000ms interval\n        soft_rules = [\n            BurstDetectionRule({\n                \"id\": \"timestamp\",\n                \"rule_type\": BurstRuleTypes.TIMESTAMP_PROXIMITY,\n                \"category\": BurstRuleCategory.SOFT,\n                \"enabled\": True,\n                \"interval_ms\": 3000,\n            })\n        ]\n        \n        stacks_created = _detect_bursts_soft_criteria(self.user, soft_rules)\n        \n        # Should group the 3 photos\n        self.assertGreaterEqual(stacks_created, 0)\n\n    def test_photos_beyond_threshold_not_grouped(self):\n        \"\"\"Test that photos beyond timestamp threshold are not grouped.\"\"\"\n        from django.utils import timezone\n        from api.stack_detection import _detect_bursts_soft_criteria\n        \n        base_time = timezone.make_aware(datetime(2024, 1, 1, 12, 0, 0))\n        \n        # Create photos with far timestamps\n        photo1 = create_test_photo(owner=self.user)\n        photo1.exif_timestamp = base_time\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.exif_timestamp = base_time + timedelta(minutes=5)\n        photo2.save()\n        \n        # Soft criteria with 3000ms interval\n        soft_rules = [\n            BurstDetectionRule({\n                \"id\": \"timestamp\",\n                \"rule_type\": BurstRuleTypes.TIMESTAMP_PROXIMITY,\n                \"category\": BurstRuleCategory.SOFT,\n                \"enabled\": True,\n                \"interval_ms\": 3000,\n            })\n        ]\n        \n        stacks_created = _detect_bursts_soft_criteria(self.user, soft_rules)\n        \n        # Should not group photos that are 5 minutes apart\n        self.assertEqual(stacks_created, 0)\n\n    def test_photos_without_timestamp_skipped(self):\n        \"\"\"Test that photos without timestamp are skipped.\"\"\"\n        from api.stack_detection import _detect_bursts_soft_criteria\n        \n        # Create photo without timestamp\n        photo1 = create_test_photo(owner=self.user)\n        photo1.exif_timestamp = None\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.exif_timestamp = None\n        photo2.save()\n        \n        soft_rules = [\n            BurstDetectionRule({\n                \"id\": \"timestamp\",\n                \"rule_type\": BurstRuleTypes.TIMESTAMP_PROXIMITY,\n                \"category\": BurstRuleCategory.SOFT,\n                \"enabled\": True,\n                \"interval_ms\": 3000,\n            })\n        ]\n        \n        # Should not crash - function filters photos without timestamps\n        stacks_created = _detect_bursts_soft_criteria(self.user, soft_rules)\n        self.assertEqual(stacks_created, 0)\n\n\n@unittest.skip(\"Live Photo detection removed - now handled via file variants during scan\")\nclass LivePhotoDetectionEdgeCasesTestCase(TestCase):\n    \"\"\"Tests for live photo detection edge cases.\n    \n    DEPRECATED: Live Photos are now handled as file variants (Photo.files)\n    during scan time, not via stack detection. These tests are skipped.\n    \"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_detection_with_no_live_photos(self):\n        \"\"\"Test live photo detection with no live photos.\"\"\"\n        from api.stack_detection import detect_live_photos\n        \n        # Create regular photos\n        for i in range(3):\n            create_test_photo(owner=self.user)\n        \n        stacks_created = detect_live_photos(self.user)\n        \n        self.assertEqual(stacks_created, 0)\n\n\nclass DetectionProgressCallbackTestCase(TestCase):\n    \"\"\"Tests for detection progress callbacks.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    @unittest.skip(\"RAW+JPEG detection removed - now handled via file variants during scan\")\n    def test_raw_jpeg_detection_calls_progress(self):\n        \"\"\"Test that RAW+JPEG detection calls progress callback.\n        \n        DEPRECATED: RAW+JPEG pairs are now handled as file variants (Photo.files)\n        during scan time, not via stack detection.\n        \"\"\"\n        from api.stack_detection import detect_raw_jpeg_pairs\n        \n        # Create some RAW photos\n        for i in range(3):\n            photo = create_test_photo(owner=self.user)\n            photo.main_file.type = File.RAW_FILE\n            photo.main_file.save()\n        \n        progress_calls = []\n        \n        def progress_callback(current, total, found):\n            progress_calls.append((current, total, found))\n        \n        detect_raw_jpeg_pairs(self.user, progress_callback=progress_callback)\n        \n        # Progress should have been called\n        self.assertGreater(len(progress_calls), 0)\n\n    def test_burst_detection_calls_progress(self):\n        \"\"\"Test that burst detection calls progress callback.\"\"\"\n        from api.stack_detection import detect_burst_sequences\n        \n        # Create some photos\n        for i in range(3):\n            create_test_photo(owner=self.user)\n        \n        progress_calls = []\n        \n        def progress_callback(current, total, found):\n            progress_calls.append((current, total, found))\n        \n        detect_burst_sequences(self.user, progress_callback=progress_callback)\n        \n        # Progress may or may not be called depending on implementation\n        # Just verify it doesn't crash\n        self.assertIsInstance(progress_calls, list)\n\n\nclass BatchDetectionEdgeCasesTestCase(TestCase):\n    \"\"\"Tests for batch detection function.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_batch_detection_all_types(self):\n        \"\"\"Test batch detection with all detection types enabled.\"\"\"\n        from api.stack_detection import batch_detect_stacks\n        \n        options = {\n            'detect_raw_jpeg': True,\n            'detect_bursts': True,\n            'detect_live_photos': True,\n        }\n        \n        # Should not crash - function may return None (runs as job)\n        try:\n            batch_detect_stacks(self.user, options)\n            success = True\n        except Exception:\n            success = False\n        \n        self.assertTrue(success)\n\n    def test_batch_detection_none_enabled(self):\n        \"\"\"Test batch detection with no detection types enabled.\"\"\"\n        from api.stack_detection import batch_detect_stacks\n        \n        options = {\n            'detect_raw_jpeg': False,\n            'detect_bursts': False,\n            'detect_live_photos': False,\n        }\n        \n        # Should not crash\n        try:\n            batch_detect_stacks(self.user, options)\n            success = True\n        except Exception:\n            success = False\n        \n        self.assertTrue(success)\n\n    def test_batch_detection_with_null_options(self):\n        \"\"\"Test batch detection with None options.\"\"\"\n        from api.stack_detection import batch_detect_stacks\n        \n        # Should use defaults and not crash\n        try:\n            batch_detect_stacks(self.user, None)\n            success = True\n        except Exception:\n            success = False\n        \n        self.assertTrue(success)\n\n    def test_batch_detection_with_empty_options(self):\n        \"\"\"Test batch detection with empty options dict.\"\"\"\n        from api.stack_detection import batch_detect_stacks\n        \n        # Should not crash\n        try:\n            batch_detect_stacks(self.user, {})\n            success = True\n        except Exception:\n            success = False\n        \n        self.assertTrue(success)\n\n\nclass MultiUserDetectionIsolationTestCase(TestCase):\n    \"\"\"Tests for multi-user detection isolation.\"\"\"\n\n    def setUp(self):\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n\n    def test_detection_only_affects_own_photos(self):\n        \"\"\"Test that detection only creates stacks for user's own photos.\"\"\"\n        from api.stack_detection import detect_burst_sequences\n        \n        # Create photos for both users\n        for i in range(3):\n            create_test_photo(owner=self.user1)\n            create_test_photo(owner=self.user2)\n        \n        # Run detection for user1 only\n        detect_burst_sequences(self.user1)\n        \n        # User2's photos should not have any stacks\n        user2_stacks = PhotoStack.objects.filter(owner=self.user2)\n        self.assertEqual(user2_stacks.count(), 0)\n\n    def test_clearing_stacks_only_affects_own(self):\n        \"\"\"Test that clearing stacks only affects user's own stacks.\"\"\"\n        from api.stack_detection import clear_stacks_of_type\n        \n        # Create stacks for both users\n        for user in [self.user1, self.user2]:\n            photo1 = create_test_photo(owner=user)\n            photo2 = create_test_photo(owner=user)\n            stack = PhotoStack.objects.create(\n                owner=user,\n                stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            )\n            stack.photos.add(photo1, photo2)\n        \n        # Clear stacks for user1 only\n        clear_stacks_of_type(self.user1, PhotoStack.StackType.BURST_SEQUENCE)\n        \n        # User1 should have no stacks\n        user1_stacks = PhotoStack.objects.filter(\n            owner=self.user1,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE\n        )\n        self.assertEqual(user1_stacks.count(), 0)\n        \n        # User2 should still have their stack\n        user2_stacks = PhotoStack.objects.filter(\n            owner=self.user2,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE\n        )\n        self.assertEqual(user2_stacks.count(), 1)\n"
  },
  {
    "path": "api/tests/test_directory_watcher_fix.py",
    "content": "from django.test import TestCase\nfrom django.db.models import Q\n\nfrom api.models import Photo\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass DirectoryWatcherFixTest(TestCase):\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_generate_tags_query_works(self):\n        \"\"\"Test that the generate_tags query works with the new PhotoCaption model\"\"\"\n        # Create a photo without places365 captions\n        photo = create_test_photo(owner=self.user)\n\n        # Add some caption data to the photo (but NOT places365)\n        from api.models.photo_caption import PhotoCaption\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n        caption_instance.captions_json = {\n            \"im2txt\": \"A beautiful landscape\",\n            \"user_caption\": \"My vacation photo\",\n        }\n        caption_instance.save()\n\n        # This query should work without FieldError\n        existing_photos = Photo.objects.filter(\n            Q(owner=self.user.id)\n            & (\n                Q(caption_instance__isnull=True)\n                | Q(caption_instance__captions_json__isnull=True)\n                | Q(caption_instance__captions_json__places365__isnull=True)\n            )\n        )\n\n        # Should find the photo since it has no places365 captions\n        self.assertEqual(existing_photos.count(), 1)\n        self.assertEqual(existing_photos.first(), photo)\n\n    def test_generate_tags_query_excludes_photos_with_places365(self):\n        \"\"\"Test that photos with places365 captions are excluded\"\"\"\n        # Create a photo with places365 captions\n        photo = create_test_photo(owner=self.user)\n        from api.models.photo_caption import PhotoCaption\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n        caption_instance.captions_json = {\n            \"places365\": {\"categories\": [\"outdoor\"], \"attributes\": [\"sunny\"]}\n        }\n        caption_instance.save()\n\n        # This query should exclude the photo since it has places365 captions\n        existing_photos = Photo.objects.filter(\n            Q(owner=self.user.id)\n            & (\n                Q(caption_instance__isnull=True)\n                | Q(caption_instance__captions_json__isnull=True)\n                | Q(caption_instance__captions_json__places365__isnull=True)\n            )\n        )\n\n        # Should not find the photo since it has places365 captions\n        self.assertEqual(existing_photos.count(), 0)\n"
  },
  {
    "path": "api/tests/test_dirtree.py",
    "content": "from django.test import TestCase\nfrom pyfakefs.fake_filesystem_unittest import Patcher\nfrom rest_framework.test import APIClient\n\nfrom api.models import User\nfrom api.tests.utils import create_password\n\n\nclass DirTreeTest(TestCase):\n    def setUp(self):\n        self.admin = User.objects.create_superuser(\n            \"admin\", \"admin@test.com\", create_password()\n        )\n        self.user = User.objects.create_user(\"user\", \"user@test.com\", create_password())\n        self.client = APIClient()\n\n    def test_admin_should_allow_to_retrieve_dirtree(self):\n        self.client.force_authenticate(user=self.admin)\n        response = self.client.get(\"/api/dirtree/\")\n        self.assertEqual(200, response.status_code)\n\n    def test_should_retrieve_dir_listing_by_path(self):\n        self.client.force_authenticate(user=self.admin)\n        response = self.client.get(\"/api/dirtree/?path=/data\")\n        self.assertEqual(200, response.status_code)\n\n    def test_should_fail_when_listing_with_invalid_path(self):\n        self.client.force_authenticate(user=self.admin)\n        response = self.client.get(\"/api/dirtree/?path=/does_not_exist\")\n        data = response.json()\n        self.assertEqual(403, response.status_code)\n        self.assertEqual(\n            data[\"message\"], \"Access denied. Path is outside the allowed directory.\"\n        )\n\n    def test_children_list_should_be_alphabetical_case_insensitive(self):\n        with Patcher() as patcher:\n            patcher.fs.create_dir(\"/data\")\n            patcher.fs.create_dir(\"/data/Z\")\n            patcher.fs.create_dir(\"/data/a\")\n            patcher.fs.create_dir(\"/data/X\")\n            patcher.fs.create_dir(\"/data/b\")\n\n            self.client.force_authenticate(user=self.admin)\n            response = self.client.get(\"/api/dirtree/\")\n            data = response.json()[0]\n            self.assertEqual(200, response.status_code)\n            self.assertEqual(data[\"children\"][0][\"title\"], \"a\")\n            self.assertEqual(data[\"children\"][1][\"title\"], \"b\")\n            self.assertEqual(data[\"children\"][2][\"title\"], \"X\")\n            self.assertEqual(data[\"children\"][3][\"title\"], \"Z\")\n\n    def test_regular_user_is_not_allowed_to_retrieve_dirtree(self):\n        self.client.force_authenticate(user=self.user)\n        response = self.client.get(\"/api/dirtree/\")\n        self.assertEqual(403, response.status_code)\n\n    def test_anonymous_user_is_not_allower_to_retrieve_dirtree(self):\n        self.client.force_authenticate(user=None)\n        response = self.client.get(\"/api/dirtree/\")\n        self.assertEqual(401, response.status_code)\n"
  },
  {
    "path": "api/tests/test_duplicate_api_edge_cases.py",
    "content": "\"\"\"\nEdge case tests for Duplicate API to find bugs.\n\nTests cover:\n- Resolution workflow edge cases\n- Revert edge cases\n- Delete edge cases (potential Bug #13)\n- List/Detail view edge cases with missing data\n- Statistics edge cases\n\"\"\"\n\nimport uuid\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.models.duplicate import Duplicate\nfrom api.models.photo_metadata import PhotoMetadata\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass DuplicateResolveEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for duplicate resolution.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_resolve_already_resolved_duplicate(self):\n        \"\"\"Test resolving a duplicate that's already resolved.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.RESOLVED,\n            kept_photo=photo1,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Try to resolve again with different kept photo\n        response = self.client.post(\n            f\"/api/duplicates/{duplicate.id}/resolve/\",\n            {\"keep_photo_hash\": photo2.image_hash},\n        )\n        \n        # Should succeed (changing which photo to keep)\n        self.assertEqual(response.status_code, 200)\n        \n        duplicate.refresh_from_db()\n        self.assertEqual(duplicate.kept_photo, photo2)\n\n    def test_resolve_with_photo_already_trashed(self):\n        \"\"\"Test resolving when one photo is already trashed.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user, in_trashcan=True)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Resolve keeping photo1\n        response = self.client.post(\n            f\"/api/duplicates/{duplicate.id}/resolve/\",\n            {\"keep_photo_hash\": photo1.image_hash},\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        \n        # Photo2 was already trashed, shouldn't change\n        photo2.refresh_from_db()\n        self.assertTrue(photo2.in_trashcan)\n\n    def test_resolve_with_trash_others_false(self):\n        \"\"\"Test resolving without trashing other photos.\n        \n        Note: Must use format='json' to properly send boolean False.\n        Form data converts False to string \"False\" which is truthy.\n        \"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Use format='json' to properly send boolean False\n        response = self.client.post(\n            f\"/api/duplicates/{duplicate.id}/resolve/\",\n            {\"keep_photo_hash\": photo1.image_hash, \"trash_others\": False},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        \n        # Photo2 should NOT be trashed\n        photo2.refresh_from_db()\n        self.assertFalse(photo2.in_trashcan)\n        \n        # But duplicate should still be marked resolved\n        duplicate.refresh_from_db()\n        self.assertEqual(duplicate.review_status, Duplicate.ReviewStatus.RESOLVED)\n\n    def test_resolve_with_nonexistent_photo_hash(self):\n        \"\"\"Test resolving with a photo hash that doesn't exist in the group.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        response = self.client.post(\n            f\"/api/duplicates/{duplicate.id}/resolve/\",\n            {\"keep_photo_hash\": \"nonexistent_hash\"},\n        )\n        \n        self.assertEqual(response.status_code, 400)\n        self.assertIn(\"error\", response.data)\n\n    def test_resolve_empty_request(self):\n        \"\"\"Test resolving with empty request body.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        response = self.client.post(\n            f\"/api/duplicates/{duplicate.id}/resolve/\",\n            {},\n        )\n        \n        self.assertEqual(response.status_code, 400)\n\n\nclass DuplicateRevertEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for duplicate revert.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_revert_pending_duplicate(self):\n        \"\"\"Test reverting a pending (not resolved) duplicate.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.PENDING,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        response = self.client.post(f\"/api/duplicates/{duplicate.id}/revert/\")\n        \n        # Should fail - can only revert resolved\n        self.assertEqual(response.status_code, 400)\n\n    def test_revert_dismissed_duplicate(self):\n        \"\"\"Test reverting a dismissed duplicate.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.DISMISSED,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        response = self.client.post(f\"/api/duplicates/{duplicate.id}/revert/\")\n        \n        # Should fail - can only revert resolved\n        self.assertEqual(response.status_code, 400)\n\n    def test_revert_when_photos_permanently_deleted(self):\n        \"\"\"Test reverting when trashed photos were permanently deleted.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Resolve keeping photo1\n        duplicate.resolve(kept_photo=photo1)\n        \n        # Now permanently delete photo2 (simulating user emptying trash)\n        photo2.in_trashcan = True\n        photo2.save()\n        photo2.manual_delete()\n        \n        # Try to revert\n        response = self.client.post(f\"/api/duplicates/{duplicate.id}/revert/\")\n        \n        # Should succeed but restored_count may be 0\n        # Bug: Duplicate group might be deleted now due to Bug #12 fix\n        # Let me check if duplicate still exists\n        if Duplicate.objects.filter(id=duplicate.id).exists():\n            self.assertEqual(response.status_code, 200)\n        else:\n            # Duplicate was deleted when photo2 was deleted (only 1 photo left)\n            self.assertEqual(response.status_code, 404)\n\n    def test_revert_multiple_times(self):\n        \"\"\"Test reverting the same duplicate multiple times.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Resolve\n        duplicate.resolve(kept_photo=photo1)\n        \n        # Revert first time\n        response1 = self.client.post(f\"/api/duplicates/{duplicate.id}/revert/\")\n        self.assertEqual(response1.status_code, 200)\n        \n        # Try to revert again (should fail - now pending)\n        response2 = self.client.post(f\"/api/duplicates/{duplicate.id}/revert/\")\n        self.assertEqual(response2.status_code, 400)\n\n\nclass DuplicateDeleteEdgeCasesTestCase(TestCase):\n    \"\"\"\n    Edge cases for duplicate delete.\n    \n    Note: Delete endpoint is at /api/duplicates/{id}/delete with DELETE method.\n    \"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_delete_duplicate_unlinks_all_photos(self):\n        \"\"\"Test that deleting a duplicate unlinks ALL photos.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(5)]\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        for photo in photos:\n            duplicate.photos.add(photo)\n        \n        duplicate_id = duplicate.id\n        \n        # Correct URL: /api/duplicates/{id}/delete\n        response = self.client.delete(f\"/api/duplicates/{duplicate_id}/delete\")\n        \n        self.assertEqual(response.status_code, 200)\n        \n        # Verify duplicate is deleted\n        self.assertFalse(Duplicate.objects.filter(id=duplicate_id).exists())\n        \n        # Verify ALL photos are unlinked\n        for photo in photos:\n            photo.refresh_from_db()\n            self.assertEqual(photo.duplicates.count(), 0,\n                \"All photos should be unlinked from deleted duplicate\")\n\n    def test_delete_duplicate_with_many_photos(self):\n        \"\"\"Test deleting a duplicate with many photos.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(20)]\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        for photo in photos:\n            duplicate.photos.add(photo)\n        \n        response = self.client.delete(f\"/api/duplicates/{duplicate.id}/delete\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"unlinked_count\"], 20)\n\n    def test_delete_resolved_duplicate(self):\n        \"\"\"Test deleting a resolved duplicate.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.RESOLVED,\n            kept_photo=photo1,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        response = self.client.delete(f\"/api/duplicates/{duplicate.id}/delete\")\n        \n        # Should succeed - can delete any duplicate\n        self.assertEqual(response.status_code, 200)\n\n    def test_delete_nonexistent_duplicate(self):\n        \"\"\"Test deleting a duplicate that doesn't exist.\"\"\"\n        response = self.client.delete(f\"/api/duplicates/{uuid.uuid4()}/delete\")\n        self.assertEqual(response.status_code, 404)\n\n    def test_delete_other_users_duplicate(self):\n        \"\"\"Test deleting another user's duplicate.\"\"\"\n        other_user = create_test_user()\n        photo1 = create_test_photo(owner=other_user)\n        photo2 = create_test_photo(owner=other_user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=other_user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        response = self.client.delete(f\"/api/duplicates/{duplicate.id}/delete\")\n        \n        # Should return 404 (not found for this user)\n        self.assertEqual(response.status_code, 404)\n\n\nclass DuplicateDetailEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for duplicate detail view.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_detail_with_photos_without_metadata(self):\n        \"\"\"Test detail view when photos have no metadata.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        # Ensure no metadata exists\n        PhotoMetadata.objects.filter(photo__in=[photo1, photo2]).delete()\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Should not crash\n        response = self.client.get(f\"/api/duplicates/{duplicate.id}/\")\n        \n        self.assertEqual(response.status_code, 200)\n        # Photos should have null width/height/camera\n        for photo_data in response.data[\"photos\"]:\n            # Should be None or return gracefully\n            pass  # If we get here without crash, the test passes\n\n    def test_detail_with_photos_without_main_file(self):\n        \"\"\"Test detail view when photos have no main_file.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo1.main_file = None\n        photo1.save()\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Should not crash\n        response = self.client.get(f\"/api/duplicates/{duplicate.id}/\")\n        \n        self.assertEqual(response.status_code, 200)\n\n    def test_detail_with_deleted_kept_photo(self):\n        \"\"\"Test detail view when kept_photo has been deleted.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.RESOLVED,\n            kept_photo=photo1,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Permanently delete the kept photo\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        # Check if duplicate still exists (might be deleted due to Bug #12 fix)\n        if not Duplicate.objects.filter(id=duplicate.id).exists():\n            # Expected behavior: duplicate deleted when < 2 photos\n            return\n        \n        # If duplicate still exists, detail should not crash\n        response = self.client.get(f\"/api/duplicates/{duplicate.id}/\")\n        # Either 200 or 404 depending on photo count\n        self.assertIn(response.status_code, [200, 404])\n\n\nclass DuplicateListEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for duplicate list view.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_list_excludes_single_photo_duplicates(self):\n        \"\"\"Test that list excludes duplicates with only 1 photo.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1)  # Only 1 photo\n        \n        response = self.client.get(\"/api/duplicates/\")\n        \n        self.assertEqual(response.status_code, 200)\n        # Should not include the single-photo duplicate\n        self.assertEqual(response.data[\"count\"], 0)\n\n    def test_list_with_kept_photo_deleted(self):\n        \"\"\"Test list view when kept_photo reference is broken.\n        \n        ForeignKey SET_NULL should handle this, but let's verify.\n        \"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.RESOLVED,\n            kept_photo=photo1,\n        )\n        duplicate.photos.add(photo1, photo2, photo3)\n        \n        # Delete the kept photo\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        # Try listing - should not crash\n        response = self.client.get(\"/api/duplicates/\")\n        \n        self.assertEqual(response.status_code, 200)\n\n    def test_list_pagination_edge_cases(self):\n        \"\"\"Test list pagination with various edge cases.\"\"\"\n        # Create 5 duplicates\n        for i in range(5):\n            photo1 = create_test_photo(owner=self.user)\n            photo2 = create_test_photo(owner=self.user)\n            dup = Duplicate.objects.create(\n                owner=self.user,\n                duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            )\n            dup.photos.add(photo1, photo2)\n        \n        # Page beyond results - Django's get_page() returns last page for out-of-range\n        # So page 100 will still return results (the last page)\n        response = self.client.get(\"/api/duplicates/?page=100\")\n        self.assertEqual(response.status_code, 200)\n        # Django returns last valid page, not empty\n        self.assertGreaterEqual(len(response.data[\"results\"]), 0)\n        \n        # Page 0 should become page 1 (our code uses max(1, page))\n        response = self.client.get(\"/api/duplicates/?page=0\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(len(response.data[\"results\"]), 5)\n\n\nclass DuplicateAutoSelectBestEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for auto_select_best_photo.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_auto_select_with_no_photos(self):\n        \"\"\"Test auto_select_best_photo with empty duplicate group.\"\"\"\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        \n        result = duplicate.auto_select_best_photo()\n        self.assertIsNone(result)\n\n    def test_auto_select_exact_copy_all_null_paths(self):\n        \"\"\"Test auto_select for exact copies when all photos have no main_file.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo1.main_file = None\n        photo2.main_file = None\n        photo1.save()\n        photo2.save()\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Should handle gracefully (might return None or first photo)\n        _result = duplicate.auto_select_best_photo()\n        # Just verify it doesn't crash\n\n    def test_auto_select_visual_duplicate_no_metadata(self):\n        \"\"\"Test auto_select for visual duplicates when photos have no metadata.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        # Delete any metadata\n        PhotoMetadata.objects.filter(photo__in=[photo1, photo2]).delete()\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Should handle gracefully\n        _result = duplicate.auto_select_best_photo()\n        # Just verify it doesn't crash\n\n\nclass DuplicateDismissEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for dismiss endpoint.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_dismiss_already_dismissed(self):\n        \"\"\"Test dismissing an already dismissed duplicate.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.DISMISSED,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Dismiss again\n        response = self.client.post(f\"/api/duplicates/{duplicate.id}/dismiss/\")\n        \n        # Should succeed (idempotent operation)\n        self.assertEqual(response.status_code, 200)\n\n    def test_dismiss_resolved_duplicate(self):\n        \"\"\"Test dismissing a resolved duplicate.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.RESOLVED,\n            kept_photo=photo1,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Dismiss the resolved duplicate\n        response = self.client.post(f\"/api/duplicates/{duplicate.id}/dismiss/\")\n        \n        # Should succeed\n        self.assertEqual(response.status_code, 200)\n        \n        duplicate.refresh_from_db()\n        self.assertEqual(duplicate.review_status, Duplicate.ReviewStatus.DISMISSED)\n\n\nclass DuplicateStatsEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for duplicate statistics endpoint.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_stats_with_no_duplicates(self):\n        \"\"\"Test stats endpoint with no duplicates.\"\"\"\n        response = self.client.get(\"/api/duplicates/stats/\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"total_duplicates\"], 0)\n        self.assertEqual(response.data[\"pending_duplicates\"], 0)\n        self.assertEqual(response.data[\"potential_savings_bytes\"], 0)\n        self.assertEqual(response.data[\"potential_savings_mb\"], 0)\n\n    def test_stats_counts_by_type(self):\n        \"\"\"Test stats counts by duplicate type.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        photo4 = create_test_photo(owner=self.user)\n        \n        # Create exact copy duplicate\n        dup1 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup1.photos.add(photo1, photo2)\n        \n        # Create visual duplicate\n        dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        dup2.photos.add(photo3, photo4)\n        \n        response = self.client.get(\"/api/duplicates/stats/\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"total_duplicates\"], 2)\n        self.assertEqual(response.data[\"by_type\"][\"exact_copy\"], 1)\n        self.assertEqual(response.data[\"by_type\"][\"visual_duplicate\"], 1)\n\n    def test_stats_counts_by_status(self):\n        \"\"\"Test stats counts by review status.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(6)]\n        \n        # Pending\n        dup1 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.PENDING,\n        )\n        dup1.photos.add(photos[0], photos[1])\n        \n        # Resolved\n        dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.RESOLVED,\n        )\n        dup2.photos.add(photos[2], photos[3])\n        \n        # Dismissed\n        dup3 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.DISMISSED,\n        )\n        dup3.photos.add(photos[4], photos[5])\n        \n        response = self.client.get(\"/api/duplicates/stats/\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"pending_duplicates\"], 1)\n        self.assertEqual(response.data[\"resolved_duplicates\"], 1)\n        self.assertEqual(response.data[\"dismissed_duplicates\"], 1)\n\n    def test_stats_potential_savings(self):\n        \"\"\"Test stats potential savings calculation.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.PENDING,\n            potential_savings=1024 * 1024 * 5,  # 5 MB\n        )\n        dup.photos.add(photo1, photo2)\n        \n        response = self.client.get(\"/api/duplicates/stats/\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"potential_savings_bytes\"], 5 * 1024 * 1024)\n        self.assertEqual(response.data[\"potential_savings_mb\"], 5.0)\n\n    def test_stats_photos_in_duplicates(self):\n        \"\"\"Test stats counts photos in duplicate groups correctly.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(5)]\n        \n        # Create duplicate with 3 photos\n        dup1 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup1.photos.add(photos[0], photos[1], photos[2])\n        \n        # Create another duplicate with 2 photos (one overlapping)\n        dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        dup2.photos.add(photos[2], photos[3])  # photos[2] is in both\n        \n        response = self.client.get(\"/api/duplicates/stats/\")\n        \n        self.assertEqual(response.status_code, 200)\n        # 4 unique photos are in duplicate groups (photos 0,1,2,3)\n        self.assertEqual(response.data[\"photos_in_duplicates\"], 4)\n\n    def test_stats_other_users_not_included(self):\n        \"\"\"Test stats don't include other user's duplicates.\"\"\"\n        other_user = create_test_user()\n        \n        # Create duplicate for other user\n        other_photo1 = create_test_photo(owner=other_user)\n        other_photo2 = create_test_photo(owner=other_user)\n        dup = Duplicate.objects.create(\n            owner=other_user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(other_photo1, other_photo2)\n        \n        # Current user should see 0 duplicates\n        response = self.client.get(\"/api/duplicates/stats/\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"total_duplicates\"], 0)\n"
  },
  {
    "path": "api/tests/test_duplicate_detection.py",
    "content": "\"\"\"\nTests for Duplicate Detection API and Models.\n\nTests cover:\n- Duplicate model creation, resolution, dismissal, revert\n- API endpoints for listing, filtering, resolving duplicates\n- Edge cases: permissions, invalid IDs, concurrent operations\n- BK-Tree algorithm for visual duplicate search\n\"\"\"\nimport uuid\n\nfrom django.test import TestCase\nfrom rest_framework import status\nfrom rest_framework.test import APIClient\n\nfrom api.models.duplicate import Duplicate\nfrom api.tests.utils import create_test_photos, create_test_user\n\n\nclass DuplicateModelTest(TestCase):\n    \"\"\"Tests for the Duplicate model methods.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.photos = create_test_photos(number_of_photos=3, owner=self.user)\n\n    def test_create_duplicate_group(self):\n        \"\"\"Test creating a duplicate group with multiple photos.\"\"\"\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        for photo in self.photos:\n            photo.duplicates.add(duplicate)\n\n        self.assertEqual(duplicate.photo_count, 3)\n        self.assertEqual(duplicate.review_status, Duplicate.ReviewStatus.PENDING)\n\n    def test_create_duplicate_with_less_than_2_photos_returns_none(self):\n        \"\"\"Test create_or_merge returns None with < 2 photos.\"\"\"\n        result = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=[self.photos[0]],\n        )\n        self.assertIsNone(result)\n\n    def test_create_or_merge_creates_new_duplicate(self):\n        \"\"\"Test create_or_merge creates a new duplicate group.\"\"\"\n        duplicate = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n            photos=self.photos[:2],\n            similarity_score=0.95,\n        )\n        self.assertIsNotNone(duplicate)\n        self.assertEqual(duplicate.photo_count, 2)\n        self.assertEqual(duplicate.similarity_score, 0.95)\n\n    def test_create_or_merge_merges_existing(self):\n        \"\"\"Test create_or_merge merges when photo already in group.\"\"\"\n        # Create initial group with first 2 photos\n        dup1 = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        # Try to create new group with overlapping photo\n        dup2 = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=[self.photos[1], self.photos[2]],\n        )\n        # Should return the same duplicate (merged)\n        self.assertEqual(dup1.id, dup2.id)\n        self.assertEqual(dup1.photo_count, 3)\n\n    def test_resolve_duplicate_trashes_others(self):\n        \"\"\"Test resolving a duplicate trashes non-kept photos.\"\"\"\n        duplicate = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos,\n        )\n        keep_photo = self.photos[0]\n        duplicate.resolve(keep_photo, trash_others=True)\n\n        # Refresh from DB\n        duplicate.refresh_from_db()\n        for photo in self.photos[1:]:\n            photo.refresh_from_db()\n            self.assertTrue(photo.in_trashcan)\n\n        self.assertEqual(duplicate.review_status, Duplicate.ReviewStatus.RESOLVED)\n        self.assertEqual(duplicate.kept_photo, keep_photo)\n        self.assertEqual(duplicate.trashed_count, 2)\n\n    def test_resolve_duplicate_without_trashing(self):\n        \"\"\"Test resolving without trashing others.\"\"\"\n        duplicate = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        keep_photo = self.photos[0]\n        duplicate.resolve(keep_photo, trash_others=False)\n\n        duplicate.refresh_from_db()\n        self.photos[1].refresh_from_db()\n\n        self.assertEqual(duplicate.review_status, Duplicate.ReviewStatus.RESOLVED)\n        self.assertFalse(self.photos[1].in_trashcan)\n        self.assertEqual(duplicate.trashed_count, 0)\n\n    def test_dismiss_duplicate(self):\n        \"\"\"Test dismissing a duplicate unlinks photos.\"\"\"\n        duplicate = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n            photos=self.photos[:2],\n        )\n        duplicate.dismiss()\n\n        duplicate.refresh_from_db()\n        self.assertEqual(duplicate.review_status, Duplicate.ReviewStatus.DISMISSED)\n        # Photos should be unlinked\n        self.assertEqual(duplicate.photo_count, 0)\n\n    def test_revert_resolved_duplicate(self):\n        \"\"\"Test reverting a resolved duplicate restores photos.\"\"\"\n        duplicate = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        duplicate.resolve(self.photos[0], trash_others=True)\n\n        # Verify photo was trashed\n        self.photos[1].refresh_from_db()\n        self.assertTrue(self.photos[1].in_trashcan)\n\n        # Revert\n        restored_count = duplicate.revert()\n        duplicate.refresh_from_db()\n        self.photos[1].refresh_from_db()\n\n        self.assertEqual(restored_count, 1)\n        self.assertEqual(duplicate.review_status, Duplicate.ReviewStatus.PENDING)\n        self.assertFalse(self.photos[1].in_trashcan)\n        self.assertIsNone(duplicate.kept_photo)\n\n    def test_revert_non_resolved_duplicate_returns_zero(self):\n        \"\"\"Test reverting a pending duplicate returns 0.\"\"\"\n        duplicate = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        restored_count = duplicate.revert()\n        self.assertEqual(restored_count, 0)\n\n    def test_auto_select_best_photo_exact_copy(self):\n        \"\"\"Test auto-selecting best photo for exact copies (shortest path).\"\"\"\n        duplicate = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        best = duplicate.auto_select_best_photo()\n        self.assertIsNotNone(best)\n\n    def test_calculate_potential_savings(self):\n        \"\"\"Test calculating potential storage savings.\"\"\"\n        # Set known sizes\n        self.photos[0].size = 1000000  # 1MB\n        self.photos[0].save()\n        self.photos[1].size = 500000   # 0.5MB\n        self.photos[1].save()\n\n        duplicate = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        savings = duplicate.calculate_potential_savings()\n\n        # Should be size of non-best photos\n        self.assertGreater(savings, 0)\n\n    def test_merge_duplicates(self):\n        \"\"\"Test merging two duplicate groups.\"\"\"\n        dup1 = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        # Create second group with photo[2]\n        extra_photo = create_test_photos(number_of_photos=1, owner=self.user)[0]\n        dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        extra_photo.duplicates.add(dup2)\n        self.photos[2].duplicates.add(dup2)\n\n        # Merge\n        dup1.merge_with(dup2)\n\n        # dup2 should be deleted\n        self.assertFalse(Duplicate.objects.filter(id=dup2.id).exists())\n        # dup1 should have all photos\n        self.assertEqual(dup1.photo_count, 4)\n\n\nclass DuplicateAPITest(TestCase):\n    \"\"\"Tests for Duplicate API endpoints.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n        self.photos = create_test_photos(number_of_photos=4, owner=self.user1)\n\n    def test_list_duplicates_empty(self):\n        \"\"\"Test listing duplicates when none exist.\"\"\"\n        response = self.client.get(\"/api/duplicates\")\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"count\"], 0)\n        self.assertEqual(data[\"results\"], [])\n\n    def test_list_duplicates_with_results(self):\n        \"\"\"Test listing duplicates with results.\"\"\"\n        Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        response = self.client.get(\"/api/duplicates\")\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"count\"], 1)\n\n    def test_list_duplicates_excludes_other_users(self):\n        \"\"\"Test that user can only see their own duplicates.\"\"\"\n        # Create duplicate for user2\n        photos2 = create_test_photos(number_of_photos=2, owner=self.user2)\n        Duplicate.create_or_merge(\n            owner=self.user2,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=photos2,\n        )\n        # User1 should not see it\n        response = self.client.get(\"/api/duplicates\")\n        data = response.json()\n        self.assertEqual(data[\"count\"], 0)\n\n    def test_list_duplicates_filter_by_type(self):\n        \"\"\"Test filtering duplicates by type.\"\"\"\n        Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n            photos=self.photos[2:4],\n        )\n\n        # Filter exact copies\n        response = self.client.get(\"/api/duplicates?duplicate_type=exact_copy\")\n        data = response.json()\n        self.assertEqual(data[\"count\"], 1)\n        self.assertEqual(data[\"results\"][0][\"duplicate_type\"], \"exact_copy\")\n\n    def test_list_duplicates_filter_by_status(self):\n        \"\"\"Test filtering duplicates by review status.\"\"\"\n        dup = Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        dup.resolve(self.photos[0], trash_others=True)\n\n        # Filter pending - should be empty\n        response = self.client.get(\"/api/duplicates?status=pending\")\n        data = response.json()\n        self.assertEqual(data[\"count\"], 0)\n\n        # Filter resolved\n        response = self.client.get(\"/api/duplicates?status=resolved\")\n        data = response.json()\n        self.assertEqual(data[\"count\"], 1)\n\n    def test_get_duplicate_detail(self):\n        \"\"\"Test getting duplicate detail.\"\"\"\n        dup = Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        response = self.client.get(f\"/api/duplicates/{dup.id}\")\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"id\"], str(dup.id))\n        self.assertEqual(len(data[\"photos\"]), 2)\n\n    def test_get_duplicate_detail_not_found(self):\n        \"\"\"Test getting non-existent duplicate returns 404.\"\"\"\n        fake_id = uuid.uuid4()\n        response = self.client.get(f\"/api/duplicates/{fake_id}\")\n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_get_duplicate_detail_wrong_user(self):\n        \"\"\"Test user cannot access other user's duplicate.\"\"\"\n        photos2 = create_test_photos(number_of_photos=2, owner=self.user2)\n        dup = Duplicate.create_or_merge(\n            owner=self.user2,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=photos2,\n        )\n        response = self.client.get(f\"/api/duplicates/{dup.id}\")\n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_resolve_duplicate(self):\n        \"\"\"Test resolving a duplicate via API.\"\"\"\n        dup = Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        payload = {\n            \"keep_photo_hash\": self.photos[0].image_hash,\n            \"trash_others\": True,\n        }\n        response = self.client.post(\n            f\"/api/duplicates/{dup.id}/resolve\",\n            data=payload,\n            format=\"json\",\n        )\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"resolved\")\n        self.assertEqual(data[\"trashed_count\"], 1)\n\n    def test_resolve_duplicate_missing_photo_hash(self):\n        \"\"\"Test resolve without photo hash returns error.\"\"\"\n        dup = Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        response = self.client.post(\n            f\"/api/duplicates/{dup.id}/resolve/\",\n            data={},\n            format=\"json\",\n        )\n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_resolve_duplicate_invalid_photo(self):\n        \"\"\"Test resolve with photo not in group returns error.\"\"\"\n        dup = Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        payload = {\n            \"keep_photo_hash\": self.photos[3].image_hash,  # Not in group\n            \"trash_others\": True,\n        }\n        response = self.client.post(\n            f\"/api/duplicates/{dup.id}/resolve\",\n            data=payload,\n            format=\"json\",\n        )\n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_dismiss_duplicate(self):\n        \"\"\"Test dismissing a duplicate via API.\"\"\"\n        dup = Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n            photos=self.photos[:2],\n        )\n        response = self.client.post(f\"/api/duplicates/{dup.id}/dismiss\")\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"dismissed\")\n\n    def test_revert_duplicate(self):\n        \"\"\"Test reverting a resolved duplicate via API.\"\"\"\n        dup = Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        dup.resolve(self.photos[0], trash_others=True)\n\n        response = self.client.post(f\"/api/duplicates/{dup.id}/revert\")\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"reverted\")\n        self.assertEqual(data[\"restored_count\"], 1)\n\n    def test_revert_non_resolved_duplicate_fails(self):\n        \"\"\"Test reverting a pending duplicate returns error.\"\"\"\n        dup = Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        response = self.client.post(f\"/api/duplicates/{dup.id}/revert\")\n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_delete_duplicate(self):\n        \"\"\"Test deleting a duplicate group via API.\"\"\"\n        dup = Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        dup_id = dup.id\n        # API uses /delete suffix instead of DELETE method on main path\n        response = self.client.delete(f\"/api/duplicates/{dup_id}/delete\")\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"deleted\")\n        self.assertEqual(data[\"unlinked_count\"], 2)\n\n        # Verify duplicate is gone\n        self.assertFalse(Duplicate.objects.filter(id=dup_id).exists())\n\n    def test_get_duplicate_stats(self):\n        \"\"\"Test getting duplicate statistics.\"\"\"\n        # Create some duplicates\n        Duplicate.create_or_merge(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertIn(\"total_duplicates\", data)\n        self.assertIn(\"pending_duplicates\", data)\n        self.assertIn(\"by_type\", data)\n\n    def test_detect_duplicates(self):\n        \"\"\"Test triggering duplicate detection.\"\"\"\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            data={\n                \"detect_exact_copies\": True,\n                \"detect_visual_duplicates\": False,\n            },\n            format=\"json\",\n        )\n        self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"queued\")\n\n\nclass DuplicateEdgeCasesTest(TestCase):\n    \"\"\"Tests for edge cases and potential bugs.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n        self.photos = create_test_photos(number_of_photos=5, owner=self.user)\n\n    def test_duplicate_with_single_photo_excluded_from_list(self):\n        \"\"\"Test duplicates with <2 photos are not returned in list.\"\"\"\n        # Create a duplicate and manually remove all but one photo\n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        self.photos[0].duplicates.add(dup)  # Only 1 photo\n\n        response = self.client.get(\"/api/duplicates/\")\n        data = response.json()\n        self.assertEqual(data[\"count\"], 0)\n\n    def test_resolve_already_resolved_duplicate(self):\n        \"\"\"Test resolving an already resolved duplicate.\"\"\"\n        dup = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:3],\n        )\n        dup.resolve(self.photos[0], trash_others=True)\n\n        # Try to resolve again with different photo\n        payload = {\"keep_photo_hash\": self.photos[1].image_hash}\n        response = self.client.post(\n            f\"/api/duplicates/{dup.id}/resolve\",\n            data=payload,\n            format=\"json\",\n        )\n        # Should still succeed (updating the resolution)\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n\n    def test_photo_in_multiple_duplicate_groups(self):\n        \"\"\"Test a photo can be in multiple duplicate groups of different types.\"\"\"\n        _dup_exact = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:2],\n        )\n        _dup_visual = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n            photos=[self.photos[0], self.photos[2]],\n        )\n\n        # Photo 0 should be in both groups\n        self.photos[0].refresh_from_db()\n        self.assertEqual(self.photos[0].duplicates.count(), 2)\n\n    def test_delete_photo_removes_from_duplicate(self):\n        \"\"\"Test deleting a photo removes it from duplicate group.\"\"\"\n        dup = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:3],\n        )\n        initial_count = dup.photo_count\n\n        # Delete a photo from the duplicate group\n        photo_to_delete = self.photos[0]\n        photo_to_delete.duplicates.remove(dup)\n\n        dup.refresh_from_db()\n        self.assertEqual(dup.photo_count, initial_count - 1)\n\n    def test_pagination_works_correctly(self):\n        \"\"\"Test pagination returns correct results.\"\"\"\n        # Create 25 duplicate groups\n        for i in range(25):\n            extra_photos = create_test_photos(number_of_photos=2, owner=self.user)\n            Duplicate.create_or_merge(\n                owner=self.user,\n                duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n                photos=extra_photos,\n            )\n\n        # Get first page\n        response = self.client.get(\"/api/duplicates?page=1&page_size=10\")\n        data = response.json()\n        self.assertEqual(len(data[\"results\"]), 10)\n        self.assertEqual(data[\"count\"], 25)\n        self.assertTrue(data[\"has_next\"])\n\n        # Get second page\n        response = self.client.get(\"/api/duplicates?page=2&page_size=10\")\n        data = response.json()\n        self.assertEqual(len(data[\"results\"]), 10)\n        self.assertTrue(data[\"has_previous\"])\n\n    def test_invalid_uuid_format(self):\n        \"\"\"Test invalid UUID format.\n        \n        Note: The URL regex pattern [0-9a-f-]+ matches any hex-like string,\n        so 'not-a-valid-uuid' partially matches. The view then returns an\n        empty result rather than 404/400. This is acceptable behavior.\n        \"\"\"\n        response = self.client.get(\"/api/duplicates/not-a-valid-uuid\")\n        # The regex matches the string (contains a-f and -), but no duplicate exists\n        # so it returns the list endpoint with empty results\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n\n    def test_concurrent_resolve_same_duplicate(self):\n        \"\"\"Test handling multiple resolution attempts.\n        \n        The API allows re-resolving a duplicate with the same or different photo.\n        This is useful if the user changes their mind about which photo to keep.\n        \"\"\"\n        dup = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=self.photos[:3],\n        )\n        # First resolve - keep photos[0], trash others\n        payload1 = {\"keep_photo_hash\": self.photos[0].image_hash}\n        response1 = self.client.post(\n            f\"/api/duplicates/{dup.id}/resolve\",\n            data=payload1,\n            format=\"json\",\n        )\n        self.assertEqual(response1.status_code, status.HTTP_200_OK)\n\n        # Verify photos[1] is now trashed\n        self.photos[1].refresh_from_db()\n        self.assertTrue(self.photos[1].in_trashcan)\n\n        # Re-resolve with same photo (idempotent operation)\n        payload2 = {\"keep_photo_hash\": self.photos[0].image_hash}\n        response2 = self.client.post(\n            f\"/api/duplicates/{dup.id}/resolve\",\n            data=payload2,\n            format=\"json\",\n        )\n        # Re-resolving is allowed\n        self.assertEqual(response2.status_code, status.HTTP_200_OK)\n\n\nclass BKTreeTest(TestCase):\n    \"\"\"Tests for BK-Tree algorithm used in visual duplicate detection.\"\"\"\n\n    def test_bk_tree_basic_operations(self):\n        \"\"\"Test BK-Tree add and search operations.\"\"\"\n        from api.duplicate_detection import BKTree\n        from api.perceptual_hash import hamming_distance\n\n        tree = BKTree(hamming_distance)\n        tree.add(\"photo1\", \"0000000000000000\")\n        tree.add(\"photo2\", \"0000000000000001\")  # 1 bit different\n        tree.add(\"photo3\", \"1111111111111111\")  # Very different\n\n        # Search with threshold 1\n        results = tree.search(\"0000000000000000\", 1)\n        result_ids = [r[0] for r in results]\n        self.assertIn(\"photo1\", result_ids)\n        self.assertIn(\"photo2\", result_ids)\n        self.assertNotIn(\"photo3\", result_ids)\n\n    def test_bk_tree_empty_search(self):\n        \"\"\"Test BK-Tree search on empty tree.\"\"\"\n        from api.duplicate_detection import BKTree\n        from api.perceptual_hash import hamming_distance\n\n        tree = BKTree(hamming_distance)\n        results = tree.search(\"0000000000000000\", 5)\n        self.assertEqual(results, [])\n\n\nclass UnionFindTest(TestCase):\n    \"\"\"Tests for Union-Find data structure used in duplicate grouping.\"\"\"\n\n    def test_union_find_basic(self):\n        \"\"\"Test Union-Find basic operations.\"\"\"\n        from api.duplicate_detection import UnionFind\n\n        uf = UnionFind()\n        uf.union(\"a\", \"b\")\n        uf.union(\"b\", \"c\")\n\n        # a, b, c should be in same group\n        self.assertEqual(uf.find(\"a\"), uf.find(\"b\"))\n        self.assertEqual(uf.find(\"b\"), uf.find(\"c\"))\n\n    def test_union_find_get_groups(self):\n        \"\"\"Test Union-Find get_groups returns correct groups.\"\"\"\n        from api.duplicate_detection import UnionFind\n\n        uf = UnionFind()\n        uf.union(\"a\", \"b\")\n        uf.union(\"c\", \"d\")\n        uf.union(\"e\", \"f\")\n\n        groups = uf.get_groups()\n        self.assertEqual(len(groups), 3)\n        # Each group should have 2 elements\n        for group in groups:\n            self.assertEqual(len(group), 2)\n\n    def test_union_find_single_elements_not_returned(self):\n        \"\"\"Test Union-Find doesn't return single-element groups.\"\"\"\n        from api.duplicate_detection import UnionFind\n\n        uf = UnionFind()\n        uf.find(\"a\")  # Creates single element\n        uf.union(\"b\", \"c\")\n\n        groups = uf.get_groups()\n        # Only the b-c group should be returned\n        self.assertEqual(len(groups), 1)\n        self.assertEqual(len(groups[0]), 2)\n"
  },
  {
    "path": "api/tests/test_duplicate_detection_logic.py",
    "content": "\"\"\"\nComprehensive tests for Duplicate Detection Logic.\n\nTests cover:\n- BKTree: Burkhard-Keller Tree for efficient Hamming distance queries\n- UnionFind: Union-Find data structure for grouping\n- detect_exact_copies: Exact copy detection via MD5 hash\n- detect_visual_duplicates: Visual similarity detection via perceptual hash\n- batch_detect_duplicates: Batch detection orchestration\n- Edge cases and error handling\n\"\"\"\n\nimport uuid\nfrom unittest.mock import patch\n\nfrom django.test import TestCase\n\nfrom api.models.duplicate import Duplicate\nfrom api.models.file import File\nfrom api.models.long_running_job import LongRunningJob\nfrom api.duplicate_detection import (\n    BKTree,\n    UnionFind,\n    detect_exact_copies,\n    detect_visual_duplicates,\n    batch_detect_duplicates,\n)\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass BKTreeTestCase(TestCase):\n    \"\"\"Tests for BKTree data structure.\"\"\"\n\n    def setUp(self):\n        # Simple Hamming distance for testing\n        def hamming(a, b):\n            return sum(c1 != c2 for c1, c2 in zip(a, b))\n        self.tree = BKTree(hamming)\n\n    def test_empty_tree_search_returns_empty(self):\n        \"\"\"Test searching empty tree returns empty list.\"\"\"\n        results = self.tree.search(\"abc\", 1)\n        self.assertEqual(results, [])\n\n    def test_add_single_item(self):\n        \"\"\"Test adding single item to tree.\"\"\"\n        self.tree.add(\"id1\", \"abc\")\n        \n        self.assertEqual(self.tree.size, 1)\n        self.assertIsNotNone(self.tree.root)\n        self.assertEqual(self.tree.root[\"id\"], \"id1\")\n        self.assertEqual(self.tree.root[\"hash\"], \"abc\")\n\n    def test_add_multiple_items(self):\n        \"\"\"Test adding multiple items to tree.\"\"\"\n        self.tree.add(\"id1\", \"abc\")\n        self.tree.add(\"id2\", \"abd\")\n        self.tree.add(\"id3\", \"xyz\")\n        \n        self.assertEqual(self.tree.size, 3)\n\n    def test_search_exact_match(self):\n        \"\"\"Test searching for exact match (distance 0).\"\"\"\n        self.tree.add(\"id1\", \"abc\")\n        self.tree.add(\"id2\", \"xyz\")\n        \n        results = self.tree.search(\"abc\", 0)\n        \n        self.assertEqual(len(results), 1)\n        self.assertEqual(results[0][0], \"id1\")\n        self.assertEqual(results[0][1], 0)  # distance\n\n    def test_search_within_threshold(self):\n        \"\"\"Test searching within Hamming threshold.\"\"\"\n        self.tree.add(\"id1\", \"abc\")\n        self.tree.add(\"id2\", \"abd\")  # distance 1 from \"abc\"\n        self.tree.add(\"id3\", \"xyz\")  # distance 3 from \"abc\"\n        \n        results = self.tree.search(\"abc\", 1)\n        \n        self.assertEqual(len(results), 2)\n        result_ids = [r[0] for r in results]\n        self.assertIn(\"id1\", result_ids)\n        self.assertIn(\"id2\", result_ids)\n        self.assertNotIn(\"id3\", result_ids)\n\n    def test_search_threshold_excludes_distant(self):\n        \"\"\"Test threshold correctly excludes distant items.\"\"\"\n        self.tree.add(\"id1\", \"aaaa\")\n        self.tree.add(\"id2\", \"zzzz\")  # distance 4 from \"aaaa\"\n        \n        results = self.tree.search(\"aaaa\", 3)\n        \n        self.assertEqual(len(results), 1)\n        self.assertEqual(results[0][0], \"id1\")\n\n    def test_search_returns_correct_distances(self):\n        \"\"\"Test search returns correct Hamming distances.\"\"\"\n        self.tree.add(\"id1\", \"abc\")\n        self.tree.add(\"id2\", \"aXc\")  # distance 1\n        self.tree.add(\"id3\", \"XXc\")  # distance 2\n        \n        results = self.tree.search(\"abc\", 2)\n        \n        results_dict = {r[0]: r[1] for r in results}\n        self.assertEqual(results_dict[\"id1\"], 0)\n        self.assertEqual(results_dict[\"id2\"], 1)\n        self.assertEqual(results_dict[\"id3\"], 2)\n\n    def test_add_duplicate_hash(self):\n        \"\"\"Test adding items with same hash.\"\"\"\n        self.tree.add(\"id1\", \"abc\")\n        self.tree.add(\"id2\", \"abc\")  # Same hash, different id\n        \n        self.assertEqual(self.tree.size, 2)\n        \n        results = self.tree.search(\"abc\", 0)\n        result_ids = [r[0] for r in results]\n        self.assertIn(\"id1\", result_ids)\n        self.assertIn(\"id2\", result_ids)\n\n    def test_large_tree_performance(self):\n        \"\"\"Test tree performs well with many items.\"\"\"\n        # Add 1000 items\n        for i in range(1000):\n            hash_val = f\"{i:04d}\"\n            self.tree.add(f\"id{i}\", hash_val)\n        \n        self.assertEqual(self.tree.size, 1000)\n        \n        # Search should complete quickly\n        results = self.tree.search(\"0500\", 1)\n        self.assertGreater(len(results), 0)\n\n\nclass UnionFindTestCase(TestCase):\n    \"\"\"Tests for UnionFind data structure.\"\"\"\n\n    def test_initial_find_creates_entry(self):\n        \"\"\"Test find creates new entry if not exists.\"\"\"\n        uf = UnionFind()\n        \n        root = uf.find(\"a\")\n        \n        self.assertEqual(root, \"a\")\n        self.assertIn(\"a\", uf.parent)\n\n    def test_find_same_element_returns_itself(self):\n        \"\"\"Test find on single element returns itself.\"\"\"\n        uf = UnionFind()\n        \n        uf.find(\"a\")\n        root = uf.find(\"a\")\n        \n        self.assertEqual(root, \"a\")\n\n    def test_union_links_elements(self):\n        \"\"\"Test union links two elements.\"\"\"\n        uf = UnionFind()\n        \n        uf.union(\"a\", \"b\")\n        \n        self.assertEqual(uf.find(\"a\"), uf.find(\"b\"))\n\n    def test_union_multiple_elements(self):\n        \"\"\"Test union of multiple elements.\"\"\"\n        uf = UnionFind()\n        \n        uf.union(\"a\", \"b\")\n        uf.union(\"b\", \"c\")\n        uf.union(\"c\", \"d\")\n        \n        # All should have same root\n        root_a = uf.find(\"a\")\n        self.assertEqual(uf.find(\"b\"), root_a)\n        self.assertEqual(uf.find(\"c\"), root_a)\n        self.assertEqual(uf.find(\"d\"), root_a)\n\n    def test_union_separate_groups(self):\n        \"\"\"Test union keeps separate groups separate.\"\"\"\n        uf = UnionFind()\n        \n        uf.union(\"a\", \"b\")\n        uf.union(\"c\", \"d\")\n        \n        self.assertEqual(uf.find(\"a\"), uf.find(\"b\"))\n        self.assertEqual(uf.find(\"c\"), uf.find(\"d\"))\n        self.assertNotEqual(uf.find(\"a\"), uf.find(\"c\"))\n\n    def test_get_groups_returns_groups(self):\n        \"\"\"Test get_groups returns correct groups.\"\"\"\n        uf = UnionFind()\n        \n        uf.union(\"a\", \"b\")\n        uf.union(\"c\", \"d\")\n        uf.union(\"d\", \"e\")\n        \n        groups = uf.get_groups()\n        \n        self.assertEqual(len(groups), 2)\n        \n        # Check group contents\n        group_sets = [set(g) for g in groups]\n        self.assertIn({\"a\", \"b\"}, group_sets)\n        self.assertIn({\"c\", \"d\", \"e\"}, group_sets)\n\n    def test_get_groups_excludes_singletons(self):\n        \"\"\"Test get_groups excludes single-element groups.\"\"\"\n        uf = UnionFind()\n        \n        uf.find(\"a\")  # Single element\n        uf.union(\"b\", \"c\")  # Pair\n        \n        groups = uf.get_groups()\n        \n        self.assertEqual(len(groups), 1)\n        self.assertEqual(set(groups[0]), {\"b\", \"c\"})\n\n    def test_path_compression(self):\n        \"\"\"Test path compression works (parent points directly to root).\"\"\"\n        uf = UnionFind()\n        \n        # Create long chain\n        uf.union(\"a\", \"b\")\n        uf.union(\"b\", \"c\")\n        uf.union(\"c\", \"d\")\n        \n        # After find, path should be compressed\n        root = uf.find(\"a\")\n        self.assertEqual(uf.parent[\"a\"], root)\n\n\nclass DetectExactCopiesTestCase(TestCase):\n    \"\"\"Tests for detect_exact_copies function.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def _create_photo_with_hash(self, image_hash, file_hash=None, **kwargs):\n        \"\"\"Helper to create Photo with specific hashes.\"\"\"\n        photo = create_test_photo(owner=self.user, **kwargs)\n        photo.image_hash = image_hash\n        \n        if file_hash:\n            file = File.objects.create(\n                hash=file_hash,\n                path=f\"/photos/test_{uuid.uuid4()}.jpg\",\n                type=File.IMAGE,\n            )\n            photo.files.add(file)\n            photo.main_file = file\n        \n        photo.save()\n        return photo\n\n    def test_no_duplicates_returns_zero(self):\n        \"\"\"Test no duplicates when all hashes unique.\"\"\"\n        self._create_photo_with_hash(\"hash1\")\n        self._create_photo_with_hash(\"hash2\")\n        self._create_photo_with_hash(\"hash3\")\n        \n        count = detect_exact_copies(self.user)\n        \n        self.assertEqual(count, 0)\n\n    def test_detects_duplicate_image_hash(self):\n        \"\"\"Test detects photos with same image_hash.\"\"\"\n        self._create_photo_with_hash(\"same_hash\")\n        self._create_photo_with_hash(\"same_hash\")\n        \n        count = detect_exact_copies(self.user)\n        \n        self.assertEqual(count, 1)\n        \n        # Check duplicate was created\n        duplicates = Duplicate.objects.filter(owner=self.user)\n        self.assertEqual(duplicates.count(), 1)\n        self.assertEqual(duplicates.first().photos.count(), 2)\n        self.assertEqual(duplicates.first().duplicate_type, Duplicate.DuplicateType.EXACT_COPY)\n\n    def test_detects_duplicate_file_hash(self):\n        \"\"\"Test detects photos with same file hash (MD5 part).\"\"\"\n        # Same MD5 content hash (first 32 chars)\n        file_hash1 = \"a\" * 32 + \"user1\"\n        file_hash2 = \"a\" * 32 + \"user2\"  # Same MD5, different suffix\n        \n        self._create_photo_with_hash(\"unique1\", file_hash1)\n        self._create_photo_with_hash(\"unique2\", file_hash2)\n        \n        count = detect_exact_copies(self.user)\n        \n        self.assertEqual(count, 1)\n\n    def test_skips_hidden_photos(self):\n        \"\"\"Test hidden photos are excluded.\"\"\"\n        self._create_photo_with_hash(\"same_hash\", hidden=True)\n        self._create_photo_with_hash(\"same_hash\")\n        \n        count = detect_exact_copies(self.user)\n        \n        # Only 1 visible photo with this hash, so no duplicate\n        self.assertEqual(count, 0)\n\n    def test_skips_trashed_photos(self):\n        \"\"\"Test trashed photos are excluded.\"\"\"\n        self._create_photo_with_hash(\"same_hash\", in_trashcan=True)\n        self._create_photo_with_hash(\"same_hash\")\n        \n        count = detect_exact_copies(self.user)\n        \n        self.assertEqual(count, 0)\n\n    def test_multiple_duplicate_groups(self):\n        \"\"\"Test detects multiple separate duplicate groups.\"\"\"\n        # Group 1\n        self._create_photo_with_hash(\"hash_a\")\n        self._create_photo_with_hash(\"hash_a\")\n        \n        # Group 2\n        self._create_photo_with_hash(\"hash_b\")\n        self._create_photo_with_hash(\"hash_b\")\n        self._create_photo_with_hash(\"hash_b\")\n        \n        count = detect_exact_copies(self.user)\n        \n        self.assertEqual(count, 2)\n\n    def test_progress_callback_called(self):\n        \"\"\"Test progress callback is called during detection.\"\"\"\n        self._create_photo_with_hash(\"same_hash\")\n        self._create_photo_with_hash(\"same_hash\")\n        \n        callback_calls = []\n        \n        def progress_callback(current, total, found):\n            callback_calls.append((current, total, found))\n        \n        detect_exact_copies(self.user, progress_callback=progress_callback)\n        \n        # Callback may or may not be called depending on group count\n\n\nclass DetectVisualDuplicatesTestCase(TestCase):\n    \"\"\"Tests for detect_visual_duplicates function.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def _create_photo_with_phash(self, perceptual_hash, **kwargs):\n        \"\"\"Helper to create Photo with perceptual hash.\"\"\"\n        photo = create_test_photo(owner=self.user, **kwargs)\n        photo.perceptual_hash = perceptual_hash\n        photo.save()\n        return photo\n\n    def test_no_photos_returns_zero(self):\n        \"\"\"Test returns 0 when no photos.\"\"\"\n        count = detect_visual_duplicates(self.user)\n        \n        self.assertEqual(count, 0)\n\n    def test_single_photo_returns_zero(self):\n        \"\"\"Test returns 0 with only one photo.\"\"\"\n        self._create_photo_with_phash(\"abcd1234\")\n        \n        count = detect_visual_duplicates(self.user)\n        \n        self.assertEqual(count, 0)\n\n    def test_detects_identical_phash(self):\n        \"\"\"Test detects photos with identical perceptual hash.\"\"\"\n        self._create_photo_with_phash(\"a\" * 16)\n        self._create_photo_with_phash(\"a\" * 16)\n        \n        count = detect_visual_duplicates(self.user, threshold=0)\n        \n        self.assertEqual(count, 1)\n\n    def test_detects_similar_phash_within_threshold(self):\n        \"\"\"Test detects photos with similar perceptual hash.\"\"\"\n        # These hashes differ by 2 characters\n        self._create_photo_with_phash(\"aaaaaaaaaaaaaaaa\")\n        self._create_photo_with_phash(\"aaaaaaaaaaaaaabb\")  # 2 chars different\n        \n        count = detect_visual_duplicates(self.user, threshold=5)\n        \n        self.assertEqual(count, 1)\n\n    def test_threshold_excludes_dissimilar(self):\n        \"\"\"Test threshold excludes dissimilar photos.\"\"\"\n        self._create_photo_with_phash(\"aaaaaaaaaaaaaaaa\")\n        self._create_photo_with_phash(\"zzzzzzzzzzzzzzzz\")  # Very different\n        \n        count = detect_visual_duplicates(self.user, threshold=5)\n        \n        self.assertEqual(count, 0)\n\n    def test_skips_photos_without_phash(self):\n        \"\"\"Test photos without perceptual hash are skipped.\"\"\"\n        self._create_photo_with_phash(\"aaaaaaaaaaaaaaaa\")\n        photo_no_hash = create_test_photo(owner=self.user)\n        photo_no_hash.perceptual_hash = None\n        photo_no_hash.save()\n        \n        count = detect_visual_duplicates(self.user)\n        \n        self.assertEqual(count, 0)\n\n    def test_skips_hidden_photos(self):\n        \"\"\"Test hidden photos are excluded.\"\"\"\n        self._create_photo_with_phash(\"aaaaaaaaaaaaaaaa\", hidden=True)\n        self._create_photo_with_phash(\"aaaaaaaaaaaaaaaa\")\n        \n        count = detect_visual_duplicates(self.user, threshold=0)\n        \n        self.assertEqual(count, 0)\n\n    def test_creates_visual_duplicate_type(self):\n        \"\"\"Test creates duplicate with VISUAL_DUPLICATE type.\"\"\"\n        self._create_photo_with_phash(\"a\" * 16)\n        self._create_photo_with_phash(\"a\" * 16)\n        \n        detect_visual_duplicates(self.user, threshold=0)\n        \n        duplicate = Duplicate.objects.filter(owner=self.user).first()\n        self.assertEqual(duplicate.duplicate_type, Duplicate.DuplicateType.VISUAL_DUPLICATE)\n\n\nclass BatchDetectDuplicatesTestCase(TestCase):\n    \"\"\"Tests for batch_detect_duplicates orchestration.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    @patch('api.duplicate_detection.detect_exact_copies')\n    @patch('api.duplicate_detection.detect_visual_duplicates')\n    def test_calls_both_detectors_by_default(self, mock_visual, mock_exact):\n        \"\"\"Test both detectors called with default options.\"\"\"\n        mock_exact.return_value = 5\n        mock_visual.return_value = 3\n        \n        batch_detect_duplicates(self.user)\n        \n        mock_exact.assert_called_once()\n        mock_visual.assert_called_once()\n\n    @patch('api.duplicate_detection.detect_exact_copies')\n    @patch('api.duplicate_detection.detect_visual_duplicates')\n    def test_respects_options(self, mock_visual, mock_exact):\n        \"\"\"Test options control which detectors run.\"\"\"\n        mock_exact.return_value = 0\n        mock_visual.return_value = 0\n        \n        batch_detect_duplicates(self.user, options={\n            'detect_exact_copies': False,\n            'detect_visual_duplicates': True,\n        })\n        \n        mock_exact.assert_not_called()\n        mock_visual.assert_called_once()\n\n    @patch('api.duplicate_detection.detect_exact_copies')\n    @patch('api.duplicate_detection.detect_visual_duplicates')\n    def test_passes_visual_threshold(self, mock_visual, mock_exact):\n        \"\"\"Test visual threshold passed to detector.\"\"\"\n        mock_exact.return_value = 0\n        mock_visual.return_value = 0\n        \n        batch_detect_duplicates(self.user, options={\n            'visual_threshold': 15,\n        })\n        \n        mock_visual.assert_called_once()\n        args, kwargs = mock_visual.call_args\n        self.assertEqual(args[1], 15)  # threshold argument\n\n    @patch('api.duplicate_detection.detect_exact_copies')\n    @patch('api.duplicate_detection.detect_visual_duplicates')\n    def test_creates_job(self, mock_visual, mock_exact):\n        \"\"\"Test LongRunningJob created for tracking.\"\"\"\n        mock_exact.return_value = 0\n        mock_visual.return_value = 0\n        \n        batch_detect_duplicates(self.user)\n        \n        job = LongRunningJob.objects.filter(\n            started_by=self.user,\n            job_type=LongRunningJob.JOB_DETECT_DUPLICATES,\n        ).first()\n        self.assertIsNotNone(job)\n\n    @patch('api.duplicate_detection.detect_exact_copies')\n    @patch('api.duplicate_detection.detect_visual_duplicates')\n    def test_clear_pending_option(self, mock_visual, mock_exact):\n        \"\"\"Test clear_pending option clears pending duplicates.\"\"\"\n        mock_exact.return_value = 0\n        mock_visual.return_value = 0\n        \n        # Create pending duplicate\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.PENDING,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        batch_detect_duplicates(self.user, options={'clear_pending': True})\n        \n        # Pending duplicate should be cleared\n        self.assertFalse(Duplicate.objects.filter(pk=duplicate.pk).exists())\n\n    @patch('api.duplicate_detection.detect_exact_copies')\n    def test_handles_exception(self, mock_exact):\n        \"\"\"Test exception handling during detection.\"\"\"\n        mock_exact.side_effect = Exception(\"Detection failed\")\n        \n        with self.assertRaises(Exception):\n            batch_detect_duplicates(self.user)\n        \n        # Job should be marked as failed\n        job = LongRunningJob.objects.filter(started_by=self.user).first()\n        self.assertIsNotNone(job)\n\n\nclass EdgeCasesTestCase(TestCase):\n    \"\"\"Edge case tests for duplicate detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_empty_photo_library(self):\n        \"\"\"Test detection on empty library.\"\"\"\n        count_exact = detect_exact_copies(self.user)\n        count_visual = detect_visual_duplicates(self.user)\n        \n        self.assertEqual(count_exact, 0)\n        self.assertEqual(count_visual, 0)\n\n    def test_photo_without_files(self):\n        \"\"\"Test photos without files are handled gracefully.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.image_hash = \"unique_hash\"\n        photo.save()\n        # No files attached\n        \n        count = detect_exact_copies(self.user)\n        self.assertEqual(count, 0)\n\n    def test_photo_with_short_file_hash(self):\n        \"\"\"Test files with hash shorter than 32 chars.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        file = File.objects.create(\n            hash=\"short\",  # Less than 32 chars\n            path=\"/photos/test.jpg\",\n            type=File.IMAGE,\n        )\n        photo.files.add(file)\n        photo.save()\n        \n        # Should not raise\n        count = detect_exact_copies(self.user)\n        self.assertEqual(count, 0)\n\n    def test_different_users_isolated(self):\n        \"\"\"Test duplicates are isolated per user.\"\"\"\n        other_user = create_test_user()\n        \n        # Create \"duplicate\" across users\n        photo1 = create_test_photo(owner=self.user)\n        photo1.image_hash = \"same_hash\"\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=other_user)\n        photo2.image_hash = \"same_hash\"\n        photo2.save()\n        \n        count = detect_exact_copies(self.user)\n        \n        # Should not find cross-user duplicates\n        self.assertEqual(count, 0)\n\n    def test_metadata_files_excluded(self):\n        \"\"\"Test metadata files excluded from duplicate detection.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        # Add metadata file\n        metadata_file = File.objects.create(\n            hash=\"a\" * 32 + \"suffix\",\n            path=\"/photos/test.xmp\",\n            type=File.METADATA_FILE,\n        )\n        photo.files.add(metadata_file)\n        photo.save()\n        \n        # Should not count metadata file for duplicate detection\n        count = detect_exact_copies(self.user)\n        self.assertEqual(count, 0)\n\n    def test_bktree_with_empty_hash(self):\n        \"\"\"Test BKTree handles empty/None hash gracefully.\"\"\"\n        def hamming(a, b):\n            if not a or not b:\n                return float('inf')\n            return sum(c1 != c2 for c1, c2 in zip(a, b))\n        \n        tree = BKTree(hamming)\n        tree.add(\"id1\", \"abc\")\n        \n        # Search with valid hash should work\n        results = tree.search(\"abc\", 1)\n        self.assertEqual(len(results), 1)\n\n    def test_union_find_with_same_element_union(self):\n        \"\"\"Test UnionFind handles self-union.\"\"\"\n        uf = UnionFind()\n        \n        uf.union(\"a\", \"a\")  # Self-union\n        \n        self.assertEqual(uf.find(\"a\"), \"a\")\n        groups = uf.get_groups()\n        self.assertEqual(len(groups), 0)  # Single element not in groups\n\n    def test_three_way_duplicate(self):\n        \"\"\"Test detection with 3+ copies of same file.\"\"\"\n        for _ in range(5):\n            photo = create_test_photo(owner=self.user)\n            photo.image_hash = \"five_way_duplicate\"\n            photo.save()\n        \n        count = detect_exact_copies(self.user)\n        \n        self.assertEqual(count, 1)\n        \n        # Check all 5 photos in same group\n        duplicate = Duplicate.objects.filter(owner=self.user).first()\n        self.assertEqual(duplicate.photos.count(), 5)\n\n    def test_transitive_duplicates_merged(self):\n        \"\"\"Test transitive duplicates are merged into one group.\"\"\"\n        # Photo A matches Photo B (same image_hash)\n        # Photo B matches Photo C (same image_hash)\n        # All three should be in same duplicate group\n        \n        photo_a = create_test_photo(owner=self.user)\n        photo_a.image_hash = \"hash_abc\"\n        file_a = File.objects.create(hash=\"x\" * 32, path=\"/a.jpg\", type=File.IMAGE)\n        photo_a.files.add(file_a)\n        photo_a.save()\n        \n        photo_b = create_test_photo(owner=self.user)\n        photo_b.image_hash = \"hash_abc\"  # Same as A\n        file_b = File.objects.create(hash=\"y\" * 32, path=\"/b.jpg\", type=File.IMAGE)\n        photo_b.files.add(file_b)\n        photo_b.save()\n        \n        photo_c = create_test_photo(owner=self.user)\n        photo_c.image_hash = \"hash_abc\"  # Same as A and B\n        file_c = File.objects.create(hash=\"z\" * 32, path=\"/c.jpg\", type=File.IMAGE)\n        photo_c.files.add(file_c)\n        photo_c.save()\n        \n        count = detect_exact_copies(self.user)\n        \n        # All 3 have same image_hash => all in one group\n        self.assertEqual(count, 1)\n        \n        duplicate = Duplicate.objects.filter(owner=self.user).first()\n        self.assertEqual(duplicate.photos.count(), 3)\n"
  },
  {
    "path": "api/tests/test_duplicate_filtering.py",
    "content": "\"\"\"\nTests for Duplicate API filtering and detection edge cases.\n\nTests cover:\n- Filter by duplicate type (exact_copy, visual_duplicate)\n- Filter by status (pending, resolved, dismissed)\n- Photos without perceptual hash\n- Visual threshold sensitivity\n- Detection job handling\n\"\"\"\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.models.duplicate import Duplicate\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass DuplicateFilterByTypeTestCase(TestCase):\n    \"\"\"Tests for filtering duplicates by type.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        \n        # Create photos for duplicates\n        self.photos = [create_test_photo(owner=self.user) for _ in range(6)]\n        \n        # Create exact copy duplicate\n        self.exact_dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        self.exact_dup.photos.add(self.photos[0], self.photos[1])\n        \n        # Create visual duplicate\n        self.visual_dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n            similarity_score=0.95,\n        )\n        self.visual_dup.photos.add(self.photos[2], self.photos[3])\n        \n        # Create another exact copy\n        self.exact_dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        self.exact_dup2.photos.add(self.photos[4], self.photos[5])\n\n    def test_filter_exact_copies(self):\n        \"\"\"Test filtering for exact copies only.\"\"\"\n        response = self.client.get(\n            f\"/api/duplicates?duplicate_type={Duplicate.DuplicateType.EXACT_COPY}\"\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 2)\n        for dup in response.data[\"results\"]:\n            self.assertEqual(dup[\"duplicate_type\"], Duplicate.DuplicateType.EXACT_COPY)\n\n    def test_filter_visual_duplicates(self):\n        \"\"\"Test filtering for visual duplicates only.\"\"\"\n        response = self.client.get(\n            f\"/api/duplicates?duplicate_type={Duplicate.DuplicateType.VISUAL_DUPLICATE}\"\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 1)\n        self.assertEqual(\n            response.data[\"results\"][0][\"duplicate_type\"],\n            Duplicate.DuplicateType.VISUAL_DUPLICATE\n        )\n\n    def test_filter_invalid_type(self):\n        \"\"\"Test filtering with invalid type returns empty or all.\"\"\"\n        response = self.client.get(\"/api/duplicates?duplicate_type=invalid_type\")\n        \n        self.assertEqual(response.status_code, 200)\n        # Should return all or empty depending on implementation\n        self.assertIn(response.data[\"count\"], [0, 3])\n\n    def test_no_filter_returns_all(self):\n        \"\"\"Test that no filter returns all duplicates.\"\"\"\n        response = self.client.get(\"/api/duplicates\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 3)\n\n\nclass DuplicateFilterByStatusTestCase(TestCase):\n    \"\"\"Tests for filtering duplicates by status.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        \n        # Create photos for duplicates\n        self.photos = [create_test_photo(owner=self.user) for _ in range(6)]\n        \n        # Create pending duplicate\n        self.pending = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.PENDING,\n        )\n        self.pending.photos.add(self.photos[0], self.photos[1])\n        \n        # Create resolved duplicate\n        self.resolved = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.RESOLVED,\n        )\n        self.resolved.photos.add(self.photos[2], self.photos[3])\n        \n        # Create dismissed duplicate\n        self.dismissed = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n            review_status=Duplicate.ReviewStatus.DISMISSED,\n        )\n        self.dismissed.photos.add(self.photos[4], self.photos[5])\n\n    def test_filter_pending(self):\n        \"\"\"Test filtering for pending duplicates.\"\"\"\n        response = self.client.get(\n            f\"/api/duplicates?status={Duplicate.ReviewStatus.PENDING}\"\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 1)\n        self.assertEqual(\n            response.data[\"results\"][0][\"review_status\"],\n            Duplicate.ReviewStatus.PENDING\n        )\n\n    def test_filter_resolved(self):\n        \"\"\"Test filtering for resolved duplicates.\"\"\"\n        response = self.client.get(\n            f\"/api/duplicates?status={Duplicate.ReviewStatus.RESOLVED}\"\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 1)\n\n    def test_filter_dismissed(self):\n        \"\"\"Test filtering for dismissed duplicates.\"\"\"\n        response = self.client.get(\n            f\"/api/duplicates?status={Duplicate.ReviewStatus.DISMISSED}\"\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 1)\n\n    def test_combined_type_and_status_filter(self):\n        \"\"\"Test combining type and status filters.\"\"\"\n        response = self.client.get(\n            f\"/api/duplicates?duplicate_type={Duplicate.DuplicateType.EXACT_COPY}&status={Duplicate.ReviewStatus.PENDING}\"\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 1)\n\n\nclass PhotosWithoutPerceptualHashTestCase(TestCase):\n    \"\"\"Tests for handling photos without perceptual hash during detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_detection_handles_null_perceptual_hash(self):\n        \"\"\"Test that detection handles photos with null perceptual hash.\"\"\"\n        # Create photos, some without perceptual hash\n        photo1 = create_test_photo(owner=self.user)\n        _photo2 = create_test_photo(owner=self.user)\n        \n        # Set null perceptual hash\n        photo1.image_phash = None\n        photo1.save()\n        \n        # Detection should not crash - just trigger the endpoint\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"detect_exact_copies\": True, \"detect_visual_duplicates\": True},\n            format='json',\n        )\n        \n        self.assertIn(response.status_code, [200, 202])\n\n    def test_visual_duplicate_detection_endpoint(self):\n        \"\"\"Test visual duplicate detection API endpoint.\"\"\"\n        # Create photos\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        # Set phash values\n        photo1.image_phash = \"abcd1234\"\n        photo1.save()\n        photo2.image_phash = \"abcd1234\"  # Same as photo1\n        photo2.save()\n        \n        # Detection should work\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"detect_visual_duplicates\": True, \"visual_threshold\": 5},\n            format='json',\n        )\n        \n        self.assertIn(response.status_code, [200, 202])\n\n\nclass DuplicateDetectionJobTestCase(TestCase):\n    \"\"\"Tests for duplicate detection job handling.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_detect_with_all_options(self):\n        \"\"\"Test detection with all options enabled.\"\"\"\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            {\n                \"detect_exact_copies\": True,\n                \"detect_visual_duplicates\": True,\n                \"visual_threshold\": 10,\n                \"clear_pending\": False,\n            },\n            format='json',\n        )\n        \n        self.assertIn(response.status_code, [200, 202])\n\n    def test_detect_exact_only(self):\n        \"\"\"Test detection with only exact copies.\"\"\"\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"detect_exact_copies\": True, \"detect_visual_duplicates\": False},\n            format='json',\n        )\n        \n        self.assertIn(response.status_code, [200, 202])\n\n    def test_detect_visual_only(self):\n        \"\"\"Test detection with only visual duplicates.\"\"\"\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"detect_exact_copies\": False, \"detect_visual_duplicates\": True},\n            format='json',\n        )\n        \n        self.assertIn(response.status_code, [200, 202])\n\n    def test_detect_nothing_returns_error(self):\n        \"\"\"Test detection with both options false.\"\"\"\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"detect_exact_copies\": False, \"detect_visual_duplicates\": False},\n            format='json',\n        )\n        \n        # Should return 400 or just skip detection\n        self.assertIn(response.status_code, [200, 202, 400])\n\n    def test_detect_with_clear_pending(self):\n        \"\"\"Test detection with clear_pending option.\"\"\"\n        # Create a pending duplicate first\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.PENDING,\n        )\n        dup.photos.add(photo1, photo2)\n        \n        # Detection API queues a background job\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"detect_exact_copies\": True, \"clear_pending\": True},\n            format='json',\n        )\n        \n        self.assertIn(response.status_code, [200, 202])\n\n\nclass VisualThresholdTestCase(TestCase):\n    \"\"\"Tests for visual duplicate threshold sensitivity.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_strict_threshold(self):\n        \"\"\"Test visual detection with strict threshold (low value).\"\"\"\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"detect_visual_duplicates\": True, \"visual_threshold\": 3},\n            format='json',\n        )\n        \n        self.assertIn(response.status_code, [200, 202])\n\n    def test_loose_threshold(self):\n        \"\"\"Test visual detection with loose threshold (high value).\"\"\"\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"detect_visual_duplicates\": True, \"visual_threshold\": 20},\n            format='json',\n        )\n        \n        self.assertIn(response.status_code, [200, 202])\n\n    def test_zero_threshold(self):\n        \"\"\"Test visual detection with zero threshold (exact match only).\"\"\"\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"detect_visual_duplicates\": True, \"visual_threshold\": 0},\n            format='json',\n        )\n        \n        self.assertIn(response.status_code, [200, 202])\n\n    def test_negative_threshold_handled(self):\n        \"\"\"Test that negative threshold is handled gracefully.\"\"\"\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"detect_visual_duplicates\": True, \"visual_threshold\": -5},\n            format='json',\n        )\n        \n        # Should handle gracefully - either 400 or clamp to 0\n        self.assertIn(response.status_code, [200, 202, 400])\n\n\nclass DuplicateListSortingTestCase(TestCase):\n    \"\"\"Tests for duplicate list sorting.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        \n        # Create multiple duplicates\n        self.photos = [create_test_photo(owner=self.user) for _ in range(4)]\n        \n        self.dup1 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        self.dup1.photos.add(self.photos[0], self.photos[1])\n        \n        self.dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n            similarity_score=0.95,\n        )\n        self.dup2.photos.add(self.photos[2], self.photos[3])\n\n    def test_default_sorting(self):\n        \"\"\"Test default sorting order.\"\"\"\n        response = self.client.get(\"/api/duplicates\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 2)\n\n    def test_sort_by_created_at(self):\n        \"\"\"Test sorting by created_at.\"\"\"\n        response = self.client.get(\"/api/duplicates?ordering=-created_at\")\n        \n        self.assertEqual(response.status_code, 200)\n\n\nclass DuplicateBulkActionsTestCase(TestCase):\n    \"\"\"Tests for bulk actions on duplicates.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_bulk_dismiss(self):\n        \"\"\"Test bulk dismissing duplicates.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(4)]\n        \n        dup1 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup1.photos.add(photos[0], photos[1])\n        \n        dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup2.photos.add(photos[2], photos[3])\n        \n        # Dismiss first one\n        response = self.client.post(f\"/api/duplicates/{dup1.id}/dismiss\")\n        self.assertEqual(response.status_code, 200)\n        \n        dup1.refresh_from_db()\n        self.assertEqual(dup1.review_status, Duplicate.ReviewStatus.DISMISSED)\n\n\nclass EmptyDuplicateGroupTestCase(TestCase):\n    \"\"\"Tests for handling empty or invalid duplicate groups.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_duplicate_with_no_photos(self):\n        \"\"\"Test handling duplicate group with no photos.\"\"\"\n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        # Don't add any photos\n        \n        response = self.client.get(f\"/api/duplicates/{dup.id}\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"photo_count\"], 0)\n\n    def test_duplicate_with_one_photo(self):\n        \"\"\"Test handling duplicate group with only one photo.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(photo)\n        \n        response = self.client.get(f\"/api/duplicates/{dup.id}\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"photo_count\"], 1)\n\n    def test_resolve_single_photo_duplicate(self):\n        \"\"\"Test resolving duplicate with only one photo.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(photo)\n        \n        response = self.client.post(\n            f\"/api/duplicates/{dup.id}/resolve\",\n            {\"keep_photo_hash\": photo.image_hash},\n            format='json',\n        )\n        \n        # Should succeed but with no photos to trash\n        self.assertIn(response.status_code, [200, 400])\n"
  },
  {
    "path": "api/tests/test_edge_cases_integration.py",
    "content": "\"\"\"\nTests for edge cases and error handling.\n\nTests:\n- Photos with no EXIF data\n- Missing/corrupted files\n- Concurrent detection jobs\n- Empty/invalid data handling\n\"\"\"\n\n\nfrom django.test import TestCase, TransactionTestCase\nfrom rest_framework.test import APIClient, APITestCase\n\nfrom api.models.duplicate import Duplicate\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.photo_metadata import PhotoMetadata\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass PhotoNoExifDataTestCase(TestCase):\n    \"\"\"Test handling of photos with no EXIF data.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_photo_without_exif_timestamp(self):\n        \"\"\"Test handling photo with no EXIF timestamp.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.exif_timestamp = None\n        photo.save()\n        \n        # Should still be usable\n        self.assertIsNotNone(photo.pk)\n        self.assertIsNone(photo.exif_timestamp)\n\n    def test_photo_without_gps_data(self):\n        \"\"\"Test handling photo with no GPS data.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.exif_gps_lat = None\n        photo.exif_gps_lon = None\n        photo.save()\n        \n        # Should still be usable\n        self.assertIsNotNone(photo.pk)\n\n    def test_photo_without_perceptual_hash(self):\n        \"\"\"Test handling photo with no perceptual hash.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.image_phash = None\n        photo.save()\n        \n        # Should still be usable but not in visual duplicate detection\n        self.assertIsNotNone(photo.pk)\n        self.assertIsNone(photo.image_phash)\n\n    def test_stack_with_no_exif_photos(self):\n        \"\"\"Test creating stack with photos that have no EXIF.\"\"\"\n        photos = []\n        for _ in range(3):\n            photo = create_test_photo(owner=self.user)\n            photo.exif_timestamp = None\n            photo.save()\n            photos.append(photo)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*photos)\n        \n        # auto_select_primary should still work\n        _result = stack.auto_select_primary()\n        # May or may not select one depending on implementation\n        # The important thing is it doesn't crash\n\n    def test_duplicate_with_no_metadata_photos(self):\n        \"\"\"Test duplicate group with photos lacking metadata.\"\"\"\n        photos = []\n        for _ in range(2):\n            photo = create_test_photo(owner=self.user)\n            # Don't create metadata\n            PhotoMetadata.objects.filter(photo=photo).delete()\n            photos.append(photo)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        dup.photos.add(*photos)\n        \n        # auto_select_best_photo should handle gracefully\n        _result = dup.auto_select_best_photo()\n        # Should return something (or None) without crashing\n\n    def test_burst_detection_no_timestamps(self):\n        \"\"\"Test burst detection with photos lacking timestamps.\"\"\"\n        photos = []\n        for _ in range(5):\n            photo = create_test_photo(owner=self.user)\n            photo.exif_timestamp = None\n            photo.save()\n            photos.append(photo)\n        \n        # Should not create burst stacks for photos without timestamps\n        # (timestamps are required for burst proximity detection)\n\n\nclass MissingFileTestCase(TestCase):\n    \"\"\"Test handling of photos with missing files.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_photo_with_null_main_file(self):\n        \"\"\"Test handling photo with null main_file reference.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        # This might not be allowed by the model, but test graceful handling\n        # Note: Can't actually set main_file to None due to NOT NULL constraint\n        # So we test that the photo with a valid file still works\n        self.assertIsNotNone(photo.main_file)\n\n    def test_stack_photos_with_missing_metadata(self):\n        \"\"\"Test stack with photos that have no PhotoMetadata records.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(3)]\n        \n        # Delete metadata records\n        PhotoMetadata.objects.filter(photo__in=photos).delete()\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*photos)\n        \n        # Stack should still function\n        self.assertEqual(stack.photos.count(), 3)\n\n\nclass ConcurrentDetectionTestCase(TransactionTestCase):\n    \"\"\"Test concurrent detection job handling.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_concurrent_duplicate_detection_requests(self):\n        \"\"\"Test handling multiple simultaneous detection requests.\"\"\"\n        # Create photos\n        for _ in range(5):\n            create_test_photo(owner=self.user)\n        \n        results = []\n        errors = []\n        \n        def trigger_detection():\n            try:\n                response = self.client.post(\"/api/duplicates/detect\")\n                results.append(response.status_code)\n            except Exception as e:\n                errors.append(str(e))\n        \n        # Trigger multiple detections (simulated - they run sequentially in test)\n        for _ in range(3):\n            trigger_detection()\n        \n        # All requests should succeed (or be queued)\n        for status in results:\n            self.assertIn(status, [200, 202, 409])  # 409 = conflict if already running\n        \n        # No errors should occur\n        self.assertEqual(len(errors), 0)\n\n    def test_concurrent_stack_detection_requests(self):\n        \"\"\"Test handling multiple simultaneous stack detection requests.\"\"\"\n        # Create photos\n        for _ in range(5):\n            create_test_photo(owner=self.user)\n        \n        results = []\n        \n        for _ in range(3):\n            response = self.client.post(\"/api/stacks/detect\")\n            results.append(response.status_code)\n        \n        # All requests should succeed or be handled gracefully\n        for status in results:\n            self.assertIn(status, [200, 202, 409])\n\n\nclass EmptyDataTestCase(APITestCase):\n    \"\"\"Test handling of empty or minimal data.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_duplicate_list_empty(self):\n        \"\"\"Test duplicate list with no duplicates.\"\"\"\n        response = self.client.get(\"/api/duplicates\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(len(response.data.get(\"results\", [])), 0)\n\n    def test_stack_list_empty(self):\n        \"\"\"Test stack list with no stacks.\"\"\"\n        response = self.client.get(\"/api/stacks\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(len(response.data.get(\"results\", [])), 0)\n\n    def test_duplicate_stats_empty(self):\n        \"\"\"Test duplicate stats with no data.\"\"\"\n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, 200)\n\n    def test_stack_stats_empty(self):\n        \"\"\"Test stack stats with no data.\"\"\"\n        response = self.client.get(\"/api/stacks/stats\")\n        self.assertEqual(response.status_code, 200)\n\n    def test_detection_with_no_photos(self):\n        \"\"\"Test detection when user has no photos.\"\"\"\n        response = self.client.post(\"/api/duplicates/detect\")\n        # Should succeed but find nothing\n        self.assertIn(response.status_code, [200, 202])\n\n    def test_stack_detection_with_no_photos(self):\n        \"\"\"Test stack detection when user has no photos.\"\"\"\n        response = self.client.post(\"/api/stacks/detect\")\n        # Should succeed but find nothing\n        self.assertIn(response.status_code, [200, 202])\n\n\nclass InvalidDataTestCase(APITestCase):\n    \"\"\"Test handling of invalid data inputs.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_resolve_with_invalid_photo_id(self):\n        \"\"\"Test resolve with invalid photo ID.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(*photos)\n        \n        response = self.client.post(\n            f\"/api/duplicates/{dup.id}/resolve\",\n            {\"kept_photo_id\": \"not-a-valid-uuid\"},\n            format=\"json\"\n        )\n        self.assertIn(response.status_code, [400, 404])\n\n    def test_add_to_stack_with_invalid_photo_ids(self):\n        \"\"\"Test adding invalid photo IDs to stack.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*photos)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/add\",\n            {\"photo_ids\": [\"invalid-id-1\", \"invalid-id-2\"]},\n            format=\"json\"\n        )\n        # Should handle gracefully\n        self.assertIn(response.status_code, [200, 400, 404])\n\n    def test_set_primary_with_invalid_photo_id(self):\n        \"\"\"Test setting primary with invalid photo ID.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*photos)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/primary\",\n            {\"photo_id\": \"not-a-uuid\"},\n            format=\"json\"\n        )\n        self.assertIn(response.status_code, [400, 404])\n\n    def test_detection_with_invalid_options(self):\n        \"\"\"Test detection with invalid options.\"\"\"\n        response = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"invalid_option\": \"value\"},\n            format=\"json\"\n        )\n        # Should ignore invalid options and proceed\n        self.assertIn(response.status_code, [200, 202, 400])\n\n\nclass SinglePhotoGroupTestCase(TestCase):\n    \"\"\"Test handling of single-photo groups.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_duplicate_with_single_photo_deleted(self):\n        \"\"\"Test duplicate group cleanup when reduced to single photo.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(*photos)\n        \n        # Remove one photo\n        dup.photos.remove(photos[0])\n        \n        # Group should still exist but may be cleaned up depending on implementation\n        # The important thing is no crash occurs\n\n    def test_stack_with_single_photo(self):\n        \"\"\"Test stack behavior with single photo.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo)\n        \n        # Single photo stack is valid for manual stacks\n        self.assertEqual(stack.photos.count(), 1)\n\n    def test_auto_select_with_single_photo(self):\n        \"\"\"Test auto_select_primary with single photo.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo)\n        \n        _result = stack.auto_select_primary()\n        stack.refresh_from_db()\n        \n        # Should select the only photo\n        self.assertEqual(stack.primary_photo, photo)\n\n\nclass PhotoDeletionEdgeCasesTestCase(TestCase):\n    \"\"\"Test edge cases around photo deletion.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_delete_photo_in_multiple_stacks(self):\n        \"\"\"Test deleting a photo that's in multiple stacks.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        other_photos1 = [create_test_photo(owner=self.user) for _ in range(2)]\n        other_photos2 = [create_test_photo(owner=self.user) for _ in range(2)]\n        \n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack1.photos.add(photo, *other_photos1)\n        \n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack2.photos.add(photo, *other_photos2)\n        \n        # Delete the shared photo\n        photo.manual_delete()\n        \n        # Both stacks should still exist with remaining photos\n        stack1.refresh_from_db()\n        stack2.refresh_from_db()\n        self.assertEqual(stack1.photos.count(), 2)\n        self.assertEqual(stack2.photos.count(), 2)\n\n    def test_delete_photo_in_multiple_duplicate_groups(self):\n        \"\"\"Test deleting a photo that's in multiple duplicate groups.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        other_photos1 = [create_test_photo(owner=self.user)]\n        other_photos2 = [create_test_photo(owner=self.user)]\n        \n        dup1 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup1.photos.add(photo, *other_photos1)\n        \n        dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        dup2.photos.add(photo, *other_photos2)\n        \n        # Delete the shared photo\n        photo.manual_delete()\n        \n        # Both groups should be cleaned up (single photo remaining)\n        # Depending on implementation, they may be deleted or left with 1 photo\n\n    def test_delete_primary_photo_from_stack(self):\n        \"\"\"Test deleting the primary photo from a stack.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(3)]\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            primary_photo=photos[0],\n        )\n        stack.photos.add(*photos)\n        \n        # Delete the primary photo\n        photos[0].manual_delete()\n        \n        stack.refresh_from_db()\n        \n        # Stack should still exist\n        self.assertEqual(stack.photos.count(), 2)\n        # Primary may be cleared or set to another photo depending on implementation\n\n\nclass MetadataEdgeCasesTestCase(TestCase):\n    \"\"\"Test edge cases for metadata handling.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_photo_with_extreme_dimensions(self):\n        \"\"\"Test handling photos with extreme dimensions.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        metadata, _ = PhotoMetadata.objects.get_or_create(photo=photo)\n        \n        # Very large dimensions\n        metadata.width = 50000\n        metadata.height = 50000\n        metadata.save()\n        \n        # Should not crash on resolution calculation\n        self.assertIsNotNone(metadata.resolution)\n\n    def test_photo_with_zero_dimensions(self):\n        \"\"\"Test handling photos with zero dimensions.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        metadata, _ = PhotoMetadata.objects.get_or_create(photo=photo)\n        \n        metadata.width = 0\n        metadata.height = 0\n        metadata.save()\n        \n        # Should handle gracefully\n        self.assertEqual(metadata.width, 0)\n\n    def test_duplicate_savings_with_zero_size(self):\n        \"\"\"Test potential savings calculation with zero-size photos.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(3)]\n        for photo in photos:\n            photo.size = 0\n            photo.save()\n        \n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(*photos)\n        \n        savings = dup.calculate_potential_savings()\n        self.assertEqual(savings, 0)\n"
  },
  {
    "path": "api/tests/test_edit_photo_details.py",
    "content": "from unittest.mock import patch\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass EditPhotoDetailsTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n\n    @patch(\"api.models.Photo._extract_date_time_from_exif\", autospec=True)\n    def test_should_update_timestamp(self, extract_date_time_from_exif_mock):\n        photo = create_test_photo(owner=self.user1)\n\n        payload = {\"exif_timestamp\": \"1970-01-01T00:00:00.001Z\"}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.patch(\n            f\"/api/photos/edit/{photo.image_hash}/\",\n            format=\"json\",\n            data=payload,\n            headers=headers,\n        )\n        data = response.json()\n\n        self.assertEqual(200, response.status_code)\n        self.assertEqual(\"1970-01-01T00:00:00.001000Z\", data[\"timestamp\"])\n        self.assertEqual(photo.image_hash, data[\"image_hash\"])\n        self.assertIsNone(data[\"exif_timestamp\"])\n        self.assertEqual(0, data[\"rating\"])\n        self.assertFalse(data[\"hidden\"])\n        self.assertFalse(data[\"in_trashcan\"])\n        self.assertFalse(data[\"video\"])\n        extract_date_time_from_exif_mock.assert_called()\n\n    @patch(\"api.models.Photo._extract_date_time_from_exif\", autospec=True)\n    def test_should_not_update_other_properties(self, extract_date_time_from_exif_mock):\n        photo = create_test_photo(owner=self.user1)\n\n        payload = {\n            \"timestamp\": \"1970-01-01T00:00:00.001Z\",\n            \"image_hash\": \"BLAH-BLAH-BLAH-BLAH\",\n            \"rating\": 100,\n            \"deleted\": True,\n            \"hidden\": True,\n            \"in_trashcan\": True,\n            \"video\": True,\n        }\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.patch(\n            f\"/api/photos/edit/{photo.image_hash}/\",\n            format=\"json\",\n            data=payload,\n            headers=headers,\n        )\n        data = response.json()\n\n        self.assertEqual(200, response.status_code)\n        self.assertNotEqual(payload[\"timestamp\"], data[\"timestamp\"])\n        self.assertNotEqual(payload[\"image_hash\"], data[\"image_hash\"])\n        self.assertNotEqual(payload[\"rating\"], data[\"rating\"])\n        self.assertNotEqual(payload[\"hidden\"], data[\"hidden\"])\n        self.assertNotEqual(payload[\"in_trashcan\"], data[\"in_trashcan\"])\n        self.assertNotEqual(payload[\"video\"], data[\"video\"])\n        extract_date_time_from_exif_mock.assert_not_called()\n"
  },
  {
    "path": "api/tests/test_face_extractor.py",
    "content": "\"\"\"Tests for face_extractor module.\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nfrom django.test import TestCase\n\nfrom api import face_extractor\n\n\nclass FaceExtractorTest(TestCase):\n    \"\"\"Test face extraction functionality.\"\"\"\n\n    @patch(\"api.face_extractor.get_face_locations\")\n    def test_extract_from_dlib_handles_exception(self, mock_get_face_locations):\n        \"\"\"Test that extract_from_dlib returns empty list when exception occurs.\"\"\"\n        # Setup: make get_face_locations raise an exception\n        mock_get_face_locations.side_effect = Exception(\"Test exception\")\n\n        # Create a mock owner with face_recognition_model\n        mock_owner = MagicMock()\n        mock_owner.face_recognition_model = \"hog\"\n\n        # Call the function\n        result = face_extractor.extract_from_dlib(\n            image_path=\"/path/to/image.jpg\",\n            big_thumbnail_path=\"/path/to/thumbnail.jpg\",\n            owner=mock_owner,\n        )\n\n        # Verify that it returns an empty list instead of raising UnboundLocalError\n        self.assertEqual(result, [])\n\n    @patch(\"api.face_extractor.get_face_locations\")\n    def test_extract_from_dlib_success(self, mock_get_face_locations):\n        \"\"\"Test that extract_from_dlib works correctly on success.\"\"\"\n        # Setup: make get_face_locations return some face locations\n        mock_face_locations = [(10, 20, 30, 40), (50, 60, 70, 80)]\n        mock_get_face_locations.return_value = mock_face_locations\n\n        # Create a mock owner with face_recognition_model\n        mock_owner = MagicMock()\n        mock_owner.face_recognition_model = \"hog\"\n\n        # Call the function\n        result = face_extractor.extract_from_dlib(\n            image_path=\"/path/to/image.jpg\",\n            big_thumbnail_path=\"/path/to/thumbnail.jpg\",\n            owner=mock_owner,\n        )\n\n        # Verify that it returns face locations with None appended\n        expected = [(10, 20, 30, 40, None), (50, 60, 70, 80, None)]\n        self.assertEqual(result, expected)\n\n    @patch(\"api.face_extractor.extract_from_exif\")\n    @patch(\"api.face_extractor.extract_from_dlib\")\n    def test_extract_prefers_exif(self, mock_dlib, mock_exif):\n        \"\"\"Test that extract function prefers EXIF data over dlib.\"\"\"\n        # Setup: make extract_from_exif return some data\n        mock_exif_data = [(10, 20, 30, 40, \"John Doe\")]\n        mock_exif.return_value = mock_exif_data\n\n        mock_owner = MagicMock()\n\n        # Call the function\n        result = face_extractor.extract(\n            image_path=\"/path/to/image.jpg\",\n            big_thumbnail_path=\"/path/to/thumbnail.jpg\",\n            owner=mock_owner,\n        )\n\n        # Verify that it returns EXIF data and doesn't call dlib\n        self.assertEqual(result, mock_exif_data)\n        mock_dlib.assert_not_called()\n\n    @patch(\"api.face_extractor.extract_from_exif\")\n    @patch(\"api.face_extractor.extract_from_dlib\")\n    def test_extract_fallback_to_dlib(self, mock_dlib, mock_exif):\n        \"\"\"Test that extract function falls back to dlib when no EXIF data.\"\"\"\n        # Setup: make extract_from_exif return None\n        mock_exif.return_value = None\n        mock_dlib_data = [(10, 20, 30, 40, None)]\n        mock_dlib.return_value = mock_dlib_data\n\n        mock_owner = MagicMock()\n\n        # Call the function\n        result = face_extractor.extract(\n            image_path=\"/path/to/image.jpg\",\n            big_thumbnail_path=\"/path/to/thumbnail.jpg\",\n            owner=mock_owner,\n        )\n\n        # Verify that it returns dlib data\n        self.assertEqual(result, mock_dlib_data)\n        mock_dlib.assert_called_once()\n"
  },
  {
    "path": "api/tests/test_face_writeback.py",
    "content": "from unittest.mock import MagicMock, patch\n\nfrom django.test import TestCase\n\nfrom api.metadata.face_regions import (\n    build_face_region_exiftool_args,\n    get_face_region_tags,\n    reverse_orientation_transform,\n    thumbnail_coords_to_normalized,\n)\nfrom api.models.person import Person\nfrom api.tests.utils import (\n    create_test_face,\n    create_test_person,\n    create_test_photo,\n    create_test_user,\n)\n\n\nclass TestThumbnailCoordsToNormalized(TestCase):\n    def test_basic_conversion(self):\n        \"\"\"Known pixel coords should produce expected normalized values.\"\"\"\n        # Face at center of a 1000x800 thumbnail\n        # top=300, right=600, bottom=500, left=400\n        x, y, w, h = thumbnail_coords_to_normalized(\n            top=300,\n            right=600,\n            bottom=500,\n            left=400,\n            thumb_width=1000,\n            thumb_height=800,\n        )\n        self.assertAlmostEqual(x, 0.5)  # center_x = (400+600)/2/1000\n        self.assertAlmostEqual(y, 0.5)  # center_y = (300+500)/2/800\n        self.assertAlmostEqual(w, 0.2)  # w = (600-400)/1000\n        self.assertAlmostEqual(h, 0.25)  # h = (500-300)/800\n\n    def test_corner_face(self):\n        \"\"\"Face in top-left corner.\"\"\"\n        x, y, w, h = thumbnail_coords_to_normalized(\n            top=0,\n            right=100,\n            bottom=100,\n            left=0,\n            thumb_width=1000,\n            thumb_height=1000,\n        )\n        self.assertAlmostEqual(x, 0.05)\n        self.assertAlmostEqual(y, 0.05)\n        self.assertAlmostEqual(w, 0.1)\n        self.assertAlmostEqual(h, 0.1)\n\n\nclass TestReverseOrientationTransform(TestCase):\n    def test_identity_for_normal_orientation(self):\n        \"\"\"Normal orientation should be a no-op.\"\"\"\n        x, y, w, h = reverse_orientation_transform(\n            0.5, 0.3, 0.2, 0.1, \"Horizontal (normal)\"\n        )\n        self.assertAlmostEqual(x, 0.5)\n        self.assertAlmostEqual(y, 0.3)\n        self.assertAlmostEqual(w, 0.2)\n        self.assertAlmostEqual(h, 0.1)\n\n    def test_identity_for_none_orientation(self):\n        \"\"\"None orientation should be a no-op.\"\"\"\n        x, y, w, h = reverse_orientation_transform(0.5, 0.3, 0.2, 0.1, None)\n        self.assertAlmostEqual(x, 0.5)\n        self.assertAlmostEqual(y, 0.3)\n        self.assertAlmostEqual(w, 0.2)\n        self.assertAlmostEqual(h, 0.1)\n\n    def test_round_trip_rotate_90_cw(self):\n        \"\"\"Forward then reverse for Rotate 90 CW should return original coords.\"\"\"\n        self._test_round_trip(\"Rotate 90 CW\")\n\n    def test_round_trip_mirror_horizontal(self):\n        self._test_round_trip(\"Mirror horizontal\")\n\n    def test_round_trip_rotate_180(self):\n        self._test_round_trip(\"Rotate 180\")\n\n    def test_round_trip_mirror_vertical(self):\n        self._test_round_trip(\"Mirror vertical\")\n\n    def test_round_trip_rotate_270_cw(self):\n        self._test_round_trip(\"Rotate 270 CW\")\n\n    def test_round_trip_mirror_horizontal_rotate_90_cw(self):\n        self._test_round_trip(\"Mirror horizontal and rotate 90 CW\")\n\n    def _test_round_trip(self, orientation):\n        \"\"\"Apply forward transform (from face_extractor) then reverse, verify identity.\"\"\"\n        orig_x, orig_y, orig_w, orig_h = 0.4, 0.3, 0.2, 0.15\n\n        # Apply forward transform (same logic as face_extractor.py lines 54-80)\n        correct_x, correct_y = orig_x, orig_y\n        correct_w, correct_h = orig_w, orig_h\n        if orientation == \"Rotate 90 CW\":\n            temp_x = correct_x\n            correct_x = 1 - correct_y\n            correct_y = temp_x\n            correct_w, correct_h = correct_h, correct_w\n        elif orientation == \"Mirror horizontal\":\n            correct_x = 1 - correct_x\n        elif orientation == \"Rotate 180\":\n            correct_x = 1 - correct_x\n            correct_y = 1 - correct_y\n        elif orientation == \"Mirror vertical\":\n            correct_y = 1 - correct_y\n        elif orientation == \"Mirror horizontal and rotate 270 CW\":\n            temp_x = correct_x\n            correct_x = 1 - correct_y\n            correct_y = temp_x\n            correct_w, correct_h = correct_h, correct_w\n        elif orientation == \"Mirror horizontal and rotate 90 CW\":\n            temp_x = correct_x\n            correct_x = correct_y\n            correct_y = 1 - temp_x\n            correct_w, correct_h = correct_h, correct_w\n        elif orientation == \"Rotate 270 CW\":\n            temp_x = correct_x\n            correct_x = correct_y\n            correct_y = 1 - temp_x\n            correct_w, correct_h = correct_h, correct_w\n\n        # Now reverse\n        rx, ry, rw, rh = reverse_orientation_transform(\n            correct_x, correct_y, correct_w, correct_h, orientation\n        )\n        self.assertAlmostEqual(\n            rx, orig_x, places=10, msg=f\"x mismatch for {orientation}\"\n        )\n        self.assertAlmostEqual(\n            ry, orig_y, places=10, msg=f\"y mismatch for {orientation}\"\n        )\n        self.assertAlmostEqual(\n            rw, orig_w, places=10, msg=f\"w mismatch for {orientation}\"\n        )\n        self.assertAlmostEqual(\n            rh, orig_h, places=10, msg=f\"h mismatch for {orientation}\"\n        )\n\n\nclass TestBuildFaceRegionExiftoolArgs(TestCase):\n    def test_single_face(self):\n        \"\"\"Single face region should produce correct structured tag.\"\"\"\n        regions = [{\"name\": \"Alice\", \"x\": 0.5, \"y\": 0.3, \"w\": 0.2, \"h\": 0.15}]\n        result = build_face_region_exiftool_args(regions)\n        self.assertIn(\"XMP-mwg-rs:RegionInfo\", result)\n        value = result[\"XMP-mwg-rs:RegionInfo\"]\n        self.assertIn(\"Alice\", value)\n        self.assertIn(\"Type=Face\", value)\n        self.assertIn(\"Unit=normalized\", value)\n        self.assertIn(\"RegionList=\", value)\n\n    def test_multiple_faces(self):\n        \"\"\"Multiple face regions should all appear in RegionList.\"\"\"\n        regions = [\n            {\"name\": \"Alice\", \"x\": 0.3, \"y\": 0.3, \"w\": 0.1, \"h\": 0.1},\n            {\"name\": \"Bob\", \"x\": 0.7, \"y\": 0.5, \"w\": 0.15, \"h\": 0.2},\n            {\"name\": \"Charlie\", \"x\": 0.5, \"y\": 0.8, \"w\": 0.12, \"h\": 0.1},\n        ]\n        result = build_face_region_exiftool_args(regions)\n        value = result[\"XMP-mwg-rs:RegionInfo\"]\n        self.assertIn(\"Alice\", value)\n        self.assertIn(\"Bob\", value)\n        self.assertIn(\"Charlie\", value)\n\n    def test_special_characters_in_name(self):\n        \"\"\"Person names with commas, braces, equals should be escaped.\"\"\"\n        regions = [{\"name\": \"O'Brien, Jr.\", \"x\": 0.5, \"y\": 0.5, \"w\": 0.1, \"h\": 0.1}]\n        result = build_face_region_exiftool_args(regions)\n        value = result[\"XMP-mwg-rs:RegionInfo\"]\n        # Comma should be escaped\n        self.assertIn(\"O'Brien\\\\, Jr.\", value)\n\n    def test_escape_braces_and_equals(self):\n        \"\"\"Braces and equals in names should be escaped.\"\"\"\n        regions = [{\"name\": \"Test{=}\", \"x\": 0.5, \"y\": 0.5, \"w\": 0.1, \"h\": 0.1}]\n        result = build_face_region_exiftool_args(regions)\n        value = result[\"XMP-mwg-rs:RegionInfo\"]\n        self.assertIn(\"Test\\\\{\\\\=\\\\}\", value)\n\n    def test_applied_to_dimensions(self):\n        \"\"\"AppliedToDimensions should be included when image dimensions are provided.\"\"\"\n        regions = [{\"name\": \"Alice\", \"x\": 0.5, \"y\": 0.3, \"w\": 0.2, \"h\": 0.15}]\n        result = build_face_region_exiftool_args(regions, image_width=4000, image_height=3000)\n        value = result[\"XMP-mwg-rs:RegionInfo\"]\n        self.assertIn(\"AppliedToDimensions={W=4000,H=3000,Unit=pixel}\", value)\n\n    def test_no_applied_to_dimensions_when_missing(self):\n        \"\"\"AppliedToDimensions should be omitted when image dimensions are not available.\"\"\"\n        regions = [{\"name\": \"Alice\", \"x\": 0.5, \"y\": 0.3, \"w\": 0.2, \"h\": 0.15}]\n        result = build_face_region_exiftool_args(regions)\n        value = result[\"XMP-mwg-rs:RegionInfo\"]\n        self.assertNotIn(\"AppliedToDimensions\", value)\n\n    def test_subject_keywords_for_named_faces(self):\n        \"\"\"Named faces should be added as XMP:Subject keywords.\"\"\"\n        regions = [\n            {\"name\": \"Alice\", \"x\": 0.3, \"y\": 0.3, \"w\": 0.1, \"h\": 0.1},\n            {\"name\": \"Bob\", \"x\": 0.7, \"y\": 0.5, \"w\": 0.15, \"h\": 0.2},\n            {\"name\": \"\", \"x\": 0.5, \"y\": 0.8, \"w\": 0.12, \"h\": 0.1},\n        ]\n        result = build_face_region_exiftool_args(regions)\n        self.assertIn(\"XMP:Subject\", result)\n        self.assertEqual(result[\"XMP:Subject\"], [\"Alice\", \"Bob\"])\n\n    def test_no_subject_keywords_when_all_unnamed(self):\n        \"\"\"No XMP:Subject should be set when all faces are unnamed.\"\"\"\n        regions = [\n            {\"name\": \"\", \"x\": 0.3, \"y\": 0.3, \"w\": 0.1, \"h\": 0.1},\n        ]\n        result = build_face_region_exiftool_args(regions)\n        self.assertNotIn(\"XMP:Subject\", result)\n\n\nclass TestRoundTripCoordinates(TestCase):\n    def test_round_trip_no_orientation(self):\n        \"\"\"Pixel coords -> normalize -> (simulate XMP read-back) -> verify ~= original.\"\"\"\n        thumb_width = 1000\n        thumb_height = 800\n        orig_top, orig_right, orig_bottom, orig_left = 200, 600, 400, 400\n\n        # Step 1: Convert pixel -> normalized (writeback path)\n        x, y, w, h = thumbnail_coords_to_normalized(\n            orig_top,\n            orig_right,\n            orig_bottom,\n            orig_left,\n            thumb_width,\n            thumb_height,\n        )\n\n        # Step 2: Simulate read-back (face_extractor.py lines 82-90)\n        half_width = (w * thumb_width) / 2\n        half_height = (h * thumb_height) / 2\n        read_top = int((y * thumb_height) - half_height)\n        read_right = int((x * thumb_width) + half_width)\n        read_bottom = int((y * thumb_height) + half_height)\n        read_left = int((x * thumb_width) - half_width)\n\n        # Verify within 1px tolerance (int rounding)\n        self.assertAlmostEqual(read_top, orig_top, delta=1)\n        self.assertAlmostEqual(read_right, orig_right, delta=1)\n        self.assertAlmostEqual(read_bottom, orig_bottom, delta=1)\n        self.assertAlmostEqual(read_left, orig_left, delta=1)\n\n\nclass TestGetFaceRegionTags(TestCase):\n    def setUp(self):\n        self.user = create_test_user()\n\n    @patch(\"api.metadata.face_regions.get_metadata\")\n    @patch(\"api.metadata.face_regions.PIL.Image.open\")\n    def test_returns_tags_for_labeled_faces(self, mock_pil_open, mock_get_metadata):\n        \"\"\"get_face_region_tags should return a dict with RegionInfo for labeled faces.\"\"\"\n        photo = create_test_photo(\n            owner=self.user, thumbnail_big=\"thumbnails_big/test.jpg\"\n        )\n        person = create_test_person(\n            name=\"Alice\", kind=Person.KIND_USER, cluster_owner=self.user\n        )\n        create_test_face(\n            photo=photo,\n            person=person,\n            location_top=100,\n            location_right=300,\n            location_bottom=300,\n            location_left=100,\n        )\n\n        mock_img = MagicMock()\n        mock_img.size = (1000, 800)\n        mock_pil_open.return_value = mock_img\n        mock_get_metadata.return_value = (None, 4000, 3000)\n\n        tags = get_face_region_tags(photo)\n\n        self.assertIn(\"XMP-mwg-rs:RegionInfo\", tags)\n        self.assertIn(\"Alice\", tags[\"XMP-mwg-rs:RegionInfo\"])\n        # AppliedToDimensions should be present\n        self.assertIn(\n            \"AppliedToDimensions={W=4000,H=3000,Unit=pixel}\",\n            tags[\"XMP-mwg-rs:RegionInfo\"],\n        )\n        # XMP:Subject should contain the person name\n        self.assertIn(\"XMP:Subject\", tags)\n        self.assertEqual(tags[\"XMP:Subject\"], [\"Alice\"])\n\n    @patch(\"api.metadata.face_regions.get_metadata\")\n    @patch(\"api.metadata.face_regions.PIL.Image.open\")\n    def test_returns_all_faces(self, mock_pil_open, mock_get_metadata):\n        \"\"\"Photo with 3 labeled faces should have all 3 in the returned tags.\"\"\"\n        photo = create_test_photo(\n            owner=self.user, thumbnail_big=\"thumbnails_big/test.jpg\"\n        )\n        for name in [\"Alice\", \"Bob\", \"Charlie\"]:\n            person = create_test_person(\n                name=name, kind=Person.KIND_USER, cluster_owner=self.user\n            )\n            create_test_face(\n                photo=photo,\n                person=person,\n                location_top=100,\n                location_right=300,\n                location_bottom=300,\n                location_left=100,\n            )\n\n        mock_img = MagicMock()\n        mock_img.size = (1000, 800)\n        mock_pil_open.return_value = mock_img\n        mock_get_metadata.return_value = (None, 4000, 3000)\n\n        tags = get_face_region_tags(photo)\n\n        value = tags[\"XMP-mwg-rs:RegionInfo\"]\n        self.assertIn(\"Alice\", value)\n        self.assertIn(\"Bob\", value)\n        self.assertIn(\"Charlie\", value)\n\n    @patch(\"api.metadata.face_regions.get_metadata\")\n    @patch(\"api.metadata.face_regions.PIL.Image.open\")\n    def test_unlabeled_faces_written_with_empty_name(\n        self, mock_pil_open, mock_get_metadata\n    ):\n        \"\"\"Faces without a KIND_USER person should be written with an empty name.\"\"\"\n        photo = create_test_photo(\n            owner=self.user, thumbnail_big=\"thumbnails_big/test.jpg\"\n        )\n        cluster_person = create_test_person(\n            name=\"cluster_0001\", kind=Person.KIND_CLUSTER, cluster_owner=self.user\n        )\n        create_test_face(\n            photo=photo,\n            person=cluster_person,\n            location_top=100,\n            location_right=300,\n            location_bottom=300,\n            location_left=100,\n        )\n\n        mock_img = MagicMock()\n        mock_img.size = (1000, 800)\n        mock_pil_open.return_value = mock_img\n        mock_get_metadata.return_value = (None, 4000, 3000)\n\n        tags = get_face_region_tags(photo)\n\n        self.assertIn(\"XMP-mwg-rs:RegionInfo\", tags)\n        value = tags[\"XMP-mwg-rs:RegionInfo\"]\n        # Should have the face region but with empty name\n        self.assertIn(\"Name=,Type=Face\", value)\n        self.assertNotIn(\"cluster_0001\", value)\n\n    @patch(\"api.metadata.face_regions.get_metadata\")\n    @patch(\"api.metadata.face_regions.PIL.Image.open\")\n    def test_faces_with_no_person_written_with_empty_name(\n        self, mock_pil_open, mock_get_metadata\n    ):\n        \"\"\"Faces with person=None should be written with an empty name.\"\"\"\n        photo = create_test_photo(\n            owner=self.user, thumbnail_big=\"thumbnails_big/test.jpg\"\n        )\n        create_test_face(\n            photo=photo,\n            person=None,\n            location_top=100,\n            location_right=300,\n            location_bottom=300,\n            location_left=100,\n        )\n\n        mock_img = MagicMock()\n        mock_img.size = (1000, 800)\n        mock_pil_open.return_value = mock_img\n        mock_get_metadata.return_value = (None, 4000, 3000)\n\n        tags = get_face_region_tags(photo)\n\n        self.assertIn(\"XMP-mwg-rs:RegionInfo\", tags)\n        value = tags[\"XMP-mwg-rs:RegionInfo\"]\n        self.assertIn(\"Name=,Type=Face\", value)\n\n    @patch(\"api.metadata.face_regions.get_metadata\")\n    @patch(\"api.metadata.face_regions.PIL.Image.open\")\n    def test_mixed_labeled_and_unlabeled_faces(\n        self, mock_pil_open, mock_get_metadata\n    ):\n        \"\"\"Photo with both labeled and unlabeled faces should include all.\"\"\"\n        photo = create_test_photo(\n            owner=self.user, thumbnail_big=\"thumbnails_big/test.jpg\"\n        )\n        labeled_person = create_test_person(\n            name=\"Alice\", kind=Person.KIND_USER, cluster_owner=self.user\n        )\n        create_test_face(\n            photo=photo,\n            person=labeled_person,\n            location_top=100,\n            location_right=300,\n            location_bottom=300,\n            location_left=100,\n        )\n        cluster_person = create_test_person(\n            name=\"cluster_0001\", kind=Person.KIND_CLUSTER, cluster_owner=self.user\n        )\n        create_test_face(\n            photo=photo,\n            person=cluster_person,\n            location_top=400,\n            location_right=600,\n            location_bottom=600,\n            location_left=400,\n        )\n\n        mock_img = MagicMock()\n        mock_img.size = (1000, 800)\n        mock_pil_open.return_value = mock_img\n        mock_get_metadata.return_value = (None, 4000, 3000)\n\n        tags = get_face_region_tags(photo)\n\n        value = tags[\"XMP-mwg-rs:RegionInfo\"]\n        self.assertIn(\"Alice\", value)\n        self.assertNotIn(\"cluster_0001\", value)\n        # Should have 2 face regions\n        self.assertEqual(value.count(\"Type=Face\"), 2)\n\n    @patch(\"api.metadata.face_regions.get_metadata\")\n    @patch(\"api.metadata.face_regions.PIL.Image.open\")\n    def test_skips_deleted_faces(self, mock_pil_open, mock_get_metadata):\n        \"\"\"Deleted faces should not be included in the tags.\"\"\"\n        photo = create_test_photo(\n            owner=self.user, thumbnail_big=\"thumbnails_big/test.jpg\"\n        )\n        person = create_test_person(\n            name=\"Active\", kind=Person.KIND_USER, cluster_owner=self.user\n        )\n        create_test_face(\n            photo=photo,\n            person=person,\n            location_top=100,\n            location_right=300,\n            location_bottom=300,\n            location_left=100,\n        )\n        deleted_person = create_test_person(\n            name=\"Deleted\", kind=Person.KIND_USER, cluster_owner=self.user\n        )\n        create_test_face(\n            photo=photo,\n            person=deleted_person,\n            deleted=True,\n            location_top=400,\n            location_right=600,\n            location_bottom=600,\n            location_left=400,\n        )\n\n        mock_img = MagicMock()\n        mock_img.size = (1000, 800)\n        mock_pil_open.return_value = mock_img\n        mock_get_metadata.return_value = (None, 4000, 3000)\n\n        tags = get_face_region_tags(photo)\n\n        value = tags[\"XMP-mwg-rs:RegionInfo\"]\n        self.assertIn(\"Active\", value)\n        self.assertNotIn(\"Deleted\", value)\n\n\nclass TestSaveMetadataIntegration(TestCase):\n    def setUp(self):\n        self.user = create_test_user()\n\n    @patch(\"api.models.photo.write_metadata\")\n    @patch(\"api.metadata.face_regions.get_metadata\")\n    @patch(\"api.metadata.face_regions.PIL.Image.open\")\n    def test_save_metadata_with_face_tags(\n        self, mock_pil_open, mock_get_metadata, mock_write_metadata\n    ):\n        \"\"\"_save_metadata(metadata_types=[\"face_tags\"]) should write face regions.\"\"\"\n        photo = create_test_photo(\n            owner=self.user, thumbnail_big=\"thumbnails_big/test.jpg\"\n        )\n        person = create_test_person(\n            name=\"Test Person\", kind=Person.KIND_USER, cluster_owner=self.user\n        )\n        create_test_face(\n            photo=photo,\n            person=person,\n            location_top=100,\n            location_right=300,\n            location_bottom=300,\n            location_left=100,\n        )\n\n        mock_img = MagicMock()\n        mock_img.size = (1000, 800)\n        mock_pil_open.return_value = mock_img\n        mock_get_metadata.return_value = (None, 4000, 3000)\n\n        photo._save_metadata(use_sidecar=True, metadata_types=[\"face_tags\"])\n\n        mock_write_metadata.assert_called_once()\n        tags = mock_write_metadata.call_args[0][1]\n        self.assertIn(\"XMP-mwg-rs:RegionInfo\", tags)\n        self.assertIn(\"Test Person\", tags[\"XMP-mwg-rs:RegionInfo\"])\n\n    @patch(\"api.models.photo.write_metadata\")\n    def test_save_metadata_default_does_not_write_face_tags(self, mock_write_metadata):\n        \"\"\"_save_metadata() with default args should NOT write face tags.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        person = create_test_person(\n            name=\"Test Person\", kind=Person.KIND_USER, cluster_owner=self.user\n        )\n        create_test_face(\n            photo=photo,\n            person=person,\n            location_top=100,\n            location_right=300,\n            location_bottom=300,\n            location_left=100,\n        )\n\n        # Default call (no metadata_types) — should only consider ratings\n        photo._save_metadata()\n\n        # Rating is 0 by default and there are no modified_fields=None,\n        # so it will write the rating tag\n        if mock_write_metadata.called:\n            tags = mock_write_metadata.call_args[0][1]\n            self.assertNotIn(\"XMP-mwg-rs:RegionInfo\", tags)\n\n    @patch(\"api.models.photo.write_metadata\")\n    @patch(\"api.metadata.face_regions.get_metadata\")\n    @patch(\"api.metadata.face_regions.PIL.Image.open\")\n    def test_save_metadata_combined_types(\n        self, mock_pil_open, mock_get_metadata, mock_write_metadata\n    ):\n        \"\"\"_save_metadata with both types should write ratings AND face tags together.\"\"\"\n        photo = create_test_photo(\n            owner=self.user, thumbnail_big=\"thumbnails_big/test.jpg\"\n        )\n        photo.rating = 5\n        person = create_test_person(\n            name=\"Alice\", kind=Person.KIND_USER, cluster_owner=self.user\n        )\n        create_test_face(\n            photo=photo,\n            person=person,\n            location_top=100,\n            location_right=300,\n            location_bottom=300,\n            location_left=100,\n        )\n\n        mock_img = MagicMock()\n        mock_img.size = (1000, 800)\n        mock_pil_open.return_value = mock_img\n        mock_get_metadata.return_value = (None, 4000, 3000)\n\n        photo._save_metadata(use_sidecar=True, metadata_types=[\"ratings\", \"face_tags\"])\n\n        mock_write_metadata.assert_called_once()\n        tags = mock_write_metadata.call_args[0][1]\n        self.assertIn(\"Rating\", tags)\n        self.assertEqual(tags[\"Rating\"], 5)\n        self.assertIn(\"XMP-mwg-rs:RegionInfo\", tags)\n        self.assertIn(\"Alice\", tags[\"XMP-mwg-rs:RegionInfo\"])\n"
  },
  {
    "path": "api/tests/test_favorite_photos.py",
    "content": "from unittest.mock import patch\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_photos, create_test_user\n\n\nclass FavoritePhotosTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user(favorite_min_rating=1)\n        self.user2 = create_test_user(favorite_min_rating=1)\n        self.client.force_authenticate(user=self.user1)\n\n    def test_tag_my_photos_as_favorite(self):\n        photos = create_test_photos(number_of_photos=3, owner=self.user1)\n        image_hashes = [p.image_hash for p in photos]\n\n        payload = {\"image_hashes\": image_hashes, \"favorite\": True}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/favorite/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(3, len(data[\"results\"]))\n        self.assertEqual(3, len(data[\"updated\"]))\n        self.assertEqual(0, len(data[\"not_updated\"]))\n\n    def test_untag_my_photos_as_favorite(self):\n        photos1 = create_test_photos(\n            number_of_photos=1, owner=self.user1, rating=self.user1.favorite_min_rating\n        )\n        photos2 = create_test_photos(number_of_photos=2, owner=self.user1)\n        image_hashes = [p.image_hash for p in photos1 + photos2]\n\n        payload = {\"image_hashes\": image_hashes, \"favorite\": False}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/favorite/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(1, len(data[\"results\"]))\n        self.assertEqual(1, len(data[\"updated\"]))\n        self.assertEqual(2, len(data[\"not_updated\"]))\n\n    def test_tag_photos_of_other_user_as_favorite(self):\n        photos = create_test_photos(number_of_photos=2, owner=self.user2)\n        image_hashes = [p.image_hash for p in photos]\n\n        payload = {\"image_hashes\": image_hashes, \"favorite\": True}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/favorite/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(0, len(data[\"results\"]))\n        self.assertEqual(0, len(data[\"updated\"]))\n        # Photos not owned by user are treated as \"missing\" for security (no info leak)\n        self.assertEqual(0, len(data[\"not_updated\"]))\n\n    @patch(\"api.views.photos.logger.warning\", autospec=True)\n    def test_tag_nonexistent_photo_as_favorite(self, logger):\n        payload = {\"image_hashes\": [\"nonexistent_photo\"], \"favorite\": True}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/favorite/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(0, len(data[\"results\"]))\n        self.assertEqual(0, len(data[\"updated\"]))\n        self.assertEqual(0, len(data[\"not_updated\"]))\n        logger.assert_called_with(\n            \"Could not set photo nonexistent_photo to favorite. It does not exist or is not owned by user.\"\n        )\n"
  },
  {
    "path": "api/tests/test_file_model.py",
    "content": "import importlib.machinery\nimport importlib.util\nimport sys\nimport types\nimport unittest\nfrom unittest.mock import patch\n\n\ndef _ensure_stub_modules():\n    if \"django\" not in sys.modules:\n        django_module = types.ModuleType(\"django\")\n        django_db_module = types.ModuleType(\"django.db\")\n        django_db_models_module = types.ModuleType(\"django.db.models\")\n\n        class DummyModel:\n            pass\n\n        def dummy_field(*args, **kwargs):\n            return None\n\n        django_db_models_module.Model = DummyModel\n        django_db_models_module.CharField = dummy_field\n        django_db_models_module.TextField = dummy_field\n        django_db_models_module.PositiveIntegerField = dummy_field\n        django_db_models_module.BooleanField = dummy_field\n        django_db_models_module.ManyToManyField = dummy_field\n\n        django_module.db = django_db_module\n        django_db_module.models = django_db_models_module\n        django_module.__path__ = []\n        django_db_module.__path__ = []\n        django_module.__spec__ = importlib.machinery.ModuleSpec(\n            \"django\", loader=None, is_package=True\n        )\n        django_db_module.__spec__ = importlib.machinery.ModuleSpec(\n            \"django.db\", loader=None, is_package=True\n        )\n        django_db_models_module.__spec__ = importlib.machinery.ModuleSpec(\n            \"django.db.models\", loader=None\n        )\n\n        sys.modules[\"django\"] = django_module\n        sys.modules[\"django.db\"] = django_db_module\n        sys.modules[\"django.db.models\"] = django_db_models_module\n\n    if \"magic\" not in sys.modules:\n        magic_module = types.ModuleType(\"magic\")\n\n        class Magic:\n            def __init__(self, *args, **kwargs):\n                pass\n\n            def from_file(self, path):\n                return \"application/octet-stream\"\n\n        magic_module.Magic = Magic\n        magic_module.__spec__ = importlib.machinery.ModuleSpec(\"magic\", loader=None)\n        sys.modules[\"magic\"] = magic_module\n\n    if \"pyvips\" not in sys.modules:\n        pyvips_module = types.ModuleType(\"pyvips\")\n\n        class Image:\n            @staticmethod\n            def thumbnail(*args, **kwargs):\n                raise NotImplementedError\n\n        class Enums:\n            class Size:\n                DOWN = \"down\"\n\n        pyvips_module.Image = Image\n        pyvips_module.enums = Enums\n        pyvips_module.__spec__ = importlib.machinery.ModuleSpec(\n            \"pyvips\", loader=None\n        )\n        sys.modules[\"pyvips\"] = pyvips_module\n\n    if \"api\" not in sys.modules:\n        api_module = types.ModuleType(\"api\")\n        api_module.__path__ = []\n        api_module.__spec__ = importlib.machinery.ModuleSpec(\n            \"api\", loader=None, is_package=True\n        )\n\n        util_module = types.ModuleType(\"api.util\")\n\n        class Logger:\n            def error(self, *args, **kwargs):\n                pass\n\n        util_module.logger = Logger()\n        util_module.__spec__ = importlib.machinery.ModuleSpec(\"api.util\", loader=None)\n        util_module.__file__ = \"<stub>\"\n\n        models_module = types.ModuleType(\"api.models\")\n        models_module.__path__ = []\n        models_module.__spec__ = importlib.machinery.ModuleSpec(\n            \"api.models\", loader=None, is_package=True\n        )\n\n        api_module.util = util_module\n        api_module.models = models_module\n\n        sys.modules[\"api\"] = api_module\n        sys.modules[\"api.util\"] = util_module\n        sys.modules[\"api.models\"] = models_module\n\n    if \"exiftool\" not in sys.modules:\n        exiftool_module = types.ModuleType(\"exiftool\")\n        exiftool_module.__spec__ = importlib.machinery.ModuleSpec(\n            \"exiftool\", loader=None\n        )\n        sys.modules[\"exiftool\"] = exiftool_module\n\n    if \"requests\" not in sys.modules:\n        requests_module = types.ModuleType(\"requests\")\n        requests_module.__spec__ = importlib.machinery.ModuleSpec(\n            \"requests\", loader=None\n        )\n        sys.modules[\"requests\"] = requests_module\n\n    if \"django.conf\" not in sys.modules:\n        django_conf_module = types.ModuleType(\"django.conf\")\n        django_conf_module.settings = types.SimpleNamespace(LOGS_ROOT=\"/tmp\")\n        django_conf_module.__spec__ = importlib.machinery.ModuleSpec(\n            \"django.conf\", loader=None, is_package=True\n        )\n        sys.modules[\"django.conf\"] = django_conf_module\n\n\ndef _load_file_module():\n    _ensure_stub_modules()\n    # Use a unique module name to avoid conflicting with Django's registry\n    module_name = \"api.models.file._test_stub\"\n    if module_name in sys.modules:\n        return sys.modules[module_name]\n    spec = importlib.util.spec_from_file_location(\n        module_name, \"api/models/file.py\"\n    )\n    module = importlib.util.module_from_spec(spec)\n    sys.modules[module_name] = module\n    spec.loader.exec_module(module)\n    return module\n\n\nclass TestIsVideo(unittest.TestCase):\n    @classmethod\n    def setUpClass(cls):\n        # Only load the module when this specific test class runs\n        # Skip if Django models are already loaded to avoid double registration\n        if \"api.models.file\" in sys.modules:\n            cls._file_module = sys.modules[\"api.models.file\"]\n        else:\n            cls._file_module = _load_file_module()\n\n    def test_is_video_returns_false_when_magic_raises(self):\n        class FailingMagic:\n            def from_file(self, path):\n                raise RuntimeError(\"magic failure\")\n\n        with patch.object(\n            self._file_module.magic, \"Magic\", return_value=FailingMagic()\n        ):\n            self.assertFalse(self._file_module.is_video(\"/tmp/test.mp4\"))\n"
  },
  {
    "path": "api/tests/test_file_path_uniqueness.py",
    "content": "\"\"\"\nTests for File path uniqueness enforcement.\n\nTests verify:\n- Database unique constraint on File.path\n- File.create() get_or_create pattern\n- Migration deduplication logic\n- Concurrent scan handling\n\"\"\"\n\nimport os\nimport tempfile\nimport threading\n\nfrom django.db import IntegrityError, transaction\nfrom django.test import TestCase, TransactionTestCase\n\nfrom api.models.file import File\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass FilePathUniqueConstraintTestCase(TestCase):\n    \"\"\"Tests for the unique constraint on File.path.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_unique_constraint_prevents_duplicate_paths(self):\n        \"\"\"Test that the database prevents creating two Files with the same path.\"\"\"\n        path = \"/photos/test_image.jpg\"\n        \n        # Create first file\n        _file1 = File.objects.create(\n            hash=\"hash1\" + \"a\" * 28,\n            path=path,\n            type=File.IMAGE,\n        )\n        \n        # Attempting to create second file with same path should fail\n        with self.assertRaises(IntegrityError):\n            with transaction.atomic():\n                File.objects.create(\n                    hash=\"hash2\" + \"b\" * 28,\n                    path=path,\n                    type=File.IMAGE,\n                )\n\n    def test_unique_constraint_allows_different_paths(self):\n        \"\"\"Test that different paths are allowed.\"\"\"\n        file1 = File.objects.create(\n            hash=\"hash1\" + \"a\" * 28,\n            path=\"/photos/image1.jpg\",\n            type=File.IMAGE,\n        )\n        file2 = File.objects.create(\n            hash=\"hash2\" + \"b\" * 28,\n            path=\"/photos/image2.jpg\",\n            type=File.IMAGE,\n        )\n        \n        self.assertEqual(File.objects.count(), 2)\n        self.assertNotEqual(file1.path, file2.path)\n\n    def test_empty_paths_are_unique(self):\n        \"\"\"Test that empty paths are subject to unique constraint.\"\"\"\n        # First empty path file\n        _file1 = File.objects.create(\n            hash=\"hash1\" + \"a\" * 28,\n            path=\"\",\n            type=File.IMAGE,\n        )\n        \n        # Second empty path should fail\n        with self.assertRaises(IntegrityError):\n            with transaction.atomic():\n                File.objects.create(\n                    hash=\"hash2\" + \"b\" * 28,\n                    path=\"\",\n                    type=File.IMAGE,\n                )\n\n\nclass FileCreateMethodTestCase(TestCase):\n    \"\"\"Tests for File.create() get_or_create pattern.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        # Create a temp directory for test files\n        self.temp_dir = tempfile.mkdtemp()\n\n    def tearDown(self):\n        # Clean up temp files\n        import shutil\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    def _create_test_file(self, filename, content=b\"test content\"):\n        \"\"\"Helper to create a test file on disk.\"\"\"\n        path = os.path.join(self.temp_dir, filename)\n        with open(path, \"wb\") as f:\n            f.write(content)\n        return path\n\n    def test_create_returns_existing_file_for_same_path(self):\n        \"\"\"Test that File.create() returns existing File for same path.\"\"\"\n        path = self._create_test_file(\"test.jpg\")\n        \n        # Create first file\n        file1 = File.create(path, self.user)\n        \n        # Create second file with same path - should return existing\n        file2 = File.create(path, self.user)\n        \n        # Should be the same file\n        self.assertEqual(file1.hash, file2.hash)\n        self.assertEqual(file1.path, file2.path)\n        \n        # Should only have one File in database\n        self.assertEqual(File.objects.filter(path=path).count(), 1)\n\n    def test_create_creates_new_file_for_different_path(self):\n        \"\"\"Test that File.create() creates new File for different path.\"\"\"\n        path1 = self._create_test_file(\"test1.jpg\", b\"content1\")\n        path2 = self._create_test_file(\"test2.jpg\", b\"content2\")\n        \n        file1 = File.create(path1, self.user)\n        file2 = File.create(path2, self.user)\n        \n        self.assertNotEqual(file1.hash, file2.hash)\n        self.assertNotEqual(file1.path, file2.path)\n        self.assertEqual(File.objects.count(), 2)\n\n    def test_create_returns_existing_even_if_content_changed(self):\n        \"\"\"Test that File.create() returns existing File even if content changed.\"\"\"\n        path = self._create_test_file(\"test.jpg\", b\"original content\")\n        \n        # Create first file\n        file1 = File.create(path, self.user)\n        original_hash = file1.hash\n        \n        # Modify file content\n        with open(path, \"wb\") as f:\n            f.write(b\"modified content\")\n        \n        # Create again - should return existing File (not recalculate hash)\n        file2 = File.create(path, self.user)\n        \n        # Should return existing file (hash stays the same)\n        self.assertEqual(file1.hash, file2.hash)\n        self.assertEqual(file2.hash, original_hash)\n\n    def test_create_determines_correct_file_type(self):\n        \"\"\"Test that File.create() correctly determines file type.\"\"\"\n        # Create image file\n        img_path = self._create_test_file(\"photo.jpg\")\n        img_file = File.create(img_path, self.user)\n        self.assertEqual(img_file.type, File.IMAGE)\n        \n        # Create RAW file\n        raw_path = self._create_test_file(\"photo.CR2\")\n        raw_file = File.create(raw_path, self.user)\n        self.assertEqual(raw_file.type, File.RAW_FILE)\n        \n        # Create metadata file\n        xmp_path = self._create_test_file(\"photo.xmp\")\n        xmp_file = File.create(xmp_path, self.user)\n        self.assertEqual(xmp_file.type, File.METADATA_FILE)\n\n\nclass MigrationDeduplicationTestCase(TestCase):\n    \"\"\"Tests for the migration deduplication logic.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_deduplication_prefers_non_missing_file(self):\n        \"\"\"Test that deduplication logic prefers non-missing files.\n        \n        This tests the scoring logic that the migration uses.\n        \"\"\"\n        # Create two files to simulate pre-migration state\n        file_missing = File.objects.create(\n            hash=\"hash_missing\" + \"a\" * 21,\n            path=\"/photos/missing_file.jpg\",\n            type=File.IMAGE,\n            missing=True,\n        )\n        file_ok = File.objects.create(\n            hash=\"hash_ok\" + \"a\" * 25,\n            path=\"/photos/ok_file.jpg\",\n            type=File.IMAGE,\n            missing=False,\n        )\n        \n        # Scoring logic: non-missing files get +100\n        def score_file(f):\n            score = 0\n            if not f.missing:\n                score += 100\n            return score\n        \n        # Non-missing file should have higher score\n        self.assertGreater(score_file(file_ok), score_file(file_missing))\n\n    def test_deduplication_keeps_file_with_more_photos(self):\n        \"\"\"Test that deduplication prefers files linked to more photos.\"\"\"\n        _path = \"/photos/popular.jpg\"\n        \n        # Create two files\n        file_popular = File.objects.create(\n            hash=\"hash_popular\" + \"a\" * 20,\n            path=\"/photos/popular1.jpg\",\n            type=File.IMAGE,\n        )\n        file_lonely = File.objects.create(\n            hash=\"hash_lonely\" + \"a\" * 21,\n            path=\"/photos/lonely1.jpg\",\n            type=File.IMAGE,\n        )\n        \n        # Link popular file to multiple photos\n        for i in range(3):\n            photo = create_test_photo(owner=self.user)\n            photo.files.add(file_popular)\n            photo.save()\n        \n        # Link lonely file to one photo\n        photo = create_test_photo(owner=self.user)\n        photo.files.add(file_lonely)\n        photo.save()\n        \n        # Verify photo counts\n        self.assertEqual(file_popular.photo_set.count(), 3)\n        self.assertEqual(file_lonely.photo_set.count(), 1)\n\n\nclass ConcurrentScanTestCase(TransactionTestCase):\n    \"\"\"Tests for concurrent scan handling with unique constraint.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.temp_dir = tempfile.mkdtemp()\n\n    def tearDown(self):\n        import shutil\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    def _create_test_file(self, filename, content=b\"test content\"):\n        \"\"\"Helper to create a test file on disk.\"\"\"\n        path = os.path.join(self.temp_dir, filename)\n        with open(path, \"wb\") as f:\n            f.write(content)\n        return path\n\n    def test_concurrent_create_same_path_no_duplicates(self):\n        \"\"\"Test that concurrent File.create() calls don't create duplicates.\"\"\"\n        path = self._create_test_file(\"concurrent_test.jpg\")\n        results = []\n        errors = []\n        \n        def create_file():\n            try:\n                file = File.create(path, self.user)\n                results.append(file.hash)\n            except Exception as e:\n                errors.append(str(e))\n        \n        # Run multiple threads trying to create the same file\n        threads = []\n        for _ in range(5):\n            t = threading.Thread(target=create_file)\n            threads.append(t)\n        \n        for t in threads:\n            t.start()\n        \n        for t in threads:\n            t.join()\n        \n        # All should succeed (due to get_or_create pattern)\n        self.assertEqual(len(errors), 0, f\"Errors occurred: {errors}\")\n        \n        # All should return the same file\n        self.assertTrue(len(set(results)) <= 1, \n            f\"Expected same hash for all, got: {results}\")\n        \n        # Should only have one File in database\n        self.assertEqual(File.objects.filter(path=path).count(), 1)\n\n    def test_concurrent_create_different_paths_succeeds(self):\n        \"\"\"Test that concurrent creates of different paths all succeed.\"\"\"\n        paths = [\n            self._create_test_file(f\"concurrent_test_{i}.jpg\", f\"content{i}\".encode())\n            for i in range(5)\n        ]\n        results = []\n        \n        def create_file(path):\n            file = File.create(path, self.user)\n            results.append(file.hash)\n        \n        threads = []\n        for path in paths:\n            t = threading.Thread(target=create_file, args=(path,))\n            threads.append(t)\n        \n        for t in threads:\n            t.start()\n        \n        for t in threads:\n            t.join()\n        \n        # All 5 files should be created\n        self.assertEqual(len(results), 5)\n        self.assertEqual(File.objects.count(), 5)\n\n\nclass FilePathLookupTestCase(TestCase):\n    \"\"\"Tests for path-based lookups.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_filter_by_path_is_exact(self):\n        \"\"\"Test that filtering by path is exact match.\"\"\"\n        file1 = File.objects.create(\n            hash=\"hash1\" + \"a\" * 28,\n            path=\"/photos/image.jpg\",\n            type=File.IMAGE,\n        )\n        _file2 = File.objects.create(\n            hash=\"hash2\" + \"b\" * 28,\n            path=\"/photos/image2.jpg\",\n            type=File.IMAGE,\n        )\n        \n        # Exact match should find only one\n        result = File.objects.filter(path=\"/photos/image.jpg\")\n        self.assertEqual(result.count(), 1)\n        self.assertEqual(result.first().hash, file1.hash)\n\n    def test_photo_files_path_lookup(self):\n        \"\"\"Test that Photo.files.filter(path=...) works correctly.\"\"\"\n        file1 = File.objects.create(\n            hash=\"hash1\" + \"a\" * 28,\n            path=\"/photos/image1.jpg\",\n            type=File.IMAGE,\n        )\n        file2 = File.objects.create(\n            hash=\"hash2\" + \"b\" * 28,\n            path=\"/photos/image2.jpg\",\n            type=File.IMAGE,\n        )\n        \n        photo = create_test_photo(owner=self.user)\n        photo.files.add(file1, file2)\n        \n        # Should find exact path\n        self.assertTrue(photo.files.filter(path=\"/photos/image1.jpg\").exists())\n        self.assertFalse(photo.files.filter(path=\"/photos/image3.jpg\").exists())\n\n\nclass PhotoFileAssociationTestCase(TestCase):\n    \"\"\"Tests for Photo-File associations with unique path constraint.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_multiple_photos_can_share_same_file(self):\n        \"\"\"Test that multiple Photos can reference the same File.\"\"\"\n        file = File.objects.create(\n            hash=\"shared_hash\" + \"a\" * 23,\n            path=\"/photos/shared_image.jpg\",\n            type=File.IMAGE,\n        )\n        \n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        photo1.files.add(file)\n        photo1.main_file = file\n        photo1.save()\n        \n        photo2.files.add(file)\n        photo2.main_file = file\n        photo2.save()\n        \n        # Both photos should reference the same file\n        self.assertEqual(photo1.main_file.hash, photo2.main_file.hash)\n        self.assertEqual(file.photo_set.count(), 2)\n\n    def test_photo_with_multiple_file_variants(self):\n        \"\"\"Test Photo with multiple file variants (JPEG + RAW).\"\"\"\n        jpeg_file = File.objects.create(\n            hash=\"jpeg_hash\" + \"a\" * 24,\n            path=\"/photos/image.jpg\",\n            type=File.IMAGE,\n        )\n        raw_file = File.objects.create(\n            hash=\"raw_hash\" + \"a\" * 25,\n            path=\"/photos/image.CR2\",\n            type=File.RAW_FILE,\n        )\n\n        photo = create_test_photo(owner=self.user)\n        photo.files.add(jpeg_file, raw_file)\n        photo.main_file = jpeg_file\n        photo.save()\n\n        # Photo should have both explicitly added files\n        # Note: create_test_photo sets main_file but doesn't add it to photo.files\n        self.assertEqual(photo.files.count(), 2)\n        self.assertTrue(photo.files.filter(path=\"/photos/image.jpg\").exists())\n        self.assertTrue(photo.files.filter(path=\"/photos/image.CR2\").exists())\n"
  },
  {
    "path": "api/tests/test_geocode.py",
    "content": "from unittest.mock import patch\n\nfrom constance.test import override_config\nfrom django.test import TestCase\n\nfrom api.geocode.geocode import reverse_geocode\nfrom api.geocode.parsers.mapbox import parse as parse_mapbox\nfrom api.geocode.parsers.nominatim import parse as parse_nominatim\nfrom api.geocode.parsers.opencage import parse as parse_opencage\nfrom api.geocode.parsers.tomtom import parse as parse_tomtom\nfrom api.tests.fixtures.geocode.expectations.mapbox import (\n    expectations as mapbox_expectations,\n)\nfrom api.tests.fixtures.geocode.expectations.nominatim import (\n    expectations as nominatim_expectations,\n)\nfrom api.tests.fixtures.geocode.expectations.opencage import (\n    expectations as opencage_expectations,\n)\nfrom api.tests.fixtures.geocode.expectations.tomtom import (\n    expectations as tomtom_expectations,\n)\nfrom api.tests.fixtures.geocode.responses.mapbox import responses as mapbox_responses\nfrom api.tests.fixtures.geocode.responses.nominatim import (\n    responses as nominatim_responses,\n)\nfrom api.tests.fixtures.geocode.responses.opencage import (\n    responses as opencage_responses,\n)\nfrom api.tests.fixtures.geocode.responses.tomtom import responses as tomtom_responses\n\n\nclass MapboxLocation:\n    def __init__(self, raw):\n        self.raw = raw\n        self.address = raw[\"place_name\"]\n\n\nclass TomTomLocation:\n    def __init__(self, raw):\n        self.raw = raw\n        self.address = raw[\"address\"][\"freeformAddress\"]\n\n\nclass NominatimLocation:\n    def __init__(self, raw):\n        self.raw = raw\n        self.address = raw[\"display_name\"]\n\n\nclass OpenCageLocation:\n    def __init__(self, raw):\n        self.raw = raw\n        self.address = raw[\"formatted\"]\n\n\nclass TestGeocodeParsers(TestCase):\n    def test_mapbox_parser(self):\n        for index, raw in enumerate(mapbox_responses):\n            self.assertEqual(\n                parse_mapbox(MapboxLocation(raw)), mapbox_expectations[index]\n            )\n\n    def test_tomtom_parser(self):\n        for index, raw in enumerate(tomtom_responses):\n            self.assertEqual(\n                parse_tomtom(TomTomLocation(raw)), tomtom_expectations[index]\n            )\n\n    def test_nominatim_parser(self):\n        for index, raw in enumerate(nominatim_responses):\n            self.assertEqual(\n                parse_nominatim(NominatimLocation(raw)), nominatim_expectations[index]\n            )\n\n    def test_opencage_parser(self):\n        for index, raw in enumerate(opencage_responses):\n            self.assertEqual(\n                parse_opencage(OpenCageLocation(raw)), opencage_expectations[index]\n            )\n\n\nclass FakeLocation:\n    raw = None\n    address = None\n\n    def __init__(self, location):\n        self.raw = location\n        self.address = location[\"place_name\"]\n\n\nclass FakeProvider:\n    def __init__(self, response):\n        self.response = response\n\n    def reverse(self, _):\n        return FakeLocation(self.response)\n\n\ndef fake_geocoder(response):\n    return lambda **_: FakeProvider(response)\n\n\nclass TestGeocoder(TestCase):\n    @override_config(MAP_API_PROVIDER=\"mapbox\")\n    @patch(\"geopy.get_geocoder_for_service\", autospec=True)\n    def test_reverse_geocode(self, get_geocoder_for_service_mock):\n        get_geocoder_for_service_mock.return_value = fake_geocoder(mapbox_responses[1])\n        result = reverse_geocode(0, 0)\n        self.assertEqual(result, mapbox_expectations[1])\n\n    @override_config(MAP_API_PROVIDER=\"mapbox\")\n    @override_config(MAP_API_KEY=\"\")\n    def test_reverse_geocode_no_api_key(self):\n        result = reverse_geocode(0, 0)\n        self.assertEqual(result, {})\n"
  },
  {
    "path": "api/tests/test_get_faces.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import (\n    create_test_face,\n    create_test_person,\n    create_test_photo,\n    create_test_user,\n)\n\n\nclass IncompleteFacesTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_if_classification_person_is_ignored_if_below_threshold(self):\n        \"\"\"Test if unknown faces with classification are returned. Only classification_probability and min_confidence should be looked into.\"\"\"\n        person = create_test_person(cluster_owner=self.user)\n        create_test_face(\n            photo=self.photo,\n            classification_person=person,\n            classification_probability=0.4,\n        )\n\n        response = self.client.get(\n            reverse(\"incomplete_faces-list\"),\n            {\n                \"inferred\": \"true\",\n                \"analysis_method\": \"classification\",\n                \"min_confidence\": \"0.5\",\n            },\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(len(response.data), 1)\n        self.assertIn(\n            \"Unknown - Other\", response.data[0][\"name\"]\n        )  # Ensure unknown face is returned\n\n    def test_if_min_confidence_and_prob_are_compared_correctly(\n        self,\n    ):\n        \"\"\"Test that incomplete faces with classification analysis method are returned properly.\"\"\"\n        create_test_face(\n            photo=self.photo,\n            classification_probability=0.3,\n        )\n        create_test_face(\n            photo=self.photo,\n            classification_probability=0.4,\n        )\n        create_test_face(\n            photo=self.photo,\n            classification_probability=0.6,\n        )\n\n        response = self.client.get(\n            reverse(\"incomplete_faces-list\"),\n            {\n                \"inferred\": \"true\",\n                \"analysis_method\": \"classification\",\n                \"min_confidence\": \"0.5\",\n            },\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\n            \"Unknown - Other\", response.data[0][\"name\"]\n        )  # Ensure unknown face is returned\n        # face count should be number of faces with classification probability less than 0.5\n        self.assertEqual(response.data[0][\"face_count\"], 2)\n\n    def test_incomplete_faces_with_clustering(self):\n        \"\"\"Test that incomplete faces with clustering analysis method are returned properly.\"\"\"\n        create_test_face(\n            photo=self.photo,\n            classification_person=None,\n            classification_probability=0.5,\n            cluster_person=None,\n            cluster_probability=0.8,\n        )\n        create_test_face(\n            photo=self.photo,\n            classification_person=None,\n            classification_probability=0.5,\n            cluster_person=None,\n            cluster_probability=0.4,\n        )\n\n        response = self.client.get(\n            reverse(\"incomplete_faces-list\"),\n            {\n                \"inferred\": \"true\",\n                \"analysis_method\": \"clustering\",\n                \"min_confidence\": \"0.5\",\n            },\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"Unknown - Other\", response.data[0][\"name\"])\n        # face count should be number of faces with clustering person = None\n        self.assertEqual(response.data[0][\"face_count\"], 2)\n\n    def test_no_inferred_faces(self):\n        \"\"\"Test when there are no inferred faces and only user-labeled faces should appear.\"\"\"\n        person = create_test_person(name=\"John Doe\", cluster_owner=self.user)\n        create_test_face(photo=self.photo, person=person)\n\n        response = self.client.get(\n            reverse(\"incomplete_faces-list\"), {\"inferred\": \"false\"}\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\n            \"John Doe\", response.data[0][\"name\"]\n        )  # Ensure user-labeled face is returned\n\n\nclass FaceListViewTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_min_confidence_when_classification(self):\n        \"\"\"Test that faces with classification are returned properly.\"\"\"\n        person = create_test_person(cluster_owner=self.user)\n        create_test_face(\n            photo=self.photo,\n            classification_person=person,\n            classification_probability=0.6,\n        )\n        create_test_face(\n            photo=self.photo,\n            classification_person=person,\n            classification_probability=0.4,\n        )\n\n        response = self.client.get(\n            reverse(\"faces-list\"),\n            {\n                \"person\": person.id,\n                \"analysis_method\": \"classification\",\n                \"min_confidence\": \"0.5\",\n            },\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(len(response.data[\"results\"]), 1)\n\n    def test_min_confidence_but_for_unknown_other(self):\n        \"\"\"Test that unknown faces with classification are returned properly.\"\"\"\n        person = create_test_person(cluster_owner=self.user)\n        create_test_face(\n            photo=self.photo,\n            classification_person=person,\n            classification_probability=0.4,\n        )\n\n        response = self.client.get(\n            reverse(\"faces-list\"),\n            {\n                \"person\": \"0\",\n                \"analysis_method\": \"classification\",\n                \"min_confidence\": \"0.5\",\n            },\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(len(response.data[\"results\"]), 1)\n        self.assertEqual(0.4, response.data[\"results\"][0][\"person_label_probability\"])\n\n    def test_min_confidence_when_clustering(self):\n        \"\"\"Test that faces with clustering are returned properly.\"\"\"\n        person = create_test_person(cluster_owner=self.user)\n        create_test_face(\n            photo=self.photo, cluster_person=person, cluster_probability=0.6\n        )\n        create_test_face(\n            photo=self.photo, cluster_person=person, cluster_probability=0.4\n        )\n\n        response = self.client.get(\n            reverse(\"faces-list\"),\n            {\n                \"person\": person.id,\n                \"analysis_method\": \"clustering\",\n                \"min_confidence\": \"0.5\",\n            },\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(len(response.data[\"results\"]), 1)\n\n    def test_min_confidence_when_clustering_and_unknown(self):\n        \"\"\"Test that unknown faces with clustering are returned properly.\"\"\"\n        person = create_test_person(cluster_owner=self.user)\n        create_test_face(\n            photo=self.photo, cluster_person=person, cluster_probability=0.4\n        )\n\n        response = self.client.get(\n            reverse(\"faces-list\"),\n            {\n                \"person\": \"0\",\n                \"analysis_method\": \"clustering\",\n                \"min_confidence\": \"0.5\",\n            },\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(len(response.data[\"results\"]), 1)\n        self.assertEqual(0.4, response.data[\"results\"][0][\"person_label_probability\"])\n\n    def test_face_list_classification_order_by_probability(self):\n        \"\"\"Test that faces with classification are ordered by classification probability.\"\"\"\n        person = create_test_person(cluster_owner=self.user)\n        create_test_face(\n            photo=self.photo,\n            classification_person=person,\n            classification_probability=0.7,\n        )\n        create_test_face(\n            photo=self.photo,\n            classification_person=person,\n            classification_probability=0.9,\n        )\n\n        response = self.client.get(\n            reverse(\"faces-list\"),\n            {\n                \"person\": person.id,\n                \"analysis_method\": \"classification\",\n                \"order_by\": \"probability\",\n            },\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertGreater(\n            response.data[\"results\"][0][\"person_label_probability\"],\n            response.data[\"results\"][1][\"person_label_probability\"],\n        )\n\n    def test_face_list_clustering_order_by_probability(self):\n        \"\"\"Test that faces with clustering are ordered by clustering probability.\"\"\"\n        person = create_test_person(cluster_owner=self.user)\n        create_test_face(\n            photo=self.photo, cluster_person=person, cluster_probability=0.6\n        )\n        create_test_face(\n            photo=self.photo, cluster_person=person, cluster_probability=0.9\n        )\n\n        response = self.client.get(\n            reverse(\"faces-list\"),\n            {\n                \"person\": person.id,\n                \"analysis_method\": \"clustering\",\n                \"order_by\": \"probability\",\n            },\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertGreater(\n            response.data[\"results\"][0][\"person_label_probability\"],\n            response.data[\"results\"][1][\"person_label_probability\"],\n        )\n\n    def test_face_list_order_by_date(self):\n        \"\"\"Test that faces can be ordered by the photo's timestamp when 'order_by' is set to 'date'.\"\"\"\n        person = create_test_person(cluster_owner=self.user)\n        photo = create_test_photo(\n            owner=self.user, exif_timestamp=\"2021-01-01T00:00:00Z\"\n        )\n        photo2 = create_test_photo(\n            owner=self.user, exif_timestamp=\"2021-01-02T00:00:00Z\"\n        )\n        create_test_face(photo=photo, person=person)\n        create_test_face(photo=photo2, person=person)\n        response = self.client.get(\n            reverse(\"faces-list\"),\n            {\"person\": person.id, \"inferred\": False, \"order_by\": \"date\"},\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertLess(\n            response.data[\"results\"][0][\"timestamp\"],\n            response.data[\"results\"][1][\"timestamp\"],\n        )\n"
  },
  {
    "path": "api/tests/test_hide_photos.py",
    "content": "from unittest.mock import patch\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_photos, create_test_user\n\n\nclass FavoritePhotosTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n\n    def test_hide_my_photos(self):\n        photos = create_test_photos(number_of_photos=3, owner=self.user1)\n        image_hashes = [p.image_hash for p in photos]\n\n        payload = {\"image_hashes\": image_hashes, \"hidden\": True}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/hide/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(3, len(data[\"results\"]))\n        self.assertEqual(3, len(data[\"updated\"]))\n        self.assertEqual(0, len(data[\"not_updated\"]))\n\n    def test_untag_my_photos_as_favorite(self):\n        photos1 = create_test_photos(number_of_photos=1, owner=self.user1, hidden=True)\n        photos2 = create_test_photos(number_of_photos=2, owner=self.user1)\n        image_hashes = [p.image_hash for p in photos1 + photos2]\n\n        payload = {\"image_hashes\": image_hashes, \"hidden\": False}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/hide/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(1, len(data[\"results\"]))\n        self.assertEqual(1, len(data[\"updated\"]))\n        self.assertEqual(2, len(data[\"not_updated\"]))\n\n    def test_tag_photos_of_other_user_as_favorite(self):\n        photos = create_test_photos(number_of_photos=2, owner=self.user2)\n        image_hashes = [p.image_hash for p in photos]\n\n        payload = {\"image_hashes\": image_hashes, \"hidden\": True}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/hide/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(0, len(data[\"results\"]))\n        self.assertEqual(0, len(data[\"updated\"]))\n        # Photos not owned by user are treated as \"missing\" for security (no info leak)\n        self.assertEqual(0, len(data[\"not_updated\"]))\n\n    @patch(\"api.views.photos.logger.warning\", autospec=True)\n    def test_tag_nonexistent_photo_as_favorite(self, logger):\n        payload = {\"image_hashes\": [\"nonexistent_photo\"], \"hidden\": True}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/hide/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(0, len(data[\"results\"]))\n        self.assertEqual(0, len(data[\"updated\"]))\n        self.assertEqual(0, len(data[\"not_updated\"]))\n        logger.assert_called_with(\n            \"Could not set photo nonexistent_photo to hidden. It does not exist or is not owned by user.\"\n        )\n"
  },
  {
    "path": "api/tests/test_im2txt.py",
    "content": "import json\nimport os\nimport statistics\nimport threading\nimport time\nfrom unittest import skip\n\nimport psutil\nimport torch\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.image_captioning import generate_caption, unload_model\nfrom api.tests.utils import create_test_user\nfrom api.util import logger\n\n\ndef test_coco(testcase, device=\"cpu\", model=\"im2txt\"):\n    from pycocoevalcap.eval import COCOEvalCap\n    from pycocotools.coco import COCO\n\n    logger.info(f\"{device} {model}\")\n    blip = model == \"blip\"\n\n    # Path to the annotations file\n    annotation_file = (\n        os.path.dirname(os.path.abspath(__file__))\n        + \"/fixtures/coco/captions_val2017.json\"\n    )\n\n    # Initialize COCO API for annotations\n    coco_caps = COCO(annotation_file)\n\n    # Get a list of all image IDs in the dataset\n    all_image_ids = coco_caps.getImgIds()\n\n    # Load all images using the COCO API\n    all_images = coco_caps.loadImgs(all_image_ids)\n\n    # Save the generated captions together with the image IDs\n    generated_captions = []\n    counter = 0\n    # Iterate through the valdation images\n    for image_info in all_images:\n        image = (\n            os.path.dirname(os.path.abspath(__file__))\n            + \"/fixtures/coco/val2017/\"\n            + image_info[\"file_name\"]\n        )\n        caption = generate_caption(blip=blip, image_path=image)\n        # The LSTM adds a <start> and <end> token to the generated caption\n        caption = caption.replace(\"<start>\", \"\").replace(\"<end>\", \"\").strip().lower()\n        generated_captions.append({\"image_id\": image_info[\"id\"], \"caption\": caption})\n        counter += 1\n        if counter > 100000:\n            break\n\n    testcase.end_time = time.time()\n\n    # Define the path to the output JSON file\n    output_json_file = (\n        os.path.dirname(os.path.abspath(__file__))\n        + \"/fixtures/coco/\"\n        + \"generated_captions.json\"\n    )\n\n    # Write the generated_captions to the JSON file\n    with open(output_json_file, \"w\") as json_file:\n        json.dump(generated_captions, json_file)\n\n    # create coco object and coco_result object\n    coco = COCO(annotation_file)\n    coco_result = coco.loadRes(output_json_file)\n\n    # create coco_eval object by taking coco and coco_result\n    coco_eval = COCOEvalCap(coco, coco_result)\n\n    # evaluate on a subset of images by setting\n    coco_eval.params[\"image_id\"] = coco_result.getImgIds()\n\n    # evaluate results\n    # SPICE will take a few minutes the first time, but speeds up due to caching\n    coco_eval.evaluate()\n\n    # print output evaluation scores\n    for metric, score in coco_eval.eval.items():\n        logger.info(f\"{metric}: {score:.3f}\")\n\n\nclass Im2TxtBenchmark(TestCase):\n    gpu_available = torch.cuda.is_available()\n    # Start monitoring RAM usage\n    process = psutil.Process(os.getpid())\n    start_ram_usage = process.memory_info().rss\n    # Start measuring timepycocoevalcap\n    start_time = time.time()\n    ram_usages = []  # List to store RAM usage samples\n    ram_monitor_thread = None\n    end_time = None\n\n    def setUp(self) -> None:\n        # Check if the required files exist in the fixtures directory\n        self.coco_fixture_path = os.path.join(self.fixtures_dir, \"coco\")\n        self.val2017_fixture_path = os.path.join(self.fixtures_dir, \"coco\", \"val2017\")\n        self.val2017captions_fixture_path = os.path.join(\n            self.fixtures_dir, \"coco\", \"captions_val2017.json\"\n        )\n\n        if not os.path.exists(self.coco_fixture_path):\n            logger.warning(\n                f\"Skipping tests. Directory not found: {self.coco_fixture_path}. Please add a coco folder to the fixtures directory.\"\n            )\n            self.skipTest(\"Directory not found\")\n\n        if not os.path.exists(self.val2017_fixture_path):\n            logger.warning(\n                f\"Skipping tests. Directory not found: {self.val2017_fixture_path}. Validation images are required for the COCO benchmark. Please download the COCO validation images and place them in the fixtures/coco/val2017 directory.\"\n            )\n            self.skipTest(\"Directory not found\")\n        if not os.path.exists(self.val2017captions_fixture_path):\n            logger.warning(\n                f\"Skipping tests. Directory not found: {self.val2017captions_fixture_path}. Captions of Validation images are required for the COCO benchmark. Please download the COCO validation images and place them in the fixtures/coco/ directory.\"\n            )\n            self.skipTest(\"Directory not found\")\n        unload_model()\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n        self.gpu_available = torch.cuda.is_available()\n        self.process = psutil.Process(os.getpid())\n        self.start_ram_usage = self.process.memory_info().rss\n        self.start_time = time.time()\n        self.ram_usages = []  # List to store RAM usage samples\n        self.ram_monitor_thread = threading.Thread(\n            target=self.monitor_ram_usage, args=(self.process, self.ram_usages, 5)\n        )  # Monitor RAM for 5 seconds\n        self.ram_monitor_thread.start()\n\n    def tearDown(self) -> None:\n        if self.end_time is None:\n            execution_time = time.time() - self.start_time\n        else:\n            execution_time = self.end_time - self.start_time\n            self.end_time = None\n        self.ram_monitor_thread.join()\n        # Calculate RAM usage statistics\n        mean_ram_usage_mb = statistics.mean(self.ram_usages) / (\n            1024 * 1024\n        )  # Convert bytes to MB\n        median_ram_usage_mb = statistics.median(self.ram_usages) / (\n            1024 * 1024\n        )  # Convert bytes to MB\n\n        # Log the results using the logger function\n        logger.info(\"Test Result:\")\n        logger.info(f\"GPU Used: {'Yes' if self.gpu_available else 'No'}\")\n        logger.info(f\"Mean RAM Usage: {mean_ram_usage_mb:.2f} MB\")\n        logger.info(f\"Median RAM Usage: {median_ram_usage_mb:.2f} MB\")\n        logger.info(f\"Execution Time: {execution_time:.2f} seconds\")\n\n    def monitor_ram_usage(self, process, ram_usages, duration):\n        # Monitor RAM usage for the specified duration\n        start_time = time.time()\n        while time.time() - start_time < duration:\n            ram_usage = process.memory_info().rss\n            ram_usages.append(ram_usage)\n            time.sleep(0.1)  # Poll every 1 second\n\n    @skip\n    def test_im2txt_cpu(self):\n        file = os.path.dirname(os.path.abspath(__file__)) + \"/fixtures/niaz.jpg\"\n        self.gpu_available = \"False\"\n        caption = generate_caption(device=torch.device(\"cpu\"), image_path=file)\n\n        self.assertEqual(\n            \"<start> a man with a beard is holding a remote control . <end>\", caption\n        )\n\n        logger.info(f\"Caption: {caption}\")\n\n    @skip\n    def test_im2txt_gpu(self):\n        file = os.path.dirname(os.path.abspath(__file__)) + \"/fixtures/niaz.jpg\"\n        caption = generate_caption(device=torch.device(\"cuda\"), image_path=file)\n\n        self.assertEqual(\n            \"<start> a man with a beard is holding a remote control . <end>\", caption\n        )\n\n        logger.info(f\"Caption: {caption}\")\n\n    @skip\n    def test_im2txt_cpu_100(self):\n        file = os.path.dirname(os.path.abspath(__file__)) + \"/fixtures/niaz.jpg\"\n        self.gpu_available = \"False\"\n\n        for i in range(100):\n            caption = generate_caption(device=torch.device(\"cpu\"), image_path=file)\n            self.assertEqual(\n                \"<start> a man with a beard is holding a remote control . <end>\",\n                caption,\n            )\n\n    @skip\n    def test_im2txt_gpu_100(self):\n        file = os.path.dirname(os.path.abspath(__file__)) + \"/fixtures/niaz.jpg\"\n\n        for i in range(100):\n            caption = generate_caption(device=torch.device(\"cuda\"), image_path=file)\n            self.assertEqual(\n                \"<start> a man with a beard is holding a remote control . <end>\",\n                caption,\n            )\n\n    @skip\n    def test_im2txt_coco_cpu(self):\n        test_coco(testcase=self, device=\"cpu\", model=\"im2txt\")\n\n    @skip\n    def test_im2txt_coco_gpu(self):\n        test_coco(testcase=self, device=\"cuda\", model=\"im2txt\")\n\n    @skip\n    def test_blip_coco_cpu(self):\n        test_coco(self, \"cpu\", \"blip\")\n\n    @skip\n    def test_blip_coco_gpu(self):\n        test_coco(self, \"cuda\", \"blip\")\n"
  },
  {
    "path": "api/tests/test_live_photo.py",
    "content": "\"\"\"\nComprehensive tests for api/stacks/live_photo.py\n\nTests the Live Photo detection and stacking logic:\n- Google Pixel Motion Photo detection (embedded MP4 after JPEG EOI)\n- Samsung Motion Photo detection (MotionPhoto_Data marker)\n- Apple Live Photo detection (paired .mov file)\n- Stack creation for detected live photos\n- Batch processing\n\"\"\"\n\nimport os\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, Mock, patch\n\nfrom django.test import TestCase, override_settings\n\nfrom api.models.file import File\nfrom api.models.photo import Photo\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.user import User\nfrom api.stacks.live_photo import (\n    APPLE_LIVE_PHOTO_EXTENSIONS,\n    GOOGLE_PIXEL_MP4_SIGNATURES,\n    JPEG_EOI_MARKER,\n    SAMSUNG_MOTION_MARKER,\n    _create_apple_live_photo_stack,\n    _create_embedded_live_photo_stack,\n    _locate_google_embedded_video,\n    _locate_samsung_embedded_video,\n    detect_live_photo,\n    extract_embedded_motion_video,\n    find_apple_live_photo_video,\n    has_embedded_motion_video,\n    process_live_photos_batch,\n)\n\n\nclass LocateGoogleEmbeddedVideoTestCase(TestCase):\n    \"\"\"Tests for the _locate_google_embedded_video function.\"\"\"\n\n    def test_finds_ftypmp42_signature(self):\n        \"\"\"Should find MP4 with ftypmp42 signature.\"\"\"\n        # Build data: JPEG content + 4 padding bytes + ftyp signature\n        data = b\"JPEG_CONTENT\\xff\\xd9\" + b\"\\x00\\x00\\x00\\x00\" + b\"ftypmp42\" + b\"more_video_data\"\n        position = _locate_google_embedded_video(data)\n        expected = data.find(b\"ftypmp42\") - 4\n        self.assertEqual(position, expected)\n\n    def test_finds_ftypisom_signature(self):\n        \"\"\"Should find MP4 with ftypisom signature.\"\"\"\n        data = b\"JPEG_CONTENT\\xff\\xd9\" + b\"\\x00\\x00\\x00\\x20\" + b\"ftypisom\"\n        position = _locate_google_embedded_video(data)\n        expected = data.find(b\"ftypisom\") - 4\n        self.assertEqual(position, expected)\n\n    def test_finds_ftypiso2_signature(self):\n        \"\"\"Should find MP4 with ftypiso2 signature.\"\"\"\n        data = b\"JPEG_CONTENT\\xff\\xd9\" + b\"\\x00\\x00\\x00\\x20\" + b\"ftypiso2\"\n        position = _locate_google_embedded_video(data)\n        expected = data.find(b\"ftypiso2\") - 4\n        self.assertEqual(position, expected)\n\n    def test_returns_minus_one_when_not_found(self):\n        \"\"\"Should return -1 if no signature found.\"\"\"\n        data = b\"JPEG_CONTENT\\xff\\xd9_no_video_here\"\n        position = _locate_google_embedded_video(data)\n        self.assertEqual(position, -1)\n\n    def test_empty_data(self):\n        \"\"\"Should return -1 for empty data.\"\"\"\n        position = _locate_google_embedded_video(b\"\")\n        self.assertEqual(position, -1)\n\n    def test_finds_first_signature_if_multiple(self):\n        \"\"\"Should find the first signature if multiple exist.\"\"\"\n        data = b\"JPEG\" + b\"\\x00\\x00\\x00\\x00\" + b\"ftypmp42\" + b\"MIDDLE\" + b\"ftypisom\"\n        position = _locate_google_embedded_video(data)\n        # Should find ftypmp42 first\n        self.assertEqual(position, 4)  # 4 bytes for \"JPEG\"\n\n\nclass LocateSamsungEmbeddedVideoTestCase(TestCase):\n    \"\"\"Tests for the _locate_samsung_embedded_video function.\"\"\"\n\n    def test_finds_samsung_marker(self):\n        \"\"\"Should find Samsung motion photo marker.\"\"\"\n        data = b\"JPEG_CONTENT\\xff\\xd9\" + SAMSUNG_MOTION_MARKER + b\"video_data\"\n        position = _locate_samsung_embedded_video(data)\n        expected = data.find(SAMSUNG_MOTION_MARKER) + len(SAMSUNG_MOTION_MARKER)\n        self.assertEqual(position, expected)\n\n    def test_returns_minus_one_when_not_found(self):\n        \"\"\"Should return -1 if no marker found.\"\"\"\n        data = b\"JPEG_CONTENT\\xff\\xd9_no_motion_marker\"\n        position = _locate_samsung_embedded_video(data)\n        self.assertEqual(position, -1)\n\n    def test_empty_data(self):\n        \"\"\"Should return -1 for empty data.\"\"\"\n        position = _locate_samsung_embedded_video(b\"\")\n        self.assertEqual(position, -1)\n\n\nclass HasEmbeddedMotionVideoTestCase(TestCase):\n    \"\"\"Tests for the has_embedded_motion_video function.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create temporary directory for test files.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n\n    def tearDown(self):\n        \"\"\"Clean up temporary files.\"\"\"\n        import shutil\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    @patch(\"api.stacks.live_photo.magic.Magic\")\n    def test_returns_false_for_non_jpeg(self, mock_magic_class):\n        \"\"\"Should return False for non-JPEG files.\"\"\"\n        mock_magic = MagicMock()\n        mock_magic.from_file.return_value = \"image/png\"\n        mock_magic_class.return_value = mock_magic\n\n        result = has_embedded_motion_video(\"/some/path.png\")\n        self.assertFalse(result)\n\n    @patch(\"api.stacks.live_photo.magic.Magic\")\n    @patch(\"builtins.open\")\n    @patch(\"api.stacks.live_photo.mmap\")\n    def test_returns_true_for_google_motion_photo(self, mock_mmap, mock_open, mock_magic_class):\n        \"\"\"Should return True for Google Motion Photo.\"\"\"\n        mock_magic = MagicMock()\n        mock_magic.from_file.return_value = \"image/jpeg\"\n        mock_magic_class.return_value = mock_magic\n\n        # Mock file data with Google MP4 signature\n        mock_data = b\"JPEG\" + b\"\\x00\\x00\\x00\\x00\" + b\"ftypmp42\"\n        mock_mm = MagicMock()\n        mock_mm.__enter__ = Mock(return_value=mock_data)\n        mock_mm.__exit__ = Mock(return_value=False)\n        mock_mmap.return_value = mock_mm\n\n        mock_file = MagicMock()\n        mock_file.__enter__ = Mock(return_value=mock_file)\n        mock_file.__exit__ = Mock(return_value=False)\n        mock_open.return_value = mock_file\n\n        with patch(\"api.stacks.live_photo._locate_google_embedded_video\", return_value=4):\n            result = has_embedded_motion_video(\"/some/path.jpg\")\n            self.assertTrue(result)\n\n    @patch(\"api.stacks.live_photo.magic.Magic\")\n    def test_returns_false_on_exception(self, mock_magic_class):\n        \"\"\"Should return False and log warning on exception.\"\"\"\n        mock_magic_class.side_effect = Exception(\"File not found\")\n\n        with patch(\"api.stacks.live_photo.logger\") as mock_logger:\n            result = has_embedded_motion_video(\"/nonexistent/path.jpg\")\n            self.assertFalse(result)\n            mock_logger.warning.assert_called()\n\n\nclass FindAppleLivePhotoVideoTestCase(TestCase):\n    \"\"\"Tests for the find_apple_live_photo_video function.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create temporary directory for test files.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n\n    def tearDown(self):\n        \"\"\"Clean up temporary files.\"\"\"\n        import shutil\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    def test_finds_lowercase_mov_companion(self):\n        \"\"\"Should find .mov companion file.\"\"\"\n        # Create test files\n        image_path = os.path.join(self.temp_dir, \"IMG_001.jpg\")\n        video_path = os.path.join(self.temp_dir, \"IMG_001.mov\")\n        Path(image_path).touch()\n        Path(video_path).touch()\n\n        result = find_apple_live_photo_video(image_path)\n        self.assertEqual(result, video_path)\n\n    def test_finds_uppercase_mov_companion(self):\n        \"\"\"Should find .MOV companion file (uppercase).\"\"\"\n        image_path = os.path.join(self.temp_dir, \"IMG_002.HEIC\")\n        video_path = os.path.join(self.temp_dir, \"IMG_002.MOV\")\n        Path(image_path).touch()\n        Path(video_path).touch()\n\n        result = find_apple_live_photo_video(image_path)\n        self.assertEqual(result, video_path)\n\n    def test_returns_none_when_no_companion(self):\n        \"\"\"Should return None if no companion video exists.\"\"\"\n        image_path = os.path.join(self.temp_dir, \"IMG_003.jpg\")\n        Path(image_path).touch()\n\n        result = find_apple_live_photo_video(image_path)\n        self.assertIsNone(result)\n\n    def test_prefers_lowercase_mov(self):\n        \"\"\"Should prefer .mov over .MOV if both exist.\"\"\"\n        image_path = os.path.join(self.temp_dir, \"IMG_004.jpg\")\n        video_lowercase = os.path.join(self.temp_dir, \"IMG_004.mov\")\n        video_uppercase = os.path.join(self.temp_dir, \"IMG_004.MOV\")\n        Path(image_path).touch()\n        Path(video_lowercase).touch()\n        Path(video_uppercase).touch()\n\n        result = find_apple_live_photo_video(image_path)\n        # Should find .mov first (lowercase is first in APPLE_LIVE_PHOTO_EXTENSIONS)\n        self.assertEqual(result, video_lowercase)\n\n    def test_handles_different_image_extensions(self):\n        \"\"\"Should work with various image extensions.\"\"\"\n        for ext in [\".jpg\", \".JPG\", \".heic\", \".HEIC\", \".jpeg\"]:\n            image_path = os.path.join(self.temp_dir, f\"test{ext}\")\n            video_path = os.path.join(self.temp_dir, \"test.mov\")\n            Path(image_path).touch()\n            Path(video_path).touch()\n\n            result = find_apple_live_photo_video(image_path)\n            self.assertEqual(result, video_path)\n\n            # Cleanup for next iteration\n            Path(image_path).unlink()\n            Path(video_path).unlink()\n\n\nclass ExtractEmbeddedMotionVideoTestCase(TestCase):\n    \"\"\"Tests for the extract_embedded_motion_video function.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create temporary directory for test files.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n\n    def tearDown(self):\n        \"\"\"Clean up temporary files.\"\"\"\n        import shutil\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    @override_settings(MEDIA_ROOT=None)\n    def test_extracts_google_motion_video(self):\n        \"\"\"Should extract embedded MP4 from Google Motion Photo.\"\"\"\n        # Use temp_dir as MEDIA_ROOT\n        with self.settings(MEDIA_ROOT=self.temp_dir):\n            # Create a fake motion photo file\n            fake_video_data = b\"fake_mp4_video_content\"\n            file_content = (\n                b\"JPEG_IMAGE_DATA\\xff\\xd9\" +  # JPEG with EOI marker\n                b\"\\x00\\x00\\x00\\x00\" +  # 4 padding bytes\n                b\"ftypmp42\" +  # ftyp signature\n                fake_video_data\n            )\n\n            input_path = os.path.join(self.temp_dir, \"motion_photo.jpg\")\n            with open(input_path, \"wb\") as f:\n                f.write(file_content)\n\n            result = extract_embedded_motion_video(input_path, \"test_hash_123\")\n\n            self.assertIsNotNone(result)\n            self.assertIn(\"test_hash_123_motion.mp4\", result)\n            self.assertTrue(os.path.exists(result))\n\n            # Verify extracted content starts from ftyp\n            with open(result, \"rb\") as f:\n                extracted = f.read()\n                self.assertTrue(extracted.startswith(b\"\\x00\\x00\\x00\\x00ftypmp42\"))\n\n    def test_returns_none_for_no_embedded_video(self):\n        \"\"\"Should return None if no embedded video found.\"\"\"\n        with self.settings(MEDIA_ROOT=self.temp_dir):\n            # Create a regular JPEG without embedded video\n            input_path = os.path.join(self.temp_dir, \"regular.jpg\")\n            with open(input_path, \"wb\") as f:\n                f.write(b\"JPEG_DATA\\xff\\xd9\")\n\n            result = extract_embedded_motion_video(input_path, \"hash123\")\n            self.assertIsNone(result)\n\n    def test_returns_none_on_file_error(self):\n        \"\"\"Should return None and log error if file access fails.\"\"\"\n        with patch(\"api.stacks.live_photo.logger\") as mock_logger:\n            result = extract_embedded_motion_video(\"/nonexistent/file.jpg\", \"hash\")\n            self.assertIsNone(result)\n            mock_logger.error.assert_called()\n\n\nclass DetectLivePhotoTestCase(TestCase):\n    \"\"\"Tests for the detect_live_photo function.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and temporary files.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n        self.user = User.objects.create(username=\"livetest\")\n\n    def tearDown(self):\n        \"\"\"Clean up.\"\"\"\n        import shutil\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    def test_returns_none_for_photo_without_main_file(self):\n        \"\"\"Should return None if photo has no main_file.\"\"\"\n        photo = MagicMock()\n        photo.main_file = None\n\n        result = detect_live_photo(photo, self.user)\n        self.assertIsNone(result)\n\n    @patch(\"api.stacks.live_photo.has_embedded_motion_video\")\n    @patch(\"api.stacks.live_photo._create_embedded_live_photo_stack\")\n    def test_detects_embedded_motion_video(self, mock_create, mock_has_embedded):\n        \"\"\"Should detect and create stack for embedded motion video.\"\"\"\n        mock_has_embedded.return_value = True\n        mock_stack = MagicMock()\n        mock_create.return_value = mock_stack\n\n        photo = MagicMock()\n        photo.main_file.path = \"/path/to/image.jpg\"\n\n        result = detect_live_photo(photo, self.user)\n\n        mock_create.assert_called_once_with(photo, self.user)\n        self.assertEqual(result, mock_stack)\n\n    @patch(\"api.stacks.live_photo.has_embedded_motion_video\")\n    @patch(\"api.stacks.live_photo.find_apple_live_photo_video\")\n    @patch(\"api.stacks.live_photo._create_apple_live_photo_stack\")\n    def test_detects_apple_live_photo(self, mock_create, mock_find, mock_has_embedded):\n        \"\"\"Should detect and create stack for Apple Live Photo.\"\"\"\n        mock_has_embedded.return_value = False\n        mock_find.return_value = \"/path/to/video.mov\"\n        mock_stack = MagicMock()\n        mock_create.return_value = mock_stack\n\n        photo = MagicMock()\n        photo.main_file.path = \"/path/to/image.jpg\"\n\n        result = detect_live_photo(photo, self.user)\n\n        mock_create.assert_called_once_with(photo, \"/path/to/video.mov\", self.user)\n        self.assertEqual(result, mock_stack)\n\n    @patch(\"api.stacks.live_photo.has_embedded_motion_video\")\n    @patch(\"api.stacks.live_photo.find_apple_live_photo_video\")\n    def test_returns_none_for_regular_photo(self, mock_find, mock_has_embedded):\n        \"\"\"Should return None for regular photos without motion.\"\"\"\n        mock_has_embedded.return_value = False\n        mock_find.return_value = None\n\n        photo = MagicMock()\n        photo.main_file.path = \"/path/to/regular.jpg\"\n\n        result = detect_live_photo(photo, self.user)\n        self.assertIsNone(result)\n\n\nclass CreateEmbeddedLivePhotoStackTestCase(TestCase):\n    \"\"\"Tests for _create_embedded_live_photo_stack function.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user.\"\"\"\n        self.user = User.objects.create(username=\"embedtest\")\n        self.temp_dir = tempfile.mkdtemp()\n\n    def tearDown(self):\n        \"\"\"Clean up.\"\"\"\n        import shutil\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    @override_settings(FEATURE_PROCESS_EMBEDDED_MEDIA=False)\n    def test_returns_none_if_feature_disabled(self):\n        \"\"\"Should return None if embedded media processing is disabled.\"\"\"\n        photo = MagicMock()\n\n        with patch(\"api.stacks.live_photo.logger\") as mock_logger:\n            result = _create_embedded_live_photo_stack(photo, self.user)\n            self.assertIsNone(result)\n            mock_logger.debug.assert_called()\n\n    @override_settings(FEATURE_PROCESS_EMBEDDED_MEDIA=True)\n    @patch(\"api.stacks.live_photo.extract_embedded_motion_video\")\n    def test_returns_none_if_extraction_fails(self, mock_extract):\n        \"\"\"Should return None if video extraction fails.\"\"\"\n        mock_extract.return_value = None\n\n        photo = MagicMock()\n        photo.main_file.path = \"/path/to/image.jpg\"\n        photo.main_file.hash = \"abc123\"\n\n        result = _create_embedded_live_photo_stack(photo, self.user)\n        self.assertIsNone(result)\n\n    @override_settings(FEATURE_PROCESS_EMBEDDED_MEDIA=True)\n    @patch(\"api.stacks.live_photo.extract_embedded_motion_video\")\n    @patch(\"api.stacks.live_photo.File.create\")\n    def test_returns_existing_stack_if_present(self, mock_file_create, mock_extract):\n        \"\"\"Should return existing stack if photo already has one.\"\"\"\n        mock_extract.return_value = \"/path/to/video.mp4\"\n        mock_video_file = MagicMock()\n        mock_file_create.return_value = mock_video_file\n\n        # Create a mock photo with existing stack\n        existing_stack = MagicMock()\n        photo = MagicMock()\n        photo.main_file.path = \"/path/to/image.jpg\"\n        photo.main_file.hash = \"abc123\"\n        photo.main_file.embedded_media = MagicMock()\n        photo.stacks.filter.return_value.first.return_value = existing_stack\n\n        result = _create_embedded_live_photo_stack(photo, self.user)\n        self.assertEqual(result, existing_stack)\n\n\nclass CreateAppleLivePhotoStackTestCase(TestCase):\n    \"\"\"Tests for _create_apple_live_photo_stack function.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and file.\"\"\"\n        from django.utils import timezone\n\n        self.user = User.objects.create(username=\"appletest\")\n        self.temp_dir = tempfile.mkdtemp()\n\n        # Create a test file\n        self.file_hash = \"a\" * 32\n        self.file_path = os.path.join(self.temp_dir, \"test.jpg\")\n        Path(self.file_path).touch()\n\n        self.file = File.objects.create(\n            hash=self.file_hash,\n            path=self.file_path,\n            type=File.IMAGE,\n        )\n\n        # Create a test photo with required fields\n        self.photo = Photo.objects.create(\n            owner=self.user,\n            main_file=self.file,\n            image_hash=\"b\" * 32,\n            added_on=timezone.now(),\n        )\n\n    def tearDown(self):\n        \"\"\"Clean up.\"\"\"\n        import shutil\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    def test_creates_new_stack_for_apple_live_photo(self):\n        \"\"\"Should create new Live Photo stack.\"\"\"\n        video_path = os.path.join(self.temp_dir, \"test.mov\")\n        Path(video_path).touch()\n\n        # Create a real video file to avoid foreign key issues\n        video_file = File.objects.create(\n            hash=\"c\" * 32,\n            path=video_path,\n            type=File.VIDEO,\n        )\n\n        with patch(\"api.stacks.live_photo.File.create\", return_value=video_file):\n            with patch(\"api.stacks.live_photo.File.objects.filter\") as mock_filter:\n                # Simulate video file not existing yet\n                mock_filter.return_value.first.return_value = None\n\n                result = _create_apple_live_photo_stack(self.photo, video_path, self.user)\n\n                self.assertIsNotNone(result)\n                self.assertEqual(result.stack_type, PhotoStack.StackType.LIVE_PHOTO)\n                self.assertEqual(result.primary_photo, self.photo)\n                self.assertEqual(result.owner, self.user)\n\n    def test_returns_existing_stack_if_present(self):\n        \"\"\"Should return existing stack if photo already has one.\"\"\"\n        # Create existing stack\n        existing_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.LIVE_PHOTO,\n            primary_photo=self.photo,\n        )\n        self.photo.stacks.add(existing_stack)\n\n        video_path = os.path.join(self.temp_dir, \"test.mov\")\n        Path(video_path).touch()\n\n        # Create a real video file\n        video_file = File.objects.create(\n            hash=\"d\" * 32,\n            path=video_path,\n            type=File.VIDEO,\n        )\n\n        with patch(\"api.stacks.live_photo.File.create\", return_value=video_file):\n            with patch(\"api.stacks.live_photo.File.objects.filter\") as mock_filter:\n                mock_filter.return_value.first.return_value = None\n\n                result = _create_apple_live_photo_stack(self.photo, video_path, self.user)\n\n                self.assertEqual(result, existing_stack)\n\n\nclass ProcessLivePhotosBatchTestCase(TestCase):\n    \"\"\"Tests for the process_live_photos_batch function.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user.\"\"\"\n        self.user = User.objects.create(username=\"batchtest\")\n\n    @patch(\"api.stacks.live_photo.detect_live_photo\")\n    def test_processes_all_photos(self, mock_detect):\n        \"\"\"Should process all photos in the list.\"\"\"\n        mock_detect.return_value = None\n\n        photos = [MagicMock() for _ in range(5)]\n        result = process_live_photos_batch(self.user, photos)\n\n        self.assertEqual(mock_detect.call_count, 5)\n        self.assertEqual(result[\"detected\"], 0)\n        self.assertEqual(result[\"stacks_created\"], 0)\n\n    @patch(\"api.stacks.live_photo.detect_live_photo\")\n    def test_counts_detected_live_photos(self, mock_detect):\n        \"\"\"Should count detected live photos.\"\"\"\n        mock_stack = MagicMock()\n        mock_stack.photo_count = 2  # Existing stack with photos\n        mock_detect.side_effect = [mock_stack, None, mock_stack, None]\n\n        photos = [MagicMock() for _ in range(4)]\n        result = process_live_photos_batch(self.user, photos)\n\n        self.assertEqual(result[\"detected\"], 2)\n\n    @patch(\"api.stacks.live_photo.detect_live_photo\")\n    def test_counts_new_stacks_created(self, mock_detect):\n        \"\"\"Should count newly created stacks.\"\"\"\n        new_stack = MagicMock()\n        new_stack.photo_count = 1  # New stack (just the photo)\n        mock_detect.return_value = new_stack\n\n        photos = [MagicMock() for _ in range(3)]\n        result = process_live_photos_batch(self.user, photos)\n\n        self.assertEqual(result[\"detected\"], 3)\n        self.assertEqual(result[\"stacks_created\"], 3)\n\n    @patch(\"api.stacks.live_photo.detect_live_photo\")\n    def test_handles_exceptions_gracefully(self, mock_detect):\n        \"\"\"Should continue processing after exceptions.\"\"\"\n        mock_detect.side_effect = [Exception(\"Error\"), MagicMock(photo_count=2)]\n\n        photos = [MagicMock(), MagicMock()]\n        photos[0].id = \"photo1\"\n        photos[1].id = \"photo2\"\n\n        with patch(\"api.stacks.live_photo.logger\") as mock_logger:\n            result = process_live_photos_batch(self.user, photos)\n            mock_logger.error.assert_called()\n            self.assertEqual(result[\"detected\"], 1)\n\n    def test_empty_list_returns_zero_counts(self):\n        \"\"\"Should return zero counts for empty list.\"\"\"\n        result = process_live_photos_batch(self.user, [])\n        self.assertEqual(result[\"detected\"], 0)\n        self.assertEqual(result[\"stacks_created\"], 0)\n\n\nclass ConstantsTestCase(TestCase):\n    \"\"\"Tests for module constants.\"\"\"\n\n    def test_jpeg_eoi_marker(self):\n        \"\"\"JPEG EOI marker should be correct.\"\"\"\n        self.assertEqual(JPEG_EOI_MARKER, b\"\\xff\\xd9\")\n\n    def test_google_signatures_list(self):\n        \"\"\"Google MP4 signatures should be defined.\"\"\"\n        self.assertIn(b\"ftypmp42\", GOOGLE_PIXEL_MP4_SIGNATURES)\n        self.assertIn(b\"ftypisom\", GOOGLE_PIXEL_MP4_SIGNATURES)\n        self.assertIn(b\"ftypiso2\", GOOGLE_PIXEL_MP4_SIGNATURES)\n\n    def test_samsung_marker(self):\n        \"\"\"Samsung motion marker should be correct.\"\"\"\n        self.assertEqual(SAMSUNG_MOTION_MARKER, b\"MotionPhoto_Data\")\n\n    def test_apple_extensions(self):\n        \"\"\"Apple Live Photo extensions should include .mov variants.\"\"\"\n        self.assertIn(\".mov\", APPLE_LIVE_PHOTO_EXTENSIONS)\n        self.assertIn(\".MOV\", APPLE_LIVE_PHOTO_EXTENSIONS)\n\n\nclass EdgeCasesTestCase(TestCase):\n    \"\"\"Edge case tests for live photo detection.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create temporary directory.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n\n    def tearDown(self):\n        \"\"\"Clean up.\"\"\"\n        import shutil\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    def test_unicode_filename_apple_live_photo(self):\n        \"\"\"Should handle unicode characters in filenames.\"\"\"\n        image_path = os.path.join(self.temp_dir, \"照片_日本_🌸.jpg\")\n        video_path = os.path.join(self.temp_dir, \"照片_日本_🌸.mov\")\n        Path(image_path).touch()\n        Path(video_path).touch()\n\n        result = find_apple_live_photo_video(image_path)\n        self.assertEqual(result, video_path)\n\n    def test_special_characters_in_path(self):\n        \"\"\"Should handle special characters in file path.\"\"\"\n        image_path = os.path.join(self.temp_dir, \"test photo (1).jpg\")\n        video_path = os.path.join(self.temp_dir, \"test photo (1).mov\")\n        Path(image_path).touch()\n        Path(video_path).touch()\n\n        result = find_apple_live_photo_video(image_path)\n        self.assertEqual(result, video_path)\n\n    def test_locate_video_at_start_of_data(self):\n        \"\"\"Should handle video marker at very start of data.\"\"\"\n        data = b\"ftypmp42rest_of_video\"\n        position = _locate_google_embedded_video(data)\n        self.assertEqual(position, -4)  # Would be negative, which is fine\n\n    def test_multiple_samsung_markers(self):\n        \"\"\"Should find first Samsung marker if multiple present.\"\"\"\n        data = (\n            SAMSUNG_MOTION_MARKER + b\"first_video\" +\n            SAMSUNG_MOTION_MARKER + b\"second_video\"\n        )\n        position = _locate_samsung_embedded_video(data)\n        self.assertEqual(position, len(SAMSUNG_MOTION_MARKER))\n\n    def test_partial_signature_not_matched(self):\n        \"\"\"Should not match partial signatures.\"\"\"\n        # ftypm instead of ftypmp42\n        data = b\"JPEG\\xff\\xd9\\x00\\x00\\x00\\x00ftypm\"\n        position = _locate_google_embedded_video(data)\n        self.assertEqual(position, -1)\n\n    def test_very_large_file_simulation(self):\n        \"\"\"Should handle large data efficiently.\"\"\"\n        # Create 10MB of fake data with marker near end\n        large_data = b\"A\" * (10 * 1024 * 1024)\n        large_data += b\"\\x00\\x00\\x00\\x00ftypmp42\"\n\n        position = _locate_google_embedded_video(large_data)\n        self.assertGreater(position, 0)\n\n    def test_binary_data_with_nulls(self):\n        \"\"\"Should handle binary data with null bytes.\"\"\"\n        data = b\"\\x00\" * 100 + b\"\\x00\\x00\\x00\\x00ftypmp42\" + b\"\\x00\" * 50\n        position = _locate_google_embedded_video(data)\n        self.assertEqual(position, 100)\n"
  },
  {
    "path": "api/tests/test_location_timeline.py",
    "content": "import csv\nimport os\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.stats import get_location_timeline, get_photo_month_counts\nfrom api.models import Photo\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\ndef prepare_database(user):\n    data = (\n        os.path.dirname(os.path.abspath(__file__))\n        + \"/fixtures/location_timeline_test_data.csv\"\n    )\n    with open(data) as f:\n        reader = csv.reader(f)\n        for row in reader:\n            if row[0].startswith(\"#\"):\n                continue\n            country = row[0]\n            exif_timestamp = row[1]\n            geolocation_json = {\"places\": [country], \"features\": [{\"text\": country}]}\n            create_test_photo(\n                owner=user,\n                exif_timestamp=exif_timestamp,\n                geolocation_json=geolocation_json,\n            )\n\n\nexpected_location_timeline = [\n    {\n        \"data\": [22208418.0],\n        \"color\": \"#a6cee3\",\n        \"loc\": \"Germany\",\n        \"start\": 1576286343.0,\n        \"end\": 1598494761.0,\n    },\n    {\n        \"data\": [9413609.0],\n        \"color\": \"#1f78b4\",\n        \"loc\": \"Canada\",\n        \"start\": 1598494761.0,\n        \"end\": 1607908370.0,\n    },\n    {\n        \"data\": [20648022.0],\n        \"color\": \"#b2df8a\",\n        \"loc\": \"France\",\n        \"start\": 1607908370.0,\n        \"end\": 1628556392.0,\n    },\n    {\n        \"data\": [6132785.0],\n        \"color\": \"#33a02c\",\n        \"loc\": \"Canada\",\n        \"start\": 1628556392.0,\n        \"end\": 1634689177.0,\n    },\n    {\n        \"data\": [79828.0],\n        \"color\": \"#fb9a99\",\n        \"loc\": \"France\",\n        \"start\": 1634689177.0,\n        \"end\": 1634769005.0,\n    },\n]\n\nexpected_photo_month_counts = [\n    {\"month\": \"2019-12\", \"count\": 4},\n    {\"month\": \"2020-1\", \"count\": 0},\n    {\"month\": \"2020-2\", \"count\": 0},\n    {\"month\": \"2020-3\", \"count\": 0},\n    {\"month\": \"2020-4\", \"count\": 0},\n    {\"month\": \"2020-5\", \"count\": 0},\n    {\"month\": \"2020-6\", \"count\": 0},\n    {\"month\": \"2020-7\", \"count\": 0},\n    {\"month\": \"2020-8\", \"count\": 4},\n    {\"month\": \"2020-9\", \"count\": 0},\n    {\"month\": \"2020-10\", \"count\": 0},\n    {\"month\": \"2020-11\", \"count\": 0},\n    {\"month\": \"2020-12\", \"count\": 4},\n    {\"month\": \"2021-1\", \"count\": 0},\n    {\"month\": \"2021-2\", \"count\": 0},\n    {\"month\": \"2021-3\", \"count\": 0},\n    {\"month\": \"2021-4\", \"count\": 0},\n    {\"month\": \"2021-5\", \"count\": 0},\n    {\"month\": \"2021-6\", \"count\": 0},\n    {\"month\": \"2021-7\", \"count\": 0},\n    {\"month\": \"2021-8\", \"count\": 4},\n    {\"month\": \"2021-9\", \"count\": 0},\n    {\"month\": \"2021-10\", \"count\": 4},\n]\n\n\nclass LocationTimelineTest(TestCase):\n    def setUp(self) -> None:\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        Photo.objects.all().delete()\n        prepare_database(self.user)\n\n    def test_location_timeline_endpoint(self):\n        response = self.client.get(\"/api/locationtimeline/\")\n        result = response.json()\n        self.assertEqual(result, expected_location_timeline)\n\n    def test_get_location_timeline(self):\n        result = get_location_timeline(self.user)\n        self.assertEqual(result, expected_location_timeline)\n\n    def test_get_photo_month_counts_endpoint(self):\n        response = self.client.get(\"/api/photomonthcounts/\")\n        result = response.json()\n        self.assertEqual(result, expected_photo_month_counts)\n\n    def test_get_photo_month_count(self):\n        result = get_photo_month_counts(self.user)\n        self.assertEqual(result, expected_photo_month_counts)\n"
  },
  {
    "path": "api/tests/test_metadata_ordering_sentinel.py",
    "content": "import os\nimport random\nimport tempfile\nimport uuid\nfrom unittest.mock import patch\n\nfrom django.test import TestCase, override_settings\n\nfrom api.models import Photo\nfrom api.tests.utils import create_test_user\n\n\ndef create_unique_png(seed=0):\n    \"\"\"\n    Generate a minimal valid PNG with unique content based on seed.\n    Each different seed produces a different hash.\n    \"\"\"\n    import struct\n\n    def png_chunk(chunk_type, data):\n        chunk_data = chunk_type + data\n        crc = 0xFFFFFFFF\n        for byte in chunk_data:\n            crc ^= byte\n            for _ in range(8):\n                crc = (crc >> 1) ^ 0xEDB88320 if crc & 1 else crc >> 1\n        crc ^= 0xFFFFFFFF\n        return struct.pack(\">I\", len(data)) + chunk_data + struct.pack(\">I\", crc)\n\n    # PNG signature\n    png_sig = b\"\\x89PNG\\r\\n\\x1a\\n\"\n\n    # IHDR: 1x1 image, 8-bit RGB\n    ihdr = struct.pack(\">IIBBBBB\", 1, 1, 8, 2, 0, 0, 0)\n\n    # IDAT: compressed image data with seed-based variation\n    idat_data = bytes([seed % 256]) + b\"\\x00\\x00\\x00\\x00\\x00\"\n    import zlib\n\n    idat_compressed = zlib.compress(idat_data)\n\n    # IEND: end of PNG\n    iend = b\"\"\n\n    return (\n        png_sig\n        + png_chunk(b\"IHDR\", ihdr)\n        + png_chunk(b\"IDAT\", idat_compressed)\n        + png_chunk(b\"IEND\", iend)\n    )\n\n\nclass DummyAsyncTask:\n    \"\"\"Synchronous replacement for django_q.tasks.AsyncTask.\n\n    - Immediately executes the callable.\n    - Tracks completion counts per group id when used for image tasks.\n    \"\"\"\n\n    GROUP_COMPLETIONS: dict[str, int] = {}\n\n    def __init__(self, func, *args, **kwargs):\n        self.func = func\n        self.args = args\n        # Extract 'group' from kwargs before passing to func (func doesn't accept it)\n        self.group_id = kwargs.pop(\"group\", None)\n        self.kwargs = kwargs\n\n    def run(self):\n        # Execute the callable synchronously (without 'group' in kwargs)\n        result = self.func(*self.args, **self.kwargs)\n\n        # If this was an image/video task scheduled with a group,\n        # increment the completion counter for that group\n        func_name = getattr(self.func, \"__name__\", \"\")\n        if self.group_id and func_name == \"handle_new_image\":\n            DummyAsyncTask.GROUP_COMPLETIONS[self.group_id] = (\n                DummyAsyncTask.GROUP_COMPLETIONS.get(self.group_id, 0) + 1\n            )\n        return result\n\n\nclass DummyChain:\n    def __init__(self, *args, **kwargs):\n        self.appended = []\n\n    def append(self, *args, **kwargs):\n        self.appended.append((args, kwargs))\n        return self\n\n    def run(self):\n        return None\n\n\nclass MetadataOrderingSentinelTest(TestCase):\n    def test_random_order_images_and_xmp_are_consistently_linked(self):\n        user = create_test_user()\n        with tempfile.TemporaryDirectory() as tmpdir:\n            user.scan_directory = tmpdir\n            user.save(update_fields=[\"scan_directory\"])\n\n            # Create N image files and corresponding XMP sidecars\n            N = 4\n            image_paths = []\n            xmp_paths = []\n            for i in range(N):\n                base = f\"img_{i}\"\n                img_path = os.path.join(tmpdir, f\"{base}.jpg\")\n                xmp_path = os.path.join(tmpdir, f\"{base}.xmp\")\n                with open(img_path, \"wb\") as f:\n                    f.write(create_unique_png(i))  # Each image has unique hash\n                with open(xmp_path, \"wb\") as f:\n                    f.write(b\"<x:xmpmeta>test</x:xmpmeta>\")\n                image_paths.append(img_path)\n                xmp_paths.append(xmp_path)\n\n            # Randomize processing order explicitly via scan_files\n            all_files = image_paths + xmp_paths\n            random.shuffle(all_files)\n\n            # Patch environment to make processing synchronous and lightweight\n            with override_settings(MEDIA_ROOT=tmpdir):\n                with (\n                    patch(\"api.directory_watcher.scan_jobs.AsyncTask\", DummyAsyncTask),\n                    patch(\"api.directory_watcher.scan_jobs.Chain\", DummyChain),\n                    patch(\n                        \"django_q.tasks.count_group\",\n                        side_effect=lambda gid: DummyAsyncTask.GROUP_COMPLETIONS.get(\n                            gid, 0\n                        ),\n                    ),\n                    patch(\n                        \"api.directory_watcher.scan_jobs.db.connections.close_all\"\n                    ) as _close_all,\n                    patch(\n                        \"api.directory_watcher.scan_jobs.update_scan_counter\"\n                    ) as _update_counter,\n                    patch(\"api.directory_watcher.scan_jobs.util.logger\") as _logger,\n                    patch(\"pyvips.Image.thumbnail\") as _thumb,\n                    patch(\n                        \"api.models.thumbnail.Thumbnail._generate_thumbnail\"\n                    ) as _gen_thumb,\n                    patch(\n                        \"api.models.thumbnail.Thumbnail._calculate_aspect_ratio\"\n                    ) as _calc_ar,\n                    patch(\n                        \"api.models.thumbnail.Thumbnail._get_dominant_color\"\n                    ) as _dom_color,\n                    patch(\n                        \"api.models.photo_metadata.PhotoMetadata.extract_exif_data\"\n                    ) as _exif,\n                    patch(\n                        \"api.models.photo.Photo._extract_date_time_from_exif\"\n                    ) as _exif_dt,\n                ):\n                    # No-op patches\n                    _thumb.return_value = None\n                    _close_all.return_value = None\n                    _update_counter.side_effect = lambda *_args, **_kwargs: None\n                    _logger.info.side_effect = lambda *_a, **_k: None\n                    _logger.warning.side_effect = lambda *_a, **_k: None\n                    _logger.exception.side_effect = lambda *_a, **_k: None\n                    _gen_thumb.return_value = None\n                    _calc_ar.return_value = None\n                    _dom_color.return_value = None\n                    _exif.return_value = None\n                    _exif_dt.return_value = None\n\n                    job_id = str(uuid.uuid4())\n                    # Emulate the core of scan_photos sequencing explicitly:\n                    # 1) Enqueue all images/videos in a group and run them synchronously\n                    # 2) Run the sentinel to process metadata after the group completes\n                    from api.directory_watcher import (\n                        handle_new_image,\n                        wait_for_group_and_process_metadata,\n                    )\n\n                    image_group_id = str(uuid.uuid4())\n                    for img in image_paths:\n                        DummyAsyncTask(\n                            handle_new_image, user, img, job_id, group=image_group_id\n                        ).run()\n\n                    DummyAsyncTask(\n                        wait_for_group_and_process_metadata,\n                        image_group_id,\n                        xmp_paths,\n                        user.id,\n                        False,\n                        job_id,\n                        len(image_paths),\n                    ).run()\n\n            # Validate: image tasks ran and each image must have its XMP associated to the same Photo\n            total_completions = sum(DummyAsyncTask.GROUP_COMPLETIONS.values())\n            self.assertEqual(\n                total_completions,\n                N,\n                msg=f\"Expected {N} image task completions, got {total_completions}\",\n            )\n\n            photos = list(Photo.objects.all())\n            self.assertEqual(\n                len(photos), N, msg=\"All images should produce Photo objects\"\n            )\n\n            # Build a map from image base name to whether an XMP is linked\n            linked = {}\n            for p in photos:\n                # main_file.path is the image path\n                main_path = p.main_file.path if p.main_file else \"\"\n                base = os.path.splitext(os.path.basename(main_path))[0]\n                xmp_list = list(\n                    p.files.filter(path__endswith=\".xmp\").values_list(\"path\", flat=True)\n                )\n                linked[base] = len(xmp_list) >= 1\n\n            # All should be True\n            self.assertTrue(\n                all(linked.values()), msg=f\"Some photos missing XMP: {linked}\"\n            )\n"
  },
  {
    "path": "api/tests/test_migration_0099.py",
    "content": "\"\"\"\nTests for migration 0099_photo_uuid_primary_key.\n\nVerifies the UUID primary key migration works correctly on both\nPostgreSQL (raw SQL) and SQLite (table recreation pattern).\n\nStrategy:\n- Migration 0099 is irreversible, so we cannot use the standard\n  \"roll back → seed → migrate forward\" pattern.\n- Instead we build a standalone in-memory SQLite database with the\n  pre-migration schema, seed data, run the migration function\n  directly, and verify the result (TestSQLiteMigration0099).\n- We also verify the post-migration schema on the live Django test DB\n  (which already had 0099 applied during test-database creation)\n  in TestPostMigrationSchema.\n- Helper functions are tested in isolation in TestSQLiteHelpers.\n\"\"\"\n\nimport sqlite3\nimport uuid\nfrom importlib import import_module\nfrom unittest.mock import MagicMock, patch\n\nfrom django.db import connection\nfrom django.test import TestCase, TransactionTestCase\n\n# Import the migration module (name starts with a digit, use importlib)\n_mod = import_module(\"api.migrations.0099_photo_uuid_primary_key\")\n\n\n# ============================================================================\n# Helpers\n# ============================================================================\n\ndef _sqlite_table_exists(cursor, table_name):\n    cursor.execute(\n        \"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?\",\n        [table_name],\n    )\n    return cursor.fetchone()[0] > 0\n\n\ndef _sqlite_column_names(cursor, table_name):\n    cursor.execute(f'PRAGMA table_info(\"{table_name}\")')\n    return {row[1] for row in cursor.fetchall()}\n\n\ndef _sqlite_pk_columns(cursor, table_name):\n    cursor.execute(f'PRAGMA table_info(\"{table_name}\")')\n    return [row[1] for row in cursor.fetchall() if row[5]]\n\n\ndef _sqlite_index_exists(cursor, index_name):\n    cursor.execute(\n        \"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name=?\",\n        [index_name],\n    )\n    return cursor.fetchone()[0] > 0\n\n\n# ============================================================================\n# Pre-migration schema builder  (mimics the tables that exist at 0098)\n# ============================================================================\n\n_PRE_MIGRATION_DDL = \"\"\"\nCREATE TABLE api_photo (\n    image_hash VARCHAR(64) NOT NULL PRIMARY KEY,\n    hidden     INTEGER NOT NULL DEFAULT 0,\n    rating     INTEGER NOT NULL DEFAULT 0,\n    deleted    INTEGER NOT NULL DEFAULT 0,\n    video      INTEGER NOT NULL DEFAULT 0\n);\n\nCREATE TABLE api_face (\n    id                           INTEGER PRIMARY KEY AUTOINCREMENT,\n    photo_id                     VARCHAR(64) NOT NULL REFERENCES api_photo(image_hash),\n    location_top                 INTEGER NOT NULL DEFAULT 0,\n    location_bottom              INTEGER NOT NULL DEFAULT 0,\n    location_left                INTEGER NOT NULL DEFAULT 0,\n    location_right               INTEGER NOT NULL DEFAULT 0,\n    deleted                      INTEGER NOT NULL DEFAULT 0,\n    classification_probability   REAL NOT NULL DEFAULT 0.0,\n    cluster_probability          REAL NOT NULL DEFAULT 0.0\n);\n\nCREATE TABLE api_thumbnail (\n    photo_id     VARCHAR(64) NOT NULL PRIMARY KEY REFERENCES api_photo(image_hash),\n    aspect_ratio REAL\n);\n\nCREATE TABLE api_photo_caption (\n    photo_id VARCHAR(64) NOT NULL PRIMARY KEY REFERENCES api_photo(image_hash)\n);\n\nCREATE TABLE api_photo_search (\n    photo_id VARCHAR(64) NOT NULL PRIMARY KEY REFERENCES api_photo(image_hash)\n);\n\nCREATE TABLE api_person (\n    id             INTEGER PRIMARY KEY AUTOINCREMENT,\n    name           TEXT NOT NULL,\n    kind           TEXT NOT NULL DEFAULT 'USER',\n    cover_photo_id VARCHAR(64) REFERENCES api_photo(image_hash),\n    face_count     INTEGER NOT NULL DEFAULT 0\n);\n\nCREATE TABLE api_albumuser (\n    id             INTEGER PRIMARY KEY AUTOINCREMENT,\n    title          TEXT,\n    cover_photo_id VARCHAR(64) REFERENCES api_photo(image_hash)\n);\n\nCREATE TABLE api_photo_shared_to (\n    id       INTEGER PRIMARY KEY AUTOINCREMENT,\n    photo_id VARCHAR(64) NOT NULL REFERENCES api_photo(image_hash),\n    user_id  INTEGER NOT NULL\n);\n\nCREATE TABLE api_photo_files (\n    id       INTEGER PRIMARY KEY AUTOINCREMENT,\n    photo_id VARCHAR(64) NOT NULL REFERENCES api_photo(image_hash),\n    file_id  INTEGER NOT NULL\n);\n\nCREATE TABLE api_albumuser_photos (\n    id             INTEGER PRIMARY KEY AUTOINCREMENT,\n    albumuser_id   INTEGER NOT NULL,\n    photo_id       VARCHAR(64) NOT NULL REFERENCES api_photo(image_hash)\n);\n\nCREATE TABLE api_albumthing_photos (\n    id              INTEGER PRIMARY KEY AUTOINCREMENT,\n    albumthing_id   INTEGER NOT NULL,\n    photo_id        VARCHAR(64) NOT NULL REFERENCES api_photo(image_hash)\n);\n\nCREATE TABLE api_albumplace_photos (\n    id              INTEGER PRIMARY KEY AUTOINCREMENT,\n    albumplace_id   INTEGER NOT NULL,\n    photo_id        VARCHAR(64) NOT NULL REFERENCES api_photo(image_hash)\n);\n\nCREATE TABLE api_albumdate_photos (\n    id             INTEGER PRIMARY KEY AUTOINCREMENT,\n    albumdate_id   INTEGER NOT NULL,\n    photo_id       VARCHAR(64) NOT NULL REFERENCES api_photo(image_hash)\n);\n\nCREATE TABLE api_albumauto_photos (\n    id             INTEGER PRIMARY KEY AUTOINCREMENT,\n    albumauto_id   INTEGER NOT NULL,\n    photo_id       VARCHAR(64) NOT NULL REFERENCES api_photo(image_hash)\n);\n\nCREATE TABLE api_albumthing_cover_photos (\n    id              INTEGER PRIMARY KEY AUTOINCREMENT,\n    albumthing_id   INTEGER NOT NULL,\n    photo_id        VARCHAR(64) NOT NULL REFERENCES api_photo(image_hash)\n);\n\nCREATE TABLE api_photostack (\n    id               TEXT PRIMARY KEY,\n    primary_photo_id VARCHAR(64) REFERENCES api_photo(image_hash)\n);\n\nCREATE INDEX api_face_photo_id_old ON api_face(photo_id);\n\"\"\"\n\n\ndef _build_test_db():\n    \"\"\"Create an in-memory SQLite DB with the pre-migration schema and seed data.\n\n    Returns (connection, hashes) where hashes is a list of image_hash values.\n    \"\"\"\n    conn = sqlite3.connect(\":memory:\")\n    cur = conn.cursor()\n    cur.executescript(_PRE_MIGRATION_DDL)\n\n    hashes = [f\"hash_{i:032d}\" for i in range(1, 4)]\n    for h in hashes:\n        cur.execute(\n            \"INSERT INTO api_photo (image_hash) VALUES (?)\", [h]\n        )\n\n    # face referencing photo 0\n    cur.execute(\n        \"INSERT INTO api_face (photo_id, location_top, location_bottom, \"\n        \"location_left, location_right, deleted, classification_probability, \"\n        \"cluster_probability) VALUES (?, 0, 100, 0, 100, 0, 0.5, 0.5)\",\n        [hashes[0]],\n    )\n    # second face referencing photo 1\n    cur.execute(\n        \"INSERT INTO api_face (photo_id, location_top, location_bottom, \"\n        \"location_left, location_right, deleted, classification_probability, \"\n        \"cluster_probability) VALUES (?, 10, 200, 10, 200, 0, 0.8, 0.3)\",\n        [hashes[1]],\n    )\n    # thumbnail for photo 0\n    cur.execute(\"INSERT INTO api_thumbnail (photo_id, aspect_ratio) VALUES (?, 1.5)\", [hashes[0]])\n    # caption for photo 1\n    cur.execute(\"INSERT INTO api_photo_caption (photo_id) VALUES (?)\", [hashes[1]])\n    # search for photo 2\n    cur.execute(\"INSERT INTO api_photo_search (photo_id) VALUES (?)\", [hashes[2]])\n    # person with cover_photo\n    cur.execute(\"INSERT INTO api_person (name, cover_photo_id) VALUES (?, ?)\", [\"Alice\", hashes[0]])\n    # album user with cover_photo\n    cur.execute(\"INSERT INTO api_albumuser (title, cover_photo_id) VALUES (?, ?)\", [\"My Album\", hashes[1]])\n    # M2M entries\n    cur.execute(\"INSERT INTO api_photo_shared_to (photo_id, user_id) VALUES (?, 1)\", [hashes[0]])\n    cur.execute(\"INSERT INTO api_albumuser_photos (albumuser_id, photo_id) VALUES (1, ?)\", [hashes[0]])\n    cur.execute(\"INSERT INTO api_albumthing_photos (albumthing_id, photo_id) VALUES (1, ?)\", [hashes[1]])\n    cur.execute(\"INSERT INTO api_albumplace_photos (albumplace_id, photo_id) VALUES (1, ?)\", [hashes[2]])\n    cur.execute(\"INSERT INTO api_albumdate_photos (albumdate_id, photo_id) VALUES (1, ?)\", [hashes[0]])\n    cur.execute(\"INSERT INTO api_albumauto_photos (albumauto_id, photo_id) VALUES (1, ?)\", [hashes[1]])\n    cur.execute(\"INSERT INTO api_albumthing_cover_photos (albumthing_id, photo_id) VALUES (1, ?)\", [hashes[2]])\n    cur.execute(\"INSERT INTO api_photostack (id, primary_photo_id) VALUES (?, ?)\", [\"stack-1\", hashes[0]])\n\n    conn.commit()\n    return conn, hashes\n\n\ndef _run_migration_on(sqlite_conn):\n    \"\"\"Run the SQLite migration path on the given raw sqlite3 connection.\"\"\"\n    # _migrate_sqlite expects a Django-like schema_editor with\n    # .connection.cursor() returning something with .execute/.fetchall.\n    # We wrap the raw sqlite3 connection to match.\n    class _CursorWrapper:\n        \"\"\"Thin adapter so _migrate_sqlite can call cursor.execute(sql, params).\"\"\"\n        def __init__(self, raw_cursor):\n            self._cur = raw_cursor\n        def execute(self, sql, params=None):\n            if params is None:\n                return self._cur.execute(sql)\n            return self._cur.execute(sql, params)\n        def fetchall(self):\n            return self._cur.fetchall()\n        def fetchone(self):\n            return self._cur.fetchone()\n\n    class _ConnWrapper:\n        def __init__(self, raw_conn):\n            self._conn = raw_conn\n            self.vendor = \"sqlite\"\n        def cursor(self):\n            return _CursorWrapper(self._conn.cursor())\n\n    class _SchemaEditor:\n        def __init__(self, raw_conn):\n            self.connection = _ConnWrapper(raw_conn)\n\n    _mod._migrate_sqlite(_SchemaEditor(sqlite_conn))\n    sqlite_conn.commit()\n\n\n# ============================================================================\n# Test: Full end-to-end SQLite migration\n# ============================================================================\n\nclass TestSQLiteMigration0099(TestCase):\n    \"\"\"\n    End-to-end test of the SQLite migration path.\n\n    Creates a standalone in-memory SQLite database with the pre-0099 schema,\n    seeds test data, runs `_migrate_sqlite`, and verifies all changes.\n    \"\"\"\n\n    @classmethod\n    def setUpClass(cls):\n        super().setUpClass()\n        cls.conn, cls.hashes = _build_test_db()\n        _run_migration_on(cls.conn)\n\n    @classmethod\n    def tearDownClass(cls):\n        cls.conn.close()\n        super().tearDownClass()\n\n    def _cursor(self):\n        return self.conn.cursor()\n\n    # -- schema assertions -------------------------------------------------\n\n    def test_photo_has_id_column(self):\n        cur = self._cursor()\n        cols = _sqlite_column_names(cur, \"api_photo\")\n        self.assertIn(\"id\", cols)\n\n    def test_photo_has_image_hash_column(self):\n        cur = self._cursor()\n        cols = _sqlite_column_names(cur, \"api_photo\")\n        self.assertIn(\"image_hash\", cols)\n\n    def test_photo_pk_is_id(self):\n        cur = self._cursor()\n        pk = _sqlite_pk_columns(cur, \"api_photo\")\n        self.assertEqual(pk, [\"id\"])\n\n    def test_image_hash_unique_index(self):\n        cur = self._cursor()\n        self.assertTrue(_sqlite_index_exists(cur, \"api_photo_image_hash_unique\"))\n\n    def test_performance_indexes(self):\n        expected = [\n            \"api_face_photo_id_idx\",\n            \"api_photo_shared_to_photo_id_idx\",\n            \"api_photo_files_photo_id_idx\",\n            \"api_person_cover_photo_id_idx\",\n            \"api_albumuser_cover_photo_id_idx\",\n            \"api_photostack_primary_photo_id_idx\",\n        ]\n        cur = self._cursor()\n        for idx in expected:\n            self.assertTrue(\n                _sqlite_index_exists(cur, idx),\n                f\"Missing index: {idx}\",\n            )\n\n    # -- data assertions ---------------------------------------------------\n\n    def test_all_photos_have_valid_uuids(self):\n        cur = self._cursor()\n        cur.execute('SELECT \"id\" FROM api_photo')\n        rows = cur.fetchall()\n        self.assertEqual(len(rows), 3)\n        for (photo_id,) in rows:\n            uuid.UUID(photo_id)  # will raise if invalid\n\n    def test_image_hashes_preserved(self):\n        cur = self._cursor()\n        cur.execute('SELECT \"image_hash\" FROM api_photo ORDER BY image_hash')\n        actual = [r[0] for r in cur.fetchall()]\n        self.assertEqual(actual, sorted(self.hashes))\n\n    def test_each_photo_has_distinct_uuid(self):\n        cur = self._cursor()\n        cur.execute('SELECT \"id\" FROM api_photo')\n        ids = [r[0] for r in cur.fetchall()]\n        self.assertEqual(len(ids), len(set(ids)))\n\n    def test_face_fk_translated(self):\n        \"\"\"Faces should join to photos via the new UUID id.\"\"\"\n        cur = self._cursor()\n        cur.execute(\n            'SELECT f.photo_id, p.id FROM api_face f '\n            'JOIN api_photo p ON f.photo_id = p.id'\n        )\n        rows = cur.fetchall()\n        self.assertEqual(len(rows), 2)\n        for fk, pk in rows:\n            self.assertEqual(fk, pk)\n            uuid.UUID(fk)\n\n    def test_no_orphan_faces(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT COUNT(*) FROM api_face f '\n            'LEFT JOIN api_photo p ON f.photo_id = p.id '\n            'WHERE p.id IS NULL'\n        )\n        self.assertEqual(cur.fetchone()[0], 0)\n\n    def test_thumbnail_fk_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT t.photo_id, p.id FROM api_thumbnail t '\n            'JOIN api_photo p ON t.photo_id = p.id'\n        )\n        rows = cur.fetchall()\n        self.assertEqual(len(rows), 1)\n        self.assertEqual(rows[0][0], rows[0][1])\n\n    def test_photo_caption_fk_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT c.photo_id, p.id FROM api_photo_caption c '\n            'JOIN api_photo p ON c.photo_id = p.id'\n        )\n        rows = cur.fetchall()\n        self.assertEqual(len(rows), 1)\n\n    def test_photo_search_fk_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT s.photo_id, p.id FROM api_photo_search s '\n            'JOIN api_photo p ON s.photo_id = p.id'\n        )\n        rows = cur.fetchall()\n        self.assertEqual(len(rows), 1)\n\n    def test_person_cover_photo_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT per.cover_photo_id, p.id FROM api_person per '\n            'JOIN api_photo p ON per.cover_photo_id = p.id'\n        )\n        rows = cur.fetchall()\n        self.assertEqual(len(rows), 1)\n        uuid.UUID(rows[0][0])\n\n    def test_albumuser_cover_photo_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT a.cover_photo_id, p.id FROM api_albumuser a '\n            'JOIN api_photo p ON a.cover_photo_id = p.id'\n        )\n        rows = cur.fetchall()\n        self.assertEqual(len(rows), 1)\n\n    def test_m2m_shared_to_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT s.photo_id, p.id FROM api_photo_shared_to s '\n            'JOIN api_photo p ON s.photo_id = p.id'\n        )\n        self.assertEqual(len(cur.fetchall()), 1)\n\n    def test_m2m_albumuser_photos_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT a.photo_id, p.id FROM api_albumuser_photos a '\n            'JOIN api_photo p ON a.photo_id = p.id'\n        )\n        self.assertEqual(len(cur.fetchall()), 1)\n\n    def test_m2m_albumthing_photos_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT a.photo_id, p.id FROM api_albumthing_photos a '\n            'JOIN api_photo p ON a.photo_id = p.id'\n        )\n        self.assertEqual(len(cur.fetchall()), 1)\n\n    def test_m2m_albumplace_photos_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT a.photo_id, p.id FROM api_albumplace_photos a '\n            'JOIN api_photo p ON a.photo_id = p.id'\n        )\n        self.assertEqual(len(cur.fetchall()), 1)\n\n    def test_m2m_albumdate_photos_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT a.photo_id, p.id FROM api_albumdate_photos a '\n            'JOIN api_photo p ON a.photo_id = p.id'\n        )\n        self.assertEqual(len(cur.fetchall()), 1)\n\n    def test_m2m_albumauto_photos_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT a.photo_id, p.id FROM api_albumauto_photos a '\n            'JOIN api_photo p ON a.photo_id = p.id'\n        )\n        self.assertEqual(len(cur.fetchall()), 1)\n\n    def test_albumthing_cover_photos_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT a.photo_id, p.id FROM api_albumthing_cover_photos a '\n            'JOIN api_photo p ON a.photo_id = p.id'\n        )\n        self.assertEqual(len(cur.fetchall()), 1)\n\n    def test_photostack_primary_photo_translated(self):\n        cur = self._cursor()\n        cur.execute(\n            'SELECT s.primary_photo_id, p.id FROM api_photostack s '\n            'JOIN api_photo p ON s.primary_photo_id = p.id'\n        )\n        rows = cur.fetchall()\n        self.assertEqual(len(rows), 1)\n        uuid.UUID(rows[0][0])\n\n\n# ============================================================================\n# Test: Post-migration schema on the live Django test database\n# ============================================================================\n\nclass TestPostMigrationSchema(TestCase):\n    \"\"\"\n    Verify the Django test DB (where migration 0099 already ran during\n    test database creation) has the expected post-migration schema.\n\n    This validates that the migration ran successfully on whatever backend\n    the test suite is configured with (SQLite via test_sqlite settings).\n    \"\"\"\n\n    def test_photo_table_has_id_and_image_hash(self):\n        with connection.cursor() as cursor:\n            if connection.vendor == \"sqlite\":\n                cursor.execute('PRAGMA table_info(\"api_photo\")')\n                cols = {row[1] for row in cursor.fetchall()}\n            else:\n                cursor.execute(\n                    \"SELECT column_name FROM information_schema.columns \"\n                    \"WHERE table_name = 'api_photo'\"\n                )\n                cols = {row[0] for row in cursor.fetchall()}\n        self.assertIn(\"id\", cols)\n        self.assertIn(\"image_hash\", cols)\n\n    def test_photo_pk_is_uuid_field(self):\n        \"\"\"Verify Django's ORM sees the PK as a UUID field named 'id'.\"\"\"\n        from api.models import Photo\n        pk_field = Photo._meta.pk\n        self.assertEqual(pk_field.name, \"id\")\n        self.assertIsInstance(pk_field, __import__(\"django\").db.models.UUIDField)\n\n\n# ============================================================================\n# Test: Dispatch and reverse logic\n# ============================================================================\n\nclass TestMigrationDispatch(TestCase):\n    \"\"\"Test migrate_forward dispatch and migrate_reverse error.\"\"\"\n\n    def test_dispatches_to_sqlite(self):\n        mock_editor = MagicMock()\n        mock_editor.connection.vendor = \"sqlite\"\n        with patch.object(_mod, \"_migrate_sqlite\") as mock_fn:\n            _mod.migrate_forward(MagicMock(), mock_editor)\n            mock_fn.assert_called_once_with(mock_editor)\n\n    def test_dispatches_to_postgresql(self):\n        mock_editor = MagicMock()\n        mock_editor.connection.vendor = \"postgresql\"\n        with patch.object(_mod, \"_migrate_postgresql\") as mock_fn:\n            _mod.migrate_forward(MagicMock(), mock_editor)\n            mock_fn.assert_called_once_with(mock_editor)\n\n    def test_rejects_unknown_backend(self):\n        mock_editor = MagicMock()\n        mock_editor.connection.vendor = \"oracle\"\n        with self.assertRaises(ValueError):\n            _mod.migrate_forward(MagicMock(), mock_editor)\n\n    def test_reverse_raises_runtime_error(self):\n        with self.assertRaises(RuntimeError):\n            _mod.migrate_reverse(MagicMock(), MagicMock())\n\n\n# ============================================================================\n# Test: SQLite helper functions in isolation\n# ============================================================================\n\nclass TestSQLiteHelpers(TestCase):\n    \"\"\"Unit tests for the individual SQLite helper functions.\"\"\"\n\n    def test_column_info(self):\n        if connection.vendor != \"sqlite\":\n            self.skipTest(\"SQLite-specific\")\n        with connection.cursor() as cur:\n            cur.execute(\n                \"CREATE TABLE IF NOT EXISTS _t_col \"\n                \"(pk INTEGER PRIMARY KEY, name TEXT NOT NULL, val REAL)\"\n            )\n            cols = _mod._sqlite_column_info(cur, \"_t_col\")\n            cur.execute(\"DROP TABLE IF EXISTS _t_col\")\n        self.assertEqual({c[1] for c in cols}, {\"pk\", \"name\", \"val\"})\n\n    def test_index_info(self):\n        if connection.vendor != \"sqlite\":\n            self.skipTest(\"SQLite-specific\")\n        with connection.cursor() as cur:\n            cur.execute(\"CREATE TABLE IF NOT EXISTS _t_idx (a TEXT, b TEXT)\")\n            cur.execute(\"CREATE INDEX IF NOT EXISTS _t_idx_a ON _t_idx(a)\")\n            idxs = _mod._sqlite_index_info(cur, \"_t_idx\")\n            cur.execute(\"DROP TABLE IF EXISTS _t_idx\")\n        self.assertIn(\"_t_idx_a\", [i[0] for i in idxs])\n\n    def test_recreate_table_changes_pk(self):\n        if connection.vendor != \"sqlite\":\n            self.skipTest(\"SQLite-specific\")\n        with connection.cursor() as cur:\n            cur.execute(\n                \"CREATE TABLE _t_rec (old_pk TEXT PRIMARY KEY, new_pk TEXT, data TEXT)\"\n            )\n            cur.execute(\"INSERT INTO _t_rec VALUES ('h1', 'u1', 'a')\")\n            cur.execute(\"INSERT INTO _t_rec VALUES ('h2', 'u2', 'b')\")\n\n            _mod._sqlite_recreate_table(\n                cur, \"_t_rec\", pk_column=\"new_pk\",\n                column_overrides={\n                    \"new_pk\": '\"new_pk\" TEXT NOT NULL',\n                    \"old_pk\": '\"old_pk\" TEXT NOT NULL UNIQUE',\n                },\n            )\n            pk = _sqlite_pk_columns(cur, \"_t_rec\")\n            self.assertEqual(pk, [\"new_pk\"])\n            cur.execute(\"SELECT old_pk, new_pk, data FROM _t_rec ORDER BY old_pk\")\n            self.assertEqual(cur.fetchall(), [(\"h1\", \"u1\", \"a\"), (\"h2\", \"u2\", \"b\")])\n            cur.execute(\"DROP TABLE _t_rec\")\n\n    def test_update_fk_table_translates_values(self):\n        if connection.vendor != \"sqlite\":\n            self.skipTest(\"SQLite-specific\")\n        with connection.cursor() as cur:\n            cur.execute(\"CREATE TABLE _t_parent (id TEXT PRIMARY KEY)\")\n            cur.execute(\"INSERT INTO _t_parent VALUES ('uuid-a')\")\n            cur.execute(\n                \"CREATE TABLE _t_child (id INTEGER PRIMARY KEY, fk TEXT, info TEXT)\"\n            )\n            cur.execute(\"INSERT INTO _t_child (fk, info) VALUES ('old', 'r1')\")\n            cur.execute(\"INSERT INTO _t_child (fk, info) VALUES ('old', 'r2')\")\n\n            _mod._sqlite_update_fk_table(cur, \"_t_child\", \"fk\", {\"old\": \"uuid-a\"})\n\n            cur.execute(\"SELECT fk FROM _t_child\")\n            self.assertTrue(all(r[0] == \"uuid-a\" for r in cur.fetchall()))\n            cur.execute(\"DROP TABLE _t_child\")\n            cur.execute(\"DROP TABLE _t_parent\")\n\n    def test_update_fk_table_skips_missing_table(self):\n        if connection.vendor != \"sqlite\":\n            self.skipTest(\"SQLite-specific\")\n        with connection.cursor() as cur:\n            # Should not raise\n            _mod._sqlite_update_fk_table(cur, \"_no_such_table_999\", \"col\", {\"a\": \"b\"})\n"
  },
  {
    "path": "api/tests/test_migration_0101.py",
    "content": "\"\"\"\nTest for migration 0101_populate_photo_metadata to ensure it works correctly.\n\nThis test verifies:\n1. The fix for the PostgreSQL error: \"operator does not exist: uuid = character varying\"\n2. The fix for the SQLite issue: cursor-during-writes conflict with iterator() + writes\n\"\"\"\nfrom importlib import import_module\n\nfrom django.db import models\nfrom django.db.models import Exists, OuterRef, Subquery\nfrom django.test import TestCase\n\nfrom api.models import Photo\nfrom api.models.photo_caption import PhotoCaption\nfrom api.models.photo_metadata import PhotoMetadata\nfrom api.tests.utils import create_test_photo, create_test_user\n\n_migration = import_module(\"api.migrations.0101_populate_photo_metadata\")\nBATCH_SIZE = _migration.BATCH_SIZE\n\n\nclass Migration0101TestCase(TestCase):\n    \"\"\"Test the migration logic for populating PhotoMetadata.\"\"\"\n\n    def setUp(self):\n        \"\"\"Set up test data.\"\"\"\n        self.user = create_test_user()\n\n    def test_subquery_with_uuid_primary_key(self):\n        \"\"\"\n        Test that the subquery correctly references the UUID primary key.\n        \n        This test ensures the fix for the PostgreSQL error where\n        photo_id (UUID) was incorrectly compared with image_hash (VARCHAR).\n        \"\"\"\n        # Create a photo with caption\n        photo = create_test_photo(\n            owner=self.user,\n            captions_json={\"user_caption\": \"Test caption\", \"keywords\": [\"test\"]},\n        )\n\n        # Verify the caption was created\n        self.assertTrue(PhotoCaption.objects.filter(photo=photo).exists())\n\n        # Test the subquery pattern from the migration (FIXED version)\n        caption_subquery = PhotoCaption.objects.filter(\n            photo_id=OuterRef('pk')  # Using 'pk' (UUID) instead of 'image_hash' (VARCHAR)\n        ).values('captions_json')[:1]\n\n        # Query photos with the subquery annotation\n        photos = Photo.objects.annotate(\n            captions_data=Subquery(caption_subquery)\n        ).filter(pk=photo.pk)\n\n        # Verify the query works without PostgreSQL type errors\n        self.assertEqual(photos.count(), 1)\n        photo_with_caption = photos.first()\n        self.assertIsNotNone(photo_with_caption.captions_data)\n        self.assertEqual(\n            photo_with_caption.captions_data.get(\"user_caption\"),\n            \"Test caption\"\n        )\n\n    def test_migration_logic_creates_metadata(self):\n        \"\"\"\n        Test the full migration logic to ensure PhotoMetadata is populated.\n        \"\"\"\n        # Create photos without metadata\n        photo1 = create_test_photo(\n            owner=self.user,\n            captions_json={\"user_caption\": \"First photo\"},\n        )\n        photo2 = create_test_photo(\n            owner=self.user,\n            captions_json={\"user_caption\": \"Second photo\", \"keywords\": [\"tag1\", \"tag2\"]},\n        )\n\n        # Manually delete any metadata that might have been auto-created\n        PhotoMetadata.objects.filter(photo__in=[photo1, photo2]).delete()\n\n        # Verify no metadata exists\n        self.assertFalse(PhotoMetadata.objects.filter(photo=photo1).exists())\n        self.assertFalse(PhotoMetadata.objects.filter(photo=photo2).exists())\n\n        # Simulate the migration logic\n        caption_subquery = PhotoCaption.objects.filter(\n            photo_id=OuterRef('pk')\n        ).values('captions_json')[:1]\n\n        existing_metadata = PhotoMetadata.objects.filter(photo_id=OuterRef('pk'))\n\n        photos = Photo.objects.filter(\n            ~models.Exists(existing_metadata)\n        ).annotate(\n            captions_data=Subquery(caption_subquery)\n        )\n\n        # Create PhotoMetadata for each photo\n        for photo in photos:\n            captions_json = photo.captions_data\n            PhotoMetadata.objects.create(\n                photo=photo,\n                caption=captions_json.get(\"user_caption\") if captions_json else None,\n                keywords=list(captions_json.get(\"keywords\", [])) if captions_json else [],\n                source=\"embedded\",\n                version=1,\n            )\n\n        # Verify metadata was created\n        self.assertTrue(PhotoMetadata.objects.filter(photo=photo1).exists())\n        self.assertTrue(PhotoMetadata.objects.filter(photo=photo2).exists())\n\n        # Verify caption data was correctly populated\n        metadata1 = PhotoMetadata.objects.get(photo=photo1)\n        self.assertEqual(metadata1.caption, \"First photo\")\n\n        metadata2 = PhotoMetadata.objects.get(photo=photo2)\n        self.assertEqual(metadata2.caption, \"Second photo\")\n        self.assertEqual(metadata2.keywords, [\"tag1\", \"tag2\"])\n\n    def test_batch_processing_without_iterator(self):\n        \"\"\"\n        Test the fixed migration approach: fetch IDs upfront, process in batches.\n\n        This tests the SQLite-compatible pattern where:\n        - All photo IDs are collected first (no open cursor during writes)\n        - Caption data is loaded per-batch\n        - No iterator() is used alongside writes\n        - Photos with pre-existing metadata are correctly excluded\n        \"\"\"\n        photo1 = create_test_photo(\n            owner=self.user,\n            captions_json={\"user_caption\": \"Batch photo 1\", \"keywords\": [\"a\"]},\n        )\n        photo2 = create_test_photo(\n            owner=self.user,\n            captions_json={\"user_caption\": \"Batch photo 2\", \"keywords\": [\"b\", \"c\"]},\n        )\n        photo3 = create_test_photo(owner=self.user)  # no caption\n\n        # photo_already_done has pre-existing metadata — should be excluded\n        photo_already_done = create_test_photo(owner=self.user)\n        PhotoMetadata.objects.filter(photo=photo_already_done).delete()\n        existing_meta = PhotoMetadata.objects.create(\n            photo=photo_already_done,\n            caption=\"pre-existing\",\n            source=\"embedded\",\n            version=1,\n        )\n\n        PhotoMetadata.objects.filter(photo__in=[photo1, photo2, photo3]).delete()\n\n        # --- Simulate the fixed migration approach ---\n        existing_metadata = PhotoMetadata.objects.filter(photo_id=OuterRef('pk'))\n        photo_ids = list(\n            Photo.objects\n            .filter(~Exists(existing_metadata))\n            .values_list('pk', flat=True)\n        )\n\n        all_batch = []\n        for chunk_start in range(0, len(photo_ids), BATCH_SIZE):\n            chunk_ids = photo_ids[chunk_start:chunk_start + BATCH_SIZE]\n            captions = {\n                c.photo_id: c.captions_json\n                for c in PhotoCaption.objects.filter(photo_id__in=chunk_ids)\n            }\n            batch = []\n            for photo in Photo.objects.filter(pk__in=chunk_ids):\n                captions_json = captions.get(photo.pk)\n                batch.append(PhotoMetadata(\n                    photo=photo,\n                    caption=captions_json.get(\"user_caption\") if captions_json else None,\n                    keywords=list(captions_json.get(\"keywords\", [])) if captions_json else [],\n                    source=\"embedded\",\n                    version=1,\n                ))\n            PhotoMetadata.objects.bulk_create(batch, ignore_conflicts=True)\n            all_batch.extend(batch)\n\n        # All three photos must have metadata\n        self.assertTrue(PhotoMetadata.objects.filter(photo=photo1).exists())\n        self.assertTrue(PhotoMetadata.objects.filter(photo=photo2).exists())\n        self.assertTrue(PhotoMetadata.objects.filter(photo=photo3).exists())\n\n        m1 = PhotoMetadata.objects.get(photo=photo1)\n        self.assertEqual(m1.caption, \"Batch photo 1\")\n        self.assertEqual(m1.keywords, [\"a\"])\n\n        m2 = PhotoMetadata.objects.get(photo=photo2)\n        self.assertEqual(m2.caption, \"Batch photo 2\")\n        self.assertEqual(m2.keywords, [\"b\", \"c\"])\n\n        m3 = PhotoMetadata.objects.get(photo=photo3)\n        self.assertIsNone(m3.caption)\n        self.assertEqual(m3.keywords, [])\n\n        # photo_already_done must NOT have been re-processed — exactly one record,\n        # the pre-existing one, and its caption must still be the original value.\n        self.assertEqual(PhotoMetadata.objects.filter(photo=photo_already_done).count(), 1)\n        existing_meta.refresh_from_db()\n        self.assertEqual(existing_meta.caption, \"pre-existing\")\n\n    def test_batch_processing_is_idempotent(self):\n        \"\"\"\n        Running the batch-based migration approach twice should not duplicate records.\n        \"\"\"\n        photo = create_test_photo(\n            owner=self.user,\n            captions_json={\"user_caption\": \"Idempotent test\"},\n        )\n        PhotoMetadata.objects.filter(photo=photo).delete()\n\n        def run_migration_logic():\n            existing_metadata = PhotoMetadata.objects.filter(photo_id=OuterRef('pk'))\n            photo_ids = list(\n                Photo.objects\n                .filter(~Exists(existing_metadata))\n                .values_list('pk', flat=True)\n            )\n            captions = {\n                c.photo_id: c.captions_json\n                for c in PhotoCaption.objects.filter(photo_id__in=photo_ids)\n            }\n            batch = []\n            for p in Photo.objects.filter(pk__in=photo_ids):\n                captions_json = captions.get(p.pk)\n                batch.append(PhotoMetadata(\n                    photo=p,\n                    caption=captions_json.get(\"user_caption\") if captions_json else None,\n                    keywords=list(captions_json.get(\"keywords\", [])) if captions_json else [],\n                    source=\"embedded\",\n                    version=1,\n                ))\n            PhotoMetadata.objects.bulk_create(batch, ignore_conflicts=True)\n\n        run_migration_logic()\n        self.assertEqual(PhotoMetadata.objects.filter(photo=photo).count(), 1)\n\n        # Second run: the photo already has metadata, so it should be skipped\n        run_migration_logic()\n        self.assertEqual(PhotoMetadata.objects.filter(photo=photo).count(), 1)\n\n    def test_bulk_create_ignore_conflicts_on_duplicate(self):\n        \"\"\"\n        Verify that bulk_create(ignore_conflicts=True) silently skips duplicate records.\n\n        This tests the safety net used in each batch: if for any reason a\n        PhotoMetadata record already exists for a photo in the batch (e.g. a\n        partially-applied migration is retried), the insert is ignored rather\n        than raising an IntegrityError.\n        \"\"\"\n        photo = create_test_photo(owner=self.user)\n        PhotoMetadata.objects.filter(photo=photo).delete()\n\n        first = PhotoMetadata(photo=photo, source=\"embedded\", version=1)\n        PhotoMetadata.objects.bulk_create([first], ignore_conflicts=True)\n        self.assertEqual(PhotoMetadata.objects.filter(photo=photo).count(), 1)\n\n        # Attempt to insert the same photo again — must not raise\n        duplicate = PhotoMetadata(photo=photo, source=\"embedded\", version=1)\n        PhotoMetadata.objects.bulk_create([duplicate], ignore_conflicts=True)\n        self.assertEqual(PhotoMetadata.objects.filter(photo=photo).count(), 1)\n"
  },
  {
    "path": "api/tests/test_multi_user_isolation.py",
    "content": "\"\"\"\nTests for multi-user isolation and security.\n\nEnsures that:\n- Users can only see/modify their own duplicates and stacks\n- Shared photos don't leak into other users' detection\n- Admin access is properly scoped\n- Cross-user operations are blocked\n\"\"\"\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient, APITestCase\n\nfrom api.models.duplicate import Duplicate\nfrom api.models.photo_stack import PhotoStack\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass DuplicateUserIsolationTestCase(APITestCase):\n    \"\"\"Test that duplicates are properly scoped to users.\"\"\"\n\n    def setUp(self):\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.admin = create_test_user()\n        self.admin.is_staff = True\n        self.admin.save()\n        \n        self.client = APIClient()\n\n    def test_user_cannot_see_other_user_duplicates(self):\n        \"\"\"Test that users can only see their own duplicates.\"\"\"\n        # Create duplicate for user2\n        photos = [create_test_photo(owner=self.user2) for _ in range(2)]\n        dup = Duplicate.objects.create(\n            owner=self.user2,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(*photos)\n        \n        # Login as user1\n        self.client.force_authenticate(user=self.user1)\n        \n        # Try to access duplicate list\n        response = self.client.get(\"/api/duplicates\")\n        self.assertEqual(response.status_code, 200)\n        \n        # Should not see user2's duplicate\n        dup_ids = [d[\"id\"] for d in response.data.get(\"results\", [])]\n        self.assertNotIn(str(dup.id), dup_ids)\n\n    def test_user_cannot_access_other_user_duplicate_detail(self):\n        \"\"\"Test that users cannot access other users' duplicate details.\"\"\"\n        photos = [create_test_photo(owner=self.user2) for _ in range(2)]\n        dup = Duplicate.objects.create(\n            owner=self.user2,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(*photos)\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        response = self.client.get(f\"/api/duplicates/{dup.id}\")\n        self.assertIn(response.status_code, [403, 404])\n\n    def test_user_cannot_resolve_other_user_duplicate(self):\n        \"\"\"Test that users cannot resolve other users' duplicates.\"\"\"\n        photos = [create_test_photo(owner=self.user2) for _ in range(2)]\n        dup = Duplicate.objects.create(\n            owner=self.user2,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(*photos)\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        response = self.client.post(\n            f\"/api/duplicates/{dup.id}/resolve\",\n            {\"kept_photo_id\": str(photos[0].pk)},\n            format=\"json\"\n        )\n        self.assertIn(response.status_code, [403, 404])\n\n    def test_user_cannot_delete_other_user_duplicate(self):\n        \"\"\"Test that users cannot delete other users' duplicates.\"\"\"\n        photos = [create_test_photo(owner=self.user2) for _ in range(2)]\n        dup = Duplicate.objects.create(\n            owner=self.user2,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(*photos)\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        response = self.client.delete(f\"/api/duplicates/{dup.id}/delete\")\n        self.assertIn(response.status_code, [403, 404])\n        \n        # Duplicate should still exist\n        self.assertTrue(Duplicate.objects.filter(pk=dup.pk).exists())\n\n    def test_admin_can_see_duplicate_stats(self):\n        \"\"\"Test that admin can see global stats.\"\"\"\n        # Create duplicates for different users\n        for user in [self.user1, self.user2]:\n            photos = [create_test_photo(owner=user) for _ in range(2)]\n            dup = Duplicate.objects.create(\n                owner=user,\n                duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            )\n            dup.photos.add(*photos)\n        \n        self.client.force_authenticate(user=self.admin)\n        \n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, 200)\n\n\nclass StackUserIsolationTestCase(APITestCase):\n    \"\"\"Test that stacks are properly scoped to users.\"\"\"\n\n    def setUp(self):\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client = APIClient()\n\n    def test_user_cannot_see_other_user_stacks(self):\n        \"\"\"Test that users can only see their own stacks.\"\"\"\n        # Create stack for user2\n        photos = [create_test_photo(owner=self.user2) for _ in range(2)]\n        stack = PhotoStack.objects.create(\n            owner=self.user2,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(*photos)\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        response = self.client.get(\"/api/stacks\")\n        self.assertEqual(response.status_code, 200)\n        \n        stack_ids = [s[\"id\"] for s in response.data.get(\"results\", [])]\n        self.assertNotIn(str(stack.id), stack_ids)\n\n    def test_user_cannot_access_other_user_stack_detail(self):\n        \"\"\"Test that users cannot access other users' stack details.\"\"\"\n        photos = [create_test_photo(owner=self.user2) for _ in range(2)]\n        stack = PhotoStack.objects.create(\n            owner=self.user2,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(*photos)\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        response = self.client.get(f\"/api/stacks/{stack.id}\")\n        self.assertIn(response.status_code, [403, 404])\n\n    def test_user_cannot_modify_other_user_stack(self):\n        \"\"\"Test that users cannot modify other users' stacks.\"\"\"\n        photos = [create_test_photo(owner=self.user2) for _ in range(3)]\n        stack = PhotoStack.objects.create(\n            owner=self.user2,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*photos[:2])\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        # Try to add a photo\n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/add\",\n            {\"photo_ids\": [str(photos[2].pk)]},\n            format=\"json\"\n        )\n        self.assertIn(response.status_code, [403, 404])\n\n    def test_user_cannot_delete_other_user_stack(self):\n        \"\"\"Test that users cannot delete other users' stacks.\"\"\"\n        photos = [create_test_photo(owner=self.user2) for _ in range(2)]\n        stack = PhotoStack.objects.create(\n            owner=self.user2,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*photos)\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        response = self.client.delete(f\"/api/stacks/{stack.id}/delete\")\n        self.assertIn(response.status_code, [403, 404])\n        \n        # Stack should still exist\n        self.assertTrue(PhotoStack.objects.filter(pk=stack.pk).exists())\n\n    def test_user_cannot_create_stack_with_other_user_photos(self):\n        \"\"\"Test that users cannot create stacks with other users' photos.\"\"\"\n        other_photos = [create_test_photo(owner=self.user2) for _ in range(2)]\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_ids\": [str(p.pk) for p in other_photos]},\n            format=\"json\"\n        )\n        # Should either fail or create empty stack\n        if response.status_code == 201:\n            # If created, should have no photos\n            stack_id = response.data.get(\"id\")\n            if stack_id:\n                stack = PhotoStack.objects.get(pk=stack_id)\n                self.assertEqual(stack.photos.count(), 0)\n        else:\n            self.assertIn(response.status_code, [400, 403, 404])\n\n\nclass SharedPhotoIsolationTestCase(TestCase):\n    \"\"\"Test that shared photos don't affect personal stacks/duplicates.\"\"\"\n\n    def setUp(self):\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n\n    def test_shared_photo_not_in_receiver_stacks(self):\n        \"\"\"Test that photos shared TO a user don't appear in their stack detection.\"\"\"\n        # Create photo for user1\n        photo1 = create_test_photo(owner=self.user1)\n        \n        # Share with user2\n        photo1.shared_to.add(self.user2)\n        \n        # Create a stack for user1\n        photo2 = create_test_photo(owner=self.user1)\n        stack = PhotoStack.objects.create(\n            owner=self.user1,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        # User2 should not see this stack\n        user2_stacks = PhotoStack.objects.filter(owner=self.user2)\n        self.assertEqual(user2_stacks.count(), 0)\n\n    def test_shared_photo_not_in_receiver_duplicates(self):\n        \"\"\"Test that shared photos don't appear in receiver's duplicate detection.\"\"\"\n        # Create photos for user1\n        photos = [create_test_photo(owner=self.user1) for _ in range(2)]\n        \n        # Create duplicate group for user1\n        dup = Duplicate.objects.create(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(*photos)\n        \n        # Share one photo with user2\n        photos[0].shared_to.add(self.user2)\n        \n        # User2 should not see this duplicate\n        user2_dups = Duplicate.objects.filter(owner=self.user2)\n        self.assertEqual(user2_dups.count(), 0)\n\n    def test_user_stack_unaffected_by_shared_photos(self):\n        \"\"\"Test that user's own stacks aren't affected by sharing.\"\"\"\n        # Create stack for user1\n        photos = [create_test_photo(owner=self.user1) for _ in range(3)]\n        stack = PhotoStack.objects.create(\n            owner=self.user1,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(*photos)\n        \n        # Share one photo\n        photos[0].shared_to.add(self.user2)\n        \n        # Stack should still have all 3 photos\n        stack.refresh_from_db()\n        self.assertEqual(stack.photos.count(), 3)\n\n\nclass DetectionUserIsolationTestCase(APITestCase):\n    \"\"\"Test that detection operations are properly isolated.\"\"\"\n\n    def setUp(self):\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client = APIClient()\n\n    def test_duplicate_detection_only_affects_own_photos(self):\n        \"\"\"Test that duplicate detection only processes user's own photos.\"\"\"\n        # Create photos for both users with same hash (simulating duplicates)\n        photo1_u1 = create_test_photo(owner=self.user1)\n        photo2_u1 = create_test_photo(owner=self.user1)\n        photo1_u2 = create_test_photo(owner=self.user2)\n        photo2_u2 = create_test_photo(owner=self.user2)\n        \n        # Set same perceptual hash to simulate visual duplicates\n        same_hash = \"0\" * 16\n        for photo in [photo1_u1, photo2_u1, photo1_u2, photo2_u2]:\n            photo.image_phash = same_hash\n            photo.save()\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        # Trigger detection for user1\n        response = self.client.post(\"/api/duplicates/detect\")\n        self.assertIn(response.status_code, [200, 202])\n        \n        # User2's photos should not be in user1's duplicates\n        user1_dups = Duplicate.objects.filter(owner=self.user1)\n        for dup in user1_dups:\n            for photo in dup.photos.all():\n                self.assertEqual(photo.owner, self.user1)\n\n    def test_stack_detection_only_affects_own_photos(self):\n        \"\"\"Test that stack detection only processes user's own photos.\"\"\"\n        # Create photos for both users\n        for _ in range(3):\n            create_test_photo(owner=self.user1)\n            create_test_photo(owner=self.user2)\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        # Trigger stack detection for user1\n        response = self.client.post(\"/api/stacks/detect\")\n        self.assertIn(response.status_code, [200, 202])\n        \n        # User2 should have no stacks created\n        user2_stacks = PhotoStack.objects.filter(owner=self.user2)\n        self.assertEqual(user2_stacks.count(), 0)\n\n\nclass CrossUserOperationTestCase(APITestCase):\n    \"\"\"Test that cross-user operations are properly blocked.\"\"\"\n\n    def setUp(self):\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client = APIClient()\n\n    def test_cannot_add_other_user_photo_to_own_stack(self):\n        \"\"\"Test that users cannot add other users' photos to their stacks.\"\"\"\n        own_photos = [create_test_photo(owner=self.user1) for _ in range(2)]\n        other_photo = create_test_photo(owner=self.user2)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user1,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*own_photos)\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        _response = self.client.post(\n            f\"/api/stacks/{stack.id}/add\",\n            {\"photo_ids\": [str(other_photo.pk)]},\n            format=\"json\"\n        )\n        \n        # Should either fail or not add the photo\n        stack.refresh_from_db()\n        self.assertNotIn(other_photo, stack.photos.all())\n\n    def test_cannot_resolve_duplicate_with_other_user_photo(self):\n        \"\"\"Test that users cannot resolve duplicates by keeping other user's photo.\"\"\"\n        photos = [create_test_photo(owner=self.user1) for _ in range(2)]\n        other_photo = create_test_photo(owner=self.user2)\n        \n        dup = Duplicate.objects.create(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(*photos)\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        # Try to resolve keeping other user's photo\n        response = self.client.post(\n            f\"/api/duplicates/{dup.id}/resolve\",\n            {\"kept_photo_id\": str(other_photo.pk)},\n            format=\"json\"\n        )\n        \n        # Should fail\n        self.assertIn(response.status_code, [400, 403, 404])\n\n    def test_cannot_set_other_user_photo_as_stack_primary(self):\n        \"\"\"Test that users cannot set other user's photo as stack primary.\"\"\"\n        own_photos = [create_test_photo(owner=self.user1) for _ in range(2)]\n        other_photo = create_test_photo(owner=self.user2)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user1,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*own_photos)\n        \n        self.client.force_authenticate(user=self.user1)\n        \n        _response = self.client.post(\n            f\"/api/stacks/{stack.id}/primary\",\n            {\"photo_id\": str(other_photo.pk)},\n            format=\"json\"\n        )\n        \n        # Should fail or not set the photo\n        stack.refresh_from_db()\n        self.assertNotEqual(stack.primary_photo, other_photo)\n\n\nclass AdminAccessTestCase(APITestCase):\n    \"\"\"Test admin access and capabilities.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.admin = create_test_user()\n        self.admin.is_staff = True\n        self.admin.save()\n        self.client = APIClient()\n\n    def test_admin_can_view_stats_for_all_users(self):\n        \"\"\"Test that admin can view aggregate stats.\"\"\"\n        # Create data for regular user\n        photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(*photos)\n        \n        self.client.force_authenticate(user=self.admin)\n        \n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, 200)\n\n    def test_regular_user_sees_only_own_stats(self):\n        \"\"\"Test that regular users only see their own stats.\"\"\"\n        # Create data for admin\n        admin_photos = [create_test_photo(owner=self.admin) for _ in range(2)]\n        admin_dup = Duplicate.objects.create(\n            owner=self.admin,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        admin_dup.photos.add(*admin_photos)\n        \n        # Create data for user\n        user_photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        user_dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        user_dup.photos.add(*user_photos)\n        \n        self.client.force_authenticate(user=self.user)\n        \n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, 200)\n        \n        # Stats should reflect only user's data\n        # The exact assertion depends on the stats endpoint response format\n"
  },
  {
    "path": "api/tests/test_only_photos_or_only_videos.py",
    "content": "from django.test import TestCase\nfrom django.utils import timezone\nfrom rest_framework.test import APIClient\n\nfrom api.models.album_date import AlbumDate\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass OnlyPhotosOrOnlyVideosTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n\n    def test_only_photos(self):\n        now = timezone.now()\n        photo = create_test_photo(owner=self.user, added_on=now, public=True)\n\n        album = AlbumDate(owner=self.user)\n        album.id = 1\n        album.photos.add(photo)\n        album.save()\n\n        response = self.client.get(\"/api/albums/date/list?photo=true\").url\n        response = self.client.get(response)\n\n        data = response.json()\n        self.assertEqual(1, len(data[\"results\"]))\n"
  },
  {
    "path": "api/tests/test_perceptual_hash.py",
    "content": "\"\"\"\nComprehensive tests for api/perceptual_hash.py\n\nTests the perceptual hashing algorithm used for visual duplicate detection:\n- calculate_perceptual_hash: Calculates pHash from image files\n- calculate_hash_from_thumbnail: Wrapper for thumbnail hashing\n- hamming_distance: Calculates bit difference between hashes\n- are_duplicates: Determines if two images are duplicates based on hash similarity\n- find_similar_hashes: Finds all similar hashes in a list\n\"\"\"\n\nimport os\nimport tempfile\nfrom unittest.mock import patch\n\nfrom django.test import TestCase\nfrom PIL import Image\n\nfrom api.perceptual_hash import (\n    DEFAULT_HAMMING_THRESHOLD,\n    are_duplicates,\n    calculate_hash_from_thumbnail,\n    calculate_perceptual_hash,\n    find_similar_hashes,\n    hamming_distance,\n)\n\n\nclass HammingDistanceTestCase(TestCase):\n    \"\"\"Tests for the hamming_distance function.\"\"\"\n\n    def test_identical_hashes_return_zero(self):\n        \"\"\"Identical hashes should have distance 0.\"\"\"\n        hash1 = \"a\" * 16  # 64-bit hash as 16 hex chars\n        self.assertEqual(hamming_distance(hash1, hash1), 0)\n\n    def test_completely_different_hashes(self):\n        \"\"\"Completely different hashes should have maximum distance (64 for 64-bit hash).\"\"\"\n        # 0000...0000 vs FFFF...FFFF\n        hash1 = \"0\" * 16\n        hash2 = \"f\" * 16\n        distance = hamming_distance(hash1, hash2)\n        self.assertEqual(distance, 64)  # All 64 bits different\n\n    def test_one_bit_difference(self):\n        \"\"\"Hashes differing by one bit should have distance 1.\"\"\"\n        # 0x0 = 0000 in binary, 0x1 = 0001 in binary - 1 bit different\n        hash1 = \"0\" * 16\n        hash2 = \"0\" * 15 + \"1\"\n        distance = hamming_distance(hash1, hash2)\n        self.assertEqual(distance, 1)\n\n    def test_half_bits_different(self):\n        \"\"\"Test hashes with approximately half the bits different.\"\"\"\n        # 0 = 0000, a = 1010 - 2 bits different per hex char\n        hash1 = \"0\" * 16\n        hash2 = \"a\" * 16  # 2 bits per char * 16 chars = 32 bits\n        distance = hamming_distance(hash1, hash2)\n        self.assertEqual(distance, 32)\n\n    def test_invalid_hash_returns_max_distance(self):\n        \"\"\"Invalid hash strings should return maximum distance (64).\"\"\"\n        distance = hamming_distance(\"invalid\", \"hash\")\n        self.assertEqual(distance, 64)\n\n    def test_empty_strings_return_max_distance(self):\n        \"\"\"Empty strings should return maximum distance.\"\"\"\n        distance = hamming_distance(\"\", \"\")\n        self.assertEqual(distance, 64)\n\n    def test_mixed_valid_invalid_returns_max_distance(self):\n        \"\"\"Mix of valid and invalid should return max distance.\"\"\"\n        valid_hash = \"a\" * 16\n        distance = hamming_distance(valid_hash, \"not_hex_xyz\")\n        self.assertEqual(distance, 64)\n\n    def test_different_length_hashes(self):\n        \"\"\"Different length hashes should return max distance or handle gracefully.\"\"\"\n        hash1 = \"a\" * 16\n        hash2 = \"a\" * 8  # Shorter hash\n        distance = hamming_distance(hash1, hash2)\n        # imagehash may handle this differently - should not crash\n        self.assertIsInstance(distance, int)\n\n    def test_real_phash_values(self):\n        \"\"\"Test with realistic pHash values.\"\"\"\n        # These are example pHash values that might be generated\n        hash1 = \"8f94b5a16363c3c3\"\n        hash2 = \"8f94b5a16363c3c7\"  # 2 bits different (c3 vs c7)\n        distance = hamming_distance(hash1, hash2)\n        self.assertLessEqual(distance, 5)  # Should be small\n\n    def test_case_insensitive_hashes(self):\n        \"\"\"Hash comparison should be case-insensitive (hex).\"\"\"\n        hash1 = \"ABCDEF0123456789\"\n        hash2 = \"abcdef0123456789\"\n        distance = hamming_distance(hash1, hash2)\n        self.assertEqual(distance, 0)\n\n\nclass AreDuplicatesTestCase(TestCase):\n    \"\"\"Tests for the are_duplicates function.\"\"\"\n\n    def test_identical_hashes_are_duplicates(self):\n        \"\"\"Identical hashes should always be considered duplicates.\"\"\"\n        hash1 = \"a\" * 16\n        self.assertTrue(are_duplicates(hash1, hash1))\n\n    def test_distance_under_threshold_is_duplicate(self):\n        \"\"\"Hashes with distance under threshold are duplicates.\"\"\"\n        # Using 1 bit difference which is well under default threshold of 10\n        hash1 = \"0\" * 16\n        hash2 = \"0\" * 15 + \"1\"\n        self.assertTrue(are_duplicates(hash1, hash2))\n\n    def test_distance_at_threshold_is_duplicate(self):\n        \"\"\"Hashes with distance exactly at threshold are duplicates.\"\"\"\n        with patch(\"api.perceptual_hash.hamming_distance\", return_value=10):\n            self.assertTrue(are_duplicates(\"a\" * 16, \"b\" * 16, threshold=10))\n\n    def test_distance_over_threshold_not_duplicate(self):\n        \"\"\"Hashes with distance over threshold are not duplicates.\"\"\"\n        with patch(\"api.perceptual_hash.hamming_distance\", return_value=11):\n            self.assertFalse(are_duplicates(\"a\" * 16, \"b\" * 16, threshold=10))\n\n    def test_empty_hash1_not_duplicate(self):\n        \"\"\"Empty first hash should not be considered duplicate.\"\"\"\n        self.assertFalse(are_duplicates(\"\", \"a\" * 16))\n\n    def test_empty_hash2_not_duplicate(self):\n        \"\"\"Empty second hash should not be considered duplicate.\"\"\"\n        self.assertFalse(are_duplicates(\"a\" * 16, \"\"))\n\n    def test_none_hash1_not_duplicate(self):\n        \"\"\"None first hash should not be considered duplicate.\"\"\"\n        self.assertFalse(are_duplicates(None, \"a\" * 16))\n\n    def test_none_hash2_not_duplicate(self):\n        \"\"\"None second hash should not be considered duplicate.\"\"\"\n        self.assertFalse(are_duplicates(\"a\" * 16, None))\n\n    def test_both_none_not_duplicate(self):\n        \"\"\"Both None should not be considered duplicate.\"\"\"\n        self.assertFalse(are_duplicates(None, None))\n\n    def test_both_empty_not_duplicate(self):\n        \"\"\"Both empty should not be considered duplicate.\"\"\"\n        self.assertFalse(are_duplicates(\"\", \"\"))\n\n    def test_custom_threshold_strict(self):\n        \"\"\"Strict threshold (lower) should reject more.\"\"\"\n        with patch(\"api.perceptual_hash.hamming_distance\", return_value=5):\n            self.assertTrue(are_duplicates(\"a\" * 16, \"b\" * 16, threshold=5))\n            self.assertFalse(are_duplicates(\"a\" * 16, \"b\" * 16, threshold=4))\n\n    def test_custom_threshold_loose(self):\n        \"\"\"Loose threshold (higher) should accept more.\"\"\"\n        with patch(\"api.perceptual_hash.hamming_distance\", return_value=15):\n            self.assertTrue(are_duplicates(\"a\" * 16, \"b\" * 16, threshold=15))\n            self.assertTrue(are_duplicates(\"a\" * 16, \"b\" * 16, threshold=20))\n\n    def test_default_threshold_value(self):\n        \"\"\"Default threshold should be 10.\"\"\"\n        self.assertEqual(DEFAULT_HAMMING_THRESHOLD, 10)\n\n\nclass FindSimilarHashesTestCase(TestCase):\n    \"\"\"Tests for the find_similar_hashes function.\"\"\"\n\n    def test_empty_target_hash_returns_empty(self):\n        \"\"\"Empty target hash should return empty list.\"\"\"\n        hash_list = [(\"img1\", \"a\" * 16), (\"img2\", \"b\" * 16)]\n        result = find_similar_hashes(\"\", hash_list)\n        self.assertEqual(result, [])\n\n    def test_none_target_hash_returns_empty(self):\n        \"\"\"None target hash should return empty list.\"\"\"\n        hash_list = [(\"img1\", \"a\" * 16), (\"img2\", \"b\" * 16)]\n        result = find_similar_hashes(None, hash_list)\n        self.assertEqual(result, [])\n\n    def test_empty_hash_list_returns_empty(self):\n        \"\"\"Empty hash list should return empty list.\"\"\"\n        result = find_similar_hashes(\"a\" * 16, [])\n        self.assertEqual(result, [])\n\n    def test_finds_similar_hashes(self):\n        \"\"\"Should find hashes within threshold.\"\"\"\n        target = \"0\" * 16\n        # 1 bit different - should be found\n        similar = \"0\" * 15 + \"1\"\n        hash_list = [(\"img1\", similar)]\n        result = find_similar_hashes(target, hash_list)\n        self.assertEqual(len(result), 1)\n        self.assertEqual(result[0][0], \"img1\")\n        self.assertEqual(result[0][1], 1)  # distance of 1\n\n    def test_excludes_distant_hashes(self):\n        \"\"\"Should exclude hashes beyond threshold.\"\"\"\n        target = \"0\" * 16\n        distant = \"f\" * 16  # 64 bits different\n        hash_list = [(\"img1\", distant)]\n        result = find_similar_hashes(target, hash_list)\n        self.assertEqual(result, [])\n\n    def test_skips_identical_hash(self):\n        \"\"\"Should skip exact same hash (self-comparison).\"\"\"\n        target = \"a\" * 16\n        hash_list = [(\"img1\", target)]  # Same hash\n        result = find_similar_hashes(target, hash_list)\n        self.assertEqual(result, [])\n\n    def test_skips_none_hash_in_list(self):\n        \"\"\"Should skip None hashes in the list.\"\"\"\n        target = \"a\" * 16\n        similar = \"a\" * 15 + \"0\"  # 1 bit different\n        hash_list = [(\"img1\", None), (\"img2\", similar)]\n        result = find_similar_hashes(target, hash_list)\n        self.assertEqual(len(result), 1)\n        self.assertEqual(result[0][0], \"img2\")\n\n    def test_skips_empty_hash_in_list(self):\n        \"\"\"Should skip empty hashes in the list.\"\"\"\n        target = \"a\" * 16\n        similar = \"a\" * 15 + \"0\"\n        hash_list = [(\"img1\", \"\"), (\"img2\", similar)]\n        result = find_similar_hashes(target, hash_list)\n        self.assertEqual(len(result), 1)\n        self.assertEqual(result[0][0], \"img2\")\n\n    def test_sorted_by_distance(self):\n        \"\"\"Results should be sorted by distance (closest first).\"\"\"\n        target = \"0\" * 16\n        # Create hashes with known distances\n        with patch(\"api.perceptual_hash.hamming_distance\") as mock_dist:\n            mock_dist.side_effect = [8, 3, 5]  # Distances for 3 hashes\n            hash_list = [(\"img1\", \"h1\"), (\"img2\", \"h2\"), (\"img3\", \"h3\")]\n            result = find_similar_hashes(target, hash_list, threshold=10)\n            # Should be sorted: img2 (3), img3 (5), img1 (8)\n            self.assertEqual(result[0][0], \"img2\")\n            self.assertEqual(result[1][0], \"img3\")\n            self.assertEqual(result[2][0], \"img1\")\n\n    def test_custom_threshold(self):\n        \"\"\"Should respect custom threshold.\"\"\"\n        target = \"0\" * 16\n        with patch(\"api.perceptual_hash.hamming_distance\") as mock_dist:\n            mock_dist.return_value = 5\n            hash_list = [(\"img1\", \"h1\")]\n            # Threshold 4 - should exclude distance 5\n            result = find_similar_hashes(target, hash_list, threshold=4)\n            self.assertEqual(result, [])\n            # Threshold 5 - should include distance 5\n            result = find_similar_hashes(target, hash_list, threshold=5)\n            self.assertEqual(len(result), 1)\n\n    def test_returns_correct_tuple_format(self):\n        \"\"\"Results should be (image_id, distance) tuples.\"\"\"\n        target = \"0\" * 16\n        with patch(\"api.perceptual_hash.hamming_distance\", return_value=2):\n            hash_list = [(\"my_image_id\", \"some_hash\")]\n            result = find_similar_hashes(target, hash_list)\n            self.assertEqual(len(result), 1)\n            image_id, distance = result[0]\n            self.assertEqual(image_id, \"my_image_id\")\n            self.assertEqual(distance, 2)\n\n    def test_multiple_similar_all_returned(self):\n        \"\"\"All similar hashes should be returned.\"\"\"\n        target = \"0\" * 16\n        with patch(\"api.perceptual_hash.hamming_distance\", return_value=5):\n            hash_list = [(\"img1\", \"h1\"), (\"img2\", \"h2\"), (\"img3\", \"h3\")]\n            result = find_similar_hashes(target, hash_list, threshold=10)\n            self.assertEqual(len(result), 3)\n\n\nclass CalculatePerceptualHashTestCase(TestCase):\n    \"\"\"Tests for the calculate_perceptual_hash function.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create temporary directory for test images.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n\n    def tearDown(self):\n        \"\"\"Clean up temporary files.\"\"\"\n        import shutil\n\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    def _create_test_image(self, filename, size=(100, 100), mode=\"RGB\", color=(255, 0, 0)):\n        \"\"\"Helper to create a test image file.\"\"\"\n        path = os.path.join(self.temp_dir, filename)\n        img = Image.new(mode, size, color)\n        img.save(path)\n        return path\n\n    def test_valid_rgb_image(self):\n        \"\"\"Should calculate hash for valid RGB image.\"\"\"\n        path = self._create_test_image(\"test.jpg\", mode=\"RGB\")\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n        self.assertIsInstance(result, str)\n        self.assertEqual(len(result), 16)  # 64-bit hash = 16 hex chars\n\n    def test_valid_rgba_image_converted(self):\n        \"\"\"Should handle RGBA images by converting to RGB.\"\"\"\n        path = self._create_test_image(\"test.png\", mode=\"RGBA\", color=(255, 0, 0, 128))\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n        self.assertEqual(len(result), 16)\n\n    def test_valid_grayscale_image(self):\n        \"\"\"Should handle grayscale (L mode) images.\"\"\"\n        path = self._create_test_image(\"test_gray.jpg\", mode=\"L\", color=128)\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n        self.assertEqual(len(result), 16)\n\n    def test_valid_palette_image_converted(self):\n        \"\"\"Should handle palette (P mode) images by converting to RGB.\"\"\"\n        path = os.path.join(self.temp_dir, \"test_palette.png\")\n        img = Image.new(\"P\", (100, 100))\n        img.save(path)\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n\n    def test_nonexistent_file_returns_none(self):\n        \"\"\"Should return None for nonexistent file.\"\"\"\n        result = calculate_perceptual_hash(\"/nonexistent/path/image.jpg\")\n        self.assertIsNone(result)\n\n    def test_corrupted_file_returns_none(self):\n        \"\"\"Should return None for corrupted/invalid image file.\"\"\"\n        path = os.path.join(self.temp_dir, \"corrupted.jpg\")\n        with open(path, \"w\") as f:\n            f.write(\"not an image file content\")\n        result = calculate_perceptual_hash(path)\n        self.assertIsNone(result)\n\n    def test_empty_file_returns_none(self):\n        \"\"\"Should return None for empty file.\"\"\"\n        path = os.path.join(self.temp_dir, \"empty.jpg\")\n        with open(path, \"w\") as _f:\n            pass  # Create empty file\n        result = calculate_perceptual_hash(path)\n        self.assertIsNone(result)\n\n    def test_directory_instead_of_file_returns_none(self):\n        \"\"\"Should return None if path is a directory.\"\"\"\n        result = calculate_perceptual_hash(self.temp_dir)\n        self.assertIsNone(result)\n\n    def test_custom_hash_size(self):\n        \"\"\"Should support custom hash sizes.\"\"\"\n        path = self._create_test_image(\"test.jpg\")\n        # hash_size=16 produces 256-bit hash = 64 hex chars\n        result = calculate_perceptual_hash(path, hash_size=16)\n        self.assertIsNotNone(result)\n        self.assertEqual(len(result), 64)\n\n    def test_small_hash_size(self):\n        \"\"\"Should support smaller hash sizes.\"\"\"\n        path = self._create_test_image(\"test.jpg\")\n        # hash_size=4 produces 16-bit hash = 4 hex chars\n        result = calculate_perceptual_hash(path, hash_size=4)\n        self.assertIsNotNone(result)\n        self.assertEqual(len(result), 4)\n\n    def test_similar_images_similar_hashes(self):\n        \"\"\"Similar images should produce similar hashes.\"\"\"\n        # Create two similar images (same color, slight size difference)\n        path1 = self._create_test_image(\"img1.jpg\", size=(100, 100), color=(255, 0, 0))\n        path2 = self._create_test_image(\"img2.jpg\", size=(110, 110), color=(255, 0, 0))\n        hash1 = calculate_perceptual_hash(path1)\n        hash2 = calculate_perceptual_hash(path2)\n        self.assertIsNotNone(hash1)\n        self.assertIsNotNone(hash2)\n        # Similar solid color images should have low distance\n        distance = hamming_distance(hash1, hash2)\n        self.assertLessEqual(distance, 10)\n\n    def test_different_images_different_hashes(self):\n        \"\"\"Very different images should produce different hashes.\"\"\"\n        # Create two very different images with patterns (not solid colors)\n        # Solid colors produce similar hashes because pHash uses DCT\n        path1 = os.path.join(self.temp_dir, \"pattern1.jpg\")\n        path2 = os.path.join(self.temp_dir, \"pattern2.jpg\")\n\n        # Create a horizontal gradient pattern\n        img1 = Image.new(\"RGB\", (100, 100))\n        for x in range(100):\n            for y in range(100):\n                img1.putpixel((x, y), (x * 2, 0, 0))\n        img1.save(path1)\n\n        # Create a vertical gradient pattern (different structure)\n        img2 = Image.new(\"RGB\", (100, 100))\n        for x in range(100):\n            for y in range(100):\n                img2.putpixel((x, y), (0, 0, y * 2))\n        img2.save(path2)\n\n        hash1 = calculate_perceptual_hash(path1)\n        hash2 = calculate_perceptual_hash(path2)\n        self.assertIsNotNone(hash1)\n        self.assertIsNotNone(hash2)\n        # Different patterns should have noticeable distance\n        distance = hamming_distance(hash1, hash2)\n        self.assertGreater(distance, 0)\n\n    def test_deterministic_hash(self):\n        \"\"\"Same image should always produce same hash.\"\"\"\n        path = self._create_test_image(\"test.jpg\")\n        hash1 = calculate_perceptual_hash(path)\n        hash2 = calculate_perceptual_hash(path)\n        self.assertEqual(hash1, hash2)\n\n    def test_very_small_image(self):\n        \"\"\"Should handle very small images (1x1 pixel).\"\"\"\n        path = self._create_test_image(\"tiny.jpg\", size=(1, 1))\n        _result = calculate_perceptual_hash(path)\n        # Should not crash - may return hash or None depending on implementation\n        # The key is it doesn't raise an exception\n\n    def test_very_large_image(self):\n        \"\"\"Should handle large images (though may be slow).\"\"\"\n        path = self._create_test_image(\"large.jpg\", size=(1000, 1000))\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n        self.assertEqual(len(result), 16)\n\n    def test_jpeg_vs_png_same_content(self):\n        \"\"\"Same image content in different formats should have similar hash.\"\"\"\n        # Create same color image in different formats\n        jpg_path = self._create_test_image(\"test.jpg\", color=(100, 150, 200))\n        png_path = self._create_test_image(\"test.png\", color=(100, 150, 200))\n        hash_jpg = calculate_perceptual_hash(jpg_path)\n        hash_png = calculate_perceptual_hash(png_path)\n        self.assertIsNotNone(hash_jpg)\n        self.assertIsNotNone(hash_png)\n        distance = hamming_distance(hash_jpg, hash_png)\n        # Same content should have identical or very similar hashes\n        self.assertLessEqual(distance, 5)\n\n\nclass CalculateHashFromThumbnailTestCase(TestCase):\n    \"\"\"Tests for the calculate_hash_from_thumbnail function.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create temporary directory for test images.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n\n    def tearDown(self):\n        \"\"\"Clean up temporary files.\"\"\"\n        import shutil\n\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    def test_delegates_to_calculate_perceptual_hash(self):\n        \"\"\"Should delegate to calculate_perceptual_hash.\"\"\"\n        with patch(\"api.perceptual_hash.calculate_perceptual_hash\") as mock:\n            mock.return_value = \"abc123\"\n            result = calculate_hash_from_thumbnail(\"/some/path\")\n            mock.assert_called_once_with(\"/some/path\")\n            self.assertEqual(result, \"abc123\")\n\n    def test_returns_none_on_failure(self):\n        \"\"\"Should return None if underlying function fails.\"\"\"\n        result = calculate_hash_from_thumbnail(\"/nonexistent/path\")\n        self.assertIsNone(result)\n\n\nclass EdgeCasesTestCase(TestCase):\n    \"\"\"Edge case tests for the perceptual hash module.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create temporary directory for test images.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n\n    def tearDown(self):\n        \"\"\"Clean up temporary files.\"\"\"\n        import shutil\n\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    def test_unicode_filename(self):\n        \"\"\"Should handle unicode characters in filename.\"\"\"\n        path = os.path.join(self.temp_dir, \"图片_照片_🖼️.jpg\")\n        img = Image.new(\"RGB\", (50, 50), (255, 255, 0))\n        img.save(path)\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n\n    def test_special_characters_in_path(self):\n        \"\"\"Should handle special characters in file path.\"\"\"\n        path = os.path.join(self.temp_dir, \"test with spaces & special (1).jpg\")\n        img = Image.new(\"RGB\", (50, 50), (255, 255, 0))\n        img.save(path)\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n\n    def test_hash_only_contains_hex_chars(self):\n        \"\"\"Generated hash should only contain valid hex characters.\"\"\"\n        path = os.path.join(self.temp_dir, \"test.jpg\")\n        img = Image.new(\"RGB\", (50, 50), (123, 45, 67))\n        img.save(path)\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n        # Check all characters are hex\n        valid_hex = set(\"0123456789abcdef\")\n        self.assertTrue(all(c in valid_hex for c in result.lower()))\n\n    def test_webp_format(self):\n        \"\"\"Should handle WebP format images.\"\"\"\n        path = os.path.join(self.temp_dir, \"test.webp\")\n        img = Image.new(\"RGB\", (50, 50), (100, 100, 100))\n        img.save(path, \"WEBP\")\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n\n    def test_gif_format(self):\n        \"\"\"Should handle GIF format images.\"\"\"\n        path = os.path.join(self.temp_dir, \"test.gif\")\n        img = Image.new(\"RGB\", (50, 50), (50, 100, 150))\n        img.save(path, \"GIF\")\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n\n    def test_bmp_format(self):\n        \"\"\"Should handle BMP format images.\"\"\"\n        path = os.path.join(self.temp_dir, \"test.bmp\")\n        img = Image.new(\"RGB\", (50, 50), (200, 100, 50))\n        img.save(path, \"BMP\")\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n\n    def test_hamming_distance_with_newlines_in_hash(self):\n        \"\"\"Should handle hashes that might have whitespace (edge case).\"\"\"\n        # This tests robustness - real hashes shouldn't have whitespace\n        # imagehash's hex_to_hash is resilient and strips/ignores trailing chars\n        distance = hamming_distance(\"a\" * 16 + \"\\n\", \"a\" * 16)\n        # The library handles this gracefully - doesn't crash\n        self.assertIsInstance(distance, int)\n\n    def test_find_similar_with_large_list(self):\n        \"\"\"Should handle large hash lists efficiently.\"\"\"\n        target = \"0\" * 16\n        # Create a large list of hashes\n        hash_list = [(f\"img_{i}\", f\"{i:016x}\") for i in range(1000)]\n        # Should not crash or hang\n        result = find_similar_hashes(target, hash_list, threshold=10)\n        self.assertIsInstance(result, list)\n\n    def test_are_duplicates_with_whitespace_only_hash(self):\n        \"\"\"Should handle whitespace-only hash gracefully.\"\"\"\n        self.assertFalse(are_duplicates(\"   \", \"a\" * 16))\n        self.assertFalse(are_duplicates(\"a\" * 16, \"   \"))\n\n    def test_cmyk_image_converted(self):\n        \"\"\"Should handle CMYK images by converting to RGB.\"\"\"\n        path = os.path.join(self.temp_dir, \"test_cmyk.jpg\")\n        # Create a CMYK image\n        img = Image.new(\"CMYK\", (50, 50), (0, 100, 100, 0))  # Red in CMYK\n        img.save(path)\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n\n    def test_1bit_image(self):\n        \"\"\"Should handle 1-bit (black and white) images.\"\"\"\n        path = os.path.join(self.temp_dir, \"test_1bit.png\")\n        img = Image.new(\"1\", (50, 50), 1)  # White\n        img.save(path)\n        result = calculate_perceptual_hash(path)\n        self.assertIsNotNone(result)\n\n    def test_concurrent_hash_calculation(self):\n        \"\"\"Hash calculation should be thread-safe (no shared mutable state).\"\"\"\n        import concurrent.futures\n\n        # Create multiple test images with distinct patterns (not just solid colors)\n        paths = []\n        for i in range(5):\n            path = os.path.join(self.temp_dir, f\"concurrent_{i}.jpg\")\n            img = Image.new(\"RGB\", (50, 50))\n            # Create distinct patterns for each image\n            for x in range(50):\n                for y in range(50):\n                    # Each image has a unique pattern based on i\n                    if i == 0:\n                        img.putpixel((x, y), (x * 5, 0, 0))  # Horizontal red gradient\n                    elif i == 1:\n                        img.putpixel((x, y), (0, y * 5, 0))  # Vertical green gradient\n                    elif i == 2:\n                        img.putpixel((x, y), (0, 0, (x + y) * 2))  # Diagonal blue\n                    elif i == 3:\n                        img.putpixel((x, y), ((x * y) % 256, 0, 0))  # Multiplicative pattern\n                    else:\n                        img.putpixel((x, y), (255 if x > 25 else 0, 255 if y > 25 else 0, 0))  # Quadrants\n            img.save(path)\n            paths.append(path)\n\n        # Calculate hashes concurrently\n        with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:\n            futures = [executor.submit(calculate_perceptual_hash, p) for p in paths]\n            results = [f.result() for f in futures]\n\n        # All should succeed\n        self.assertTrue(all(r is not None for r in results))\n        # Most should be unique (distinct patterns) - allow some similarity\n        unique_count = len(set(results))\n        self.assertGreaterEqual(unique_count, 3)  # At least 3 unique hashes from 5 distinct patterns\n\n\nclass PerformanceTestCase(TestCase):\n    \"\"\"Performance-related tests for the perceptual hash module.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create temporary directory for test images.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n\n    def tearDown(self):\n        \"\"\"Clean up temporary files.\"\"\"\n        import shutil\n\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    def test_hamming_distance_performance(self):\n        \"\"\"Hamming distance should be fast for many comparisons.\"\"\"\n        import time\n\n        hash1 = \"a\" * 16\n        hash2 = \"b\" * 16\n\n        start = time.time()\n        for _ in range(10000):\n            hamming_distance(hash1, hash2)\n        elapsed = time.time() - start\n\n        # 10000 comparisons should complete in under 1 second\n        self.assertLess(elapsed, 1.0)\n\n    def test_find_similar_performance(self):\n        \"\"\"find_similar_hashes should be reasonably fast for medium-sized lists.\"\"\"\n        import time\n\n        target = \"0\" * 16\n        # Create a list of 100 hashes\n        hash_list = [(f\"img_{i}\", f\"{i:016x}\") for i in range(100)]\n\n        start = time.time()\n        for _ in range(100):\n            find_similar_hashes(target, hash_list, threshold=10)\n        elapsed = time.time() - start\n\n        # 100 searches over 100 hashes should complete quickly\n        self.assertLess(elapsed, 2.0)\n"
  },
  {
    "path": "api/tests/test_photo_caption_model.py",
    "content": "from django.test import TestCase\n\nfrom api.models import PhotoCaption\nfrom api.tests.utils import create_test_user, create_test_photo\n\n\nclass PhotoCaptionModelTest(TestCase):\n    def setUp(self):\n        self.user = create_test_user()\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_create_photo_caption(self):\n        \"\"\"Test creating a PhotoCaption instance\"\"\"\n        caption = PhotoCaption.objects.create(\n            photo=self.photo, captions_json={\"user_caption\": \"Test caption\"}\n        )\n\n        self.assertEqual(caption.photo, self.photo)\n        self.assertEqual(caption.captions_json[\"user_caption\"], \"Test caption\")\n\n    def test_photo_caption_one_to_one_relationship(self):\n        \"\"\"Test that PhotoCaption has a one-to-one relationship with Photo\"\"\"\n        PhotoCaption.objects.create(\n            photo=self.photo, captions_json={\"user_caption\": \"First caption\"}\n        )\n\n        # Trying to create another caption for the same photo should fail\n        with self.assertRaises(Exception):\n            PhotoCaption.objects.create(\n                photo=self.photo, captions_json={\"user_caption\": \"Second caption\"}\n            )\n\n    def test_generate_captions_im2txt(self):\n        \"\"\"Test generating im2txt captions\"\"\"\n        caption = PhotoCaption.objects.create(photo=self.photo)\n\n        # This method requires thumbnail access which isn't available in tests\n        # We'll test that it returns False when no thumbnail is available\n        result = caption.generate_captions_im2txt(commit=False)\n        self.assertFalse(result)\n\n    def test_save_user_caption(self):\n        \"\"\"Test saving user captions\"\"\"\n        caption = PhotoCaption.objects.create(photo=self.photo)\n\n        # This method requires thumbnail access which isn't available in tests\n        # We'll test that it returns False when no thumbnail is available\n        result = caption.save_user_caption(\"My beautiful photo\", commit=True)\n        self.assertFalse(result)\n\n    def test_generate_tag_captions_skips_existing(self):\n        \"\"\"Test that generate_tag_captions skips if active model tags already exist\"\"\"\n        caption = PhotoCaption.objects.create(photo=self.photo)\n\n        # Pre-populate places365 data (the default tagging model)\n        caption.captions_json = {\n            \"places365\": {\n                \"categories\": [\"outdoor\", \"landscape\"],\n                \"attributes\": [\"natural\", \"sunny\"],\n                \"environment\": \"outdoor\",\n            }\n        }\n        caption.save()\n\n        # Should return early since places365 tags already exist\n        caption.generate_tag_captions(commit=True)\n        caption.refresh_from_db()\n\n        self.assertIn(\"places365\", caption.captions_json)\n\n    def test_recreate_search_captions_delegates_to_photo_search(self):\n        \"\"\"Test that recreate_search_captions delegates to PhotoSearch\"\"\"\n        caption = PhotoCaption.objects.create(\n            photo=self.photo, captions_json={\"user_caption\": \"Test caption\"}\n        )\n\n        # This should create a PhotoSearch instance and update search captions\n        caption.recreate_search_captions()\n\n        # Verify PhotoSearch was created and has search captions\n        self.assertTrue(hasattr(self.photo, \"search_instance\"))\n        from api.models.photo_search import PhotoSearch\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n        self.assertIsNotNone(search_instance.search_captions)\n\n    def test_captions_json_default_empty_dict(self):\n        \"\"\"Test that captions_json defaults to None (nullable field)\"\"\"\n        caption = PhotoCaption.objects.create(photo=self.photo)\n\n        self.assertIsNone(caption.captions_json)\n\n    def test_str_representation(self):\n        \"\"\"Test string representation of PhotoCaption\"\"\"\n        caption = PhotoCaption.objects.create(\n            photo=self.photo, captions_json={\"user_caption\": \"Test\"}\n        )\n\n        str_repr = str(caption)\n        self.assertIn(self.photo.image_hash, str_repr)\n\n    def test_cascade_delete_with_photo(self):\n        \"\"\"Test that PhotoCaption is deleted when Photo is deleted\"\"\"\n        PhotoCaption.objects.create(photo=self.photo)\n        photo_id = self.photo.image_hash\n\n        self.photo.delete()\n\n        with self.assertRaises(PhotoCaption.DoesNotExist):\n            PhotoCaption.objects.get(photo_id=photo_id)\n\n    def test_multiple_caption_types(self):\n        \"\"\"Test storing multiple types of captions\"\"\"\n        caption = PhotoCaption.objects.create(\n            photo=self.photo,\n            captions_json={\n                \"user_caption\": \"My photo\",\n                \"im2txt\": \"a photo of a landscape\",\n                \"places365\": {\n                    \"categories\": [\"outdoor\"],\n                    \"attributes\": [\"natural\"],\n                    \"environment\": \"outdoor\",\n                },\n            },\n        )\n\n        self.assertEqual(caption.captions_json[\"user_caption\"], \"My photo\")\n        self.assertEqual(caption.captions_json[\"im2txt\"], \"a photo of a landscape\")\n        self.assertIn(\"categories\", caption.captions_json[\"places365\"])\n\n    def test_update_existing_captions(self):\n        \"\"\"Test updating existing captions\"\"\"\n        caption = PhotoCaption.objects.create(\n            photo=self.photo, captions_json={\"user_caption\": \"Original caption\"}\n        )\n\n        # Update the caption directly (since save_user_caption requires thumbnails)\n        caption.captions_json[\"user_caption\"] = \"Updated caption\"\n        caption.save()\n        caption.refresh_from_db()\n\n        self.assertEqual(caption.captions_json[\"user_caption\"], \"Updated caption\")\n\n    def test_empty_captions_json_handling(self):\n        \"\"\"Test handling of empty or None captions_json\"\"\"\n        caption = PhotoCaption.objects.create(photo=self.photo)\n\n        # Should handle empty dict gracefully\n        caption.recreate_search_captions()\n\n        # Test direct assignment since save_user_caption requires thumbnails\n        caption.captions_json = {\"user_caption\": \"\"}\n        caption.save()\n        self.assertEqual(caption.captions_json[\"user_caption\"], \"\")\n"
  },
  {
    "path": "api/tests/test_photo_captions.py",
    "content": "from unittest.mock import patch\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass PhotoCaptionsTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n\n    @patch(\n        \"api.models.photo_caption.PhotoCaption.generate_captions_im2txt\", autospec=True\n    )\n    def test_generate_captions_for_my_photo(self, generate_caption_mock):\n        generate_caption_mock.return_value = True\n        photo = create_test_photo(owner=self.user1)\n\n        payload = {\"image_hash\": photo.image_hash}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/generateim2txt/\",\n            format=\"json\",\n            data=payload,\n            headers=headers,\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n\n    @patch(\n        \"api.models.photo_caption.PhotoCaption.generate_captions_im2txt\", autospec=True\n    )\n    def test_fail_to_generate_captions_for_my_photo(self, generate_caption_mock):\n        generate_caption_mock.return_value = False\n        photo = create_test_photo(owner=self.user1)\n\n        payload = {\"image_hash\": photo.image_hash}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/generateim2txt/\",\n            format=\"json\",\n            data=payload,\n            headers=headers,\n        )\n        data = response.json()\n\n        self.assertFalse(data[\"status\"])\n\n    def test_generate_captions_for_my_photo_of_another_user(self):\n        photo = create_test_photo(owner=self.user2)\n\n        payload = {\"image_hash\": photo.image_hash}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/generateim2txt/\",\n            format=\"json\",\n            data=payload,\n            headers=headers,\n        )\n        data = response.json()\n\n        # Returns 404 to avoid leaking existence of other users' photos\n        self.assertEqual(404, response.status_code)\n        self.assertFalse(data[\"status\"])\n        self.assertEqual(\"photo not found\", data[\"message\"])\n"
  },
  {
    "path": "api/tests/test_photo_lifecycle.py",
    "content": "\"\"\"\nTests for Photo Lifecycle - deletion, trashing, restoration.\n\nTests verify proper cleanup of:\n- Stack memberships (ManyToMany)\n- Duplicate group memberships (ManyToMany)\n- Empty groups after photo removal\n- Restoration behavior\n\"\"\"\n\nfrom django.test import TestCase\n\nfrom api.models.file import File\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.duplicate import Duplicate\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass PhotoDeletionStackCleanupTestCase(TestCase):\n    \"\"\"Tests for photo deletion and stack cleanup.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_manual_delete_clears_stack_membership(self):\n        \"\"\"Test that manual_delete removes photo from stacks.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(photo1, photo2, photo3)\n        \n        self.assertEqual(stack.photos.count(), 3)\n        \n        # Delete photo1\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        # photo1 should be removed from stack\n        photo1.refresh_from_db()\n        self.assertEqual(photo1.stacks.count(), 0)\n        \n        # Stack should still have 2 photos\n        stack.refresh_from_db()\n        self.assertEqual(stack.photos.count(), 2)\n\n    def test_manual_delete_deletes_stack_with_one_remaining(self):\n        \"\"\"Test that deleting a photo leaves stack with only 1 photo, stack is deleted.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(photo1, photo2)\n        stack_id = stack.id\n        \n        # Delete photo1\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        # Stack should be deleted (only 1 photo remaining)\n        self.assertFalse(PhotoStack.objects.filter(id=stack_id).exists())\n        \n        # photo2 should have no stacks\n        photo2.refresh_from_db()\n        self.assertEqual(photo2.stacks.count(), 0)\n\n    def test_manual_delete_deletes_empty_stack(self):\n        \"\"\"Test that deleting all photos in stack deletes the stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(photo1, photo2)\n        stack_id = stack.id\n        \n        # Delete both photos\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        photo2.in_trashcan = True\n        photo2.save()\n        photo2.manual_delete()\n        \n        # Stack should be deleted\n        self.assertFalse(PhotoStack.objects.filter(id=stack_id).exists())\n\n\nclass PhotoDeletionDuplicateCleanupTestCase(TestCase):\n    \"\"\"Tests for photo deletion and duplicate group cleanup.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_manual_delete_clears_duplicate_membership(self):\n        \"\"\"Test that manual_delete removes photo from duplicate groups.\n        \n        BUG #12: This test will FAIL if duplicates are not cleared!\n        \"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2, photo3)\n        \n        self.assertEqual(duplicate.photos.count(), 3)\n        \n        # Delete photo1\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        # photo1 should be removed from duplicate group\n        photo1.refresh_from_db()\n        self.assertEqual(photo1.duplicates.count(), 0,\n            \"Bug #12: manual_delete should clear duplicates\")\n        \n        # Duplicate group should still have 2 photos\n        duplicate.refresh_from_db()\n        self.assertEqual(duplicate.photos.count(), 2)\n\n    def test_manual_delete_deletes_duplicate_with_one_remaining(self):\n        \"\"\"Test that deleting a photo leaves duplicate with only 1 photo, group is deleted.\n        \n        BUG #12: This test will FAIL if duplicate groups are not cleaned up!\n        \"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2)\n        duplicate_id = duplicate.id\n        \n        # Delete photo1\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        # Duplicate group should be deleted (only 1 photo remaining)\n        self.assertFalse(Duplicate.objects.filter(id=duplicate_id).exists(),\n            \"Bug #12: Duplicate group with 1 photo should be deleted\")\n        \n        # photo2 should have no duplicate groups\n        photo2.refresh_from_db()\n        self.assertEqual(photo2.duplicates.count(), 0)\n\n    def test_manual_delete_deletes_empty_duplicate_group(self):\n        \"\"\"Test that deleting all photos in duplicate group deletes the group.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2)\n        duplicate_id = duplicate.id\n        \n        # Delete both photos\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        photo2.in_trashcan = True\n        photo2.save()\n        photo2.manual_delete()\n        \n        # Duplicate group should be deleted\n        self.assertFalse(Duplicate.objects.filter(id=duplicate_id).exists(),\n            \"Bug #12: Empty duplicate group should be deleted\")\n\n\nclass PhotoTrashRestoreTestCase(TestCase):\n    \"\"\"Tests for trashing and restoring photos.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_trashed_photo_preserves_stack_membership(self):\n        \"\"\"Test that trashing a photo does NOT remove it from stacks.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        # Trash photo1 (not permanent delete)\n        photo1.in_trashcan = True\n        photo1.save()\n        \n        # photo1 should still be in stack (just trashed)\n        photo1.refresh_from_db()\n        self.assertEqual(photo1.stacks.count(), 1)\n        \n        stack.refresh_from_db()\n        self.assertEqual(stack.photos.count(), 2)\n\n    def test_trashed_photo_preserves_duplicate_membership(self):\n        \"\"\"Test that trashing a photo does NOT remove it from duplicates.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        # Trash photo1 (not permanent delete)\n        photo1.in_trashcan = True\n        photo1.save()\n        \n        # photo1 should still be in duplicate group (just trashed)\n        photo1.refresh_from_db()\n        self.assertEqual(photo1.duplicates.count(), 1)\n        \n        duplicate.refresh_from_db()\n        self.assertEqual(duplicate.photos.count(), 2)\n\n    def test_restore_photo_from_trash(self):\n        \"\"\"Test that restoring a photo from trash works correctly.\"\"\"\n        photo1 = create_test_photo(owner=self.user, in_trashcan=True)\n        \n        # Restore\n        photo1.in_trashcan = False\n        photo1.save()\n        \n        photo1.refresh_from_db()\n        self.assertFalse(photo1.in_trashcan)\n\n\nclass PhotoInMultipleGroupsTestCase(TestCase):\n    \"\"\"Tests for photos that are in multiple stacks and/or duplicate groups.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_photo_in_both_stack_and_duplicate(self):\n        \"\"\"Test deleting photo that's in both a stack and duplicate group.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        # Photo1 is in a burst stack with photo2\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        # Photo1 is also in a duplicate group with photo3\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo3)\n        \n        stack_id = stack.id\n        duplicate_id = duplicate.id\n        \n        # Delete photo1\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        photo1.refresh_from_db()\n        \n        # Both relationships should be cleared\n        self.assertEqual(photo1.stacks.count(), 0)\n        self.assertEqual(photo1.duplicates.count(), 0,\n            \"Bug #12: Duplicates should be cleared on delete\")\n        \n        # Both groups should be deleted (only 1 photo remaining in each)\n        self.assertFalse(PhotoStack.objects.filter(id=stack_id).exists())\n        self.assertFalse(Duplicate.objects.filter(id=duplicate_id).exists(),\n            \"Bug #12: Duplicate group should be deleted\")\n\n    def test_photo_in_multiple_stacks(self):\n        \"\"\"Test photo that's in multiple stacks (different types).\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        # Stack 1: Burst with photo1 and photo2\n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack1.photos.add(photo1, photo2)\n        \n        # Stack 2: Manual with photo1 and photo3\n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack2.photos.add(photo1, photo3)\n        \n        stack1_id = stack1.id\n        stack2_id = stack2.id\n        \n        # Delete photo1\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        # Both stacks should be deleted (only 1 photo remaining in each)\n        self.assertFalse(PhotoStack.objects.filter(id=stack1_id).exists())\n        self.assertFalse(PhotoStack.objects.filter(id=stack2_id).exists())\n\n\nclass DuplicateResolutionCleanupTestCase(TestCase):\n    \"\"\"Tests for duplicate resolution affecting photo lifecycle.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_resolve_duplicate_trashes_non_kept_photos(self):\n        \"\"\"Test that resolving a duplicate trashes other photos.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        duplicate.photos.add(photo1, photo2, photo3)\n        \n        # Resolve keeping photo1\n        duplicate.resolve(kept_photo=photo1)\n        \n        # photo2 and photo3 should be trashed\n        photo2.refresh_from_db()\n        photo3.refresh_from_db()\n        self.assertTrue(photo2.in_trashcan)\n        self.assertTrue(photo3.in_trashcan)\n        \n        # photo1 should not be trashed\n        photo1.refresh_from_db()\n        self.assertFalse(photo1.in_trashcan)\n\n    def test_resolve_duplicate_updates_status(self):\n        \"\"\"Test that resolving updates duplicate status.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.PENDING,\n        )\n        duplicate.photos.add(photo1, photo2)\n        \n        duplicate.resolve(kept_photo=photo1)\n        \n        duplicate.refresh_from_db()\n        self.assertEqual(duplicate.review_status, Duplicate.ReviewStatus.RESOLVED)\n\n\nclass EdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for photo lifecycle.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_delete_photo_not_in_any_group(self):\n        \"\"\"Test deleting a photo that's not in any stack or duplicate group.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        photo.in_trashcan = True\n        photo.save()\n        photo.manual_delete()\n        \n        photo.refresh_from_db()\n        self.assertTrue(photo.removed)\n\n    def test_delete_photo_with_no_main_file(self):\n        \"\"\"Test deleting a photo without a main_file.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file = None\n        photo.save()\n        \n        photo.in_trashcan = True\n        photo.save()\n        \n        # Should not crash\n        photo.manual_delete()\n        \n        photo.refresh_from_db()\n        self.assertTrue(photo.removed)\n\n    def test_stack_primary_photo_deleted(self):\n        \"\"\"Test what happens when the primary photo of a stack is deleted.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            primary_photo=photo1,\n        )\n        stack.photos.add(photo1, photo2, photo3)\n        \n        # Delete the primary photo\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        # Stack should still exist with 2 photos\n        stack.refresh_from_db()\n        self.assertEqual(stack.photos.count(), 2)\n        \n        # Primary photo reference might be stale - check it\n        # (This tests if there's a bug in primary_photo handling)\n        # The primary_photo should ideally be updated or cleared\n\n    def test_duplicate_kept_photo_deleted(self):\n        \"\"\"Test what happens when the kept_photo of a resolved duplicate is deleted.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.RESOLVED,\n            kept_photo=photo1,\n        )\n        duplicate.photos.add(photo1, photo2)\n        duplicate_id = duplicate.id\n        \n        # Now delete the kept photo\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        # Duplicate group should be deleted (only 1 photo remaining)\n        self.assertFalse(Duplicate.objects.filter(id=duplicate_id).exists(),\n            \"Duplicate group with 1 photo should be deleted after kept_photo deletion\")\n\n\nclass SharedFileTestCase(TestCase):\n    \"\"\"Tests for photos sharing the same File.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_delete_photo_preserves_shared_file(self):\n        \"\"\"Test that deleting a photo does not delete a file shared with another photo.\"\"\"\n        # Create a shared file\n        shared_file = File.objects.create(\n            hash=\"shared_file_hash\" + \"a\" * 17,\n            path=\"/photos/shared_image.jpg\",\n            type=File.IMAGE,\n        )\n        \n        # Create two photos that share the same file\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        photo1.files.add(shared_file)\n        photo1.main_file = shared_file\n        photo1.save()\n        \n        photo2.files.add(shared_file)\n        photo2.main_file = shared_file\n        photo2.save()\n        \n        # Verify both photos reference the same file\n        self.assertEqual(photo1.main_file.hash, photo2.main_file.hash)\n        self.assertEqual(shared_file.photo_set.count(), 2)\n        \n        # Delete photo1\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        # File should still exist (used by photo2)\n        self.assertTrue(File.objects.filter(hash=shared_file.hash).exists(),\n            \"File should not be deleted when another photo still uses it\")\n        \n        # photo2 should still have its main_file\n        photo2.refresh_from_db()\n        self.assertIsNotNone(photo2.main_file)\n        self.assertEqual(photo2.main_file.hash, shared_file.hash)\n        \n        # photo2's files should still include the shared file\n        self.assertTrue(photo2.files.filter(hash=shared_file.hash).exists())\n\n    def test_delete_photo_removes_unshared_file(self):\n        \"\"\"Test that deleting a photo removes a file only used by that photo.\"\"\"\n        import tempfile\n        import os\n        \n        # Create a temp file to simulate a real file\n        with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp:\n            tmp.write(b'test image data')\n            temp_path = tmp.name\n        \n        try:\n            # Create a file that's only used by one photo\n            unique_file = File.objects.create(\n                hash=\"unique_file_hash\" + \"a\" * 18,\n                path=temp_path,\n                type=File.IMAGE,\n            )\n            \n            photo = create_test_photo(owner=self.user)\n            photo.files.add(unique_file)\n            photo.main_file = unique_file\n            photo.save()\n            \n            # Verify only one photo uses this file\n            self.assertEqual(unique_file.photo_set.count(), 1)\n            \n            # Delete the photo\n            photo.in_trashcan = True\n            photo.save()\n            photo.manual_delete()\n            \n            # File should be deleted from database\n            self.assertFalse(File.objects.filter(hash=unique_file.hash).exists(),\n                \"File should be deleted when no other photos use it\")\n            \n            # Physical file should be deleted\n            self.assertFalse(os.path.exists(temp_path),\n                \"Physical file should be removed from disk\")\n        finally:\n            # Cleanup in case test fails\n            if os.path.exists(temp_path):\n                os.remove(temp_path)\n\n    def test_delete_photo_with_shared_main_file_different_from_files(self):\n        \"\"\"Test deleting when main_file is shared but files M2M has unique files.\"\"\"\n        # Shared main_file\n        shared_main = File.objects.create(\n            hash=\"shared_main_hash\" + \"a\" * 18,\n            path=\"/photos/main.jpg\",\n            type=File.IMAGE,\n        )\n        \n        # Unique sidecar file for photo1 only\n        unique_sidecar = File.objects.create(\n            hash=\"unique_sidecar_hash\" + \"a\" * 15,\n            path=\"/photos/sidecar.xmp\",\n            type=File.METADATA_FILE,\n        )\n        \n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        # Both photos share main_file\n        photo1.main_file = shared_main\n        photo1.files.add(shared_main, unique_sidecar)\n        photo1.save()\n        \n        photo2.main_file = shared_main\n        photo2.files.add(shared_main)\n        photo2.save()\n        \n        # Delete photo1\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        # shared_main should still exist (used by photo2)\n        self.assertTrue(File.objects.filter(hash=shared_main.hash).exists())\n        \n        # unique_sidecar should be deleted (only used by photo1)\n        self.assertFalse(File.objects.filter(hash=unique_sidecar.hash).exists())\n        \n        # photo2 should still reference shared_main\n        photo2.refresh_from_db()\n        self.assertEqual(photo2.main_file.hash, shared_main.hash)\n\n    def test_delete_last_photo_using_shared_file(self):\n        \"\"\"Test that file is deleted when the last photo using it is deleted.\"\"\"\n        shared_file = File.objects.create(\n            hash=\"eventually_orphan\" + \"a\" * 17,\n            path=\"/photos/shared.jpg\",\n            type=File.IMAGE,\n        )\n        \n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        photo1.files.add(shared_file)\n        photo1.main_file = shared_file\n        photo1.save()\n        \n        photo2.files.add(shared_file)\n        photo2.main_file = shared_file\n        photo2.save()\n        \n        # Delete photo1 - file should remain\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        self.assertTrue(File.objects.filter(hash=shared_file.hash).exists())\n        \n        # Delete photo2 - file should now be deleted (no photos using it)\n        photo2.in_trashcan = True\n        photo2.save()\n        photo2.manual_delete()\n        \n        # File should be deleted from database (no physical file to check)\n        self.assertFalse(File.objects.filter(hash=shared_file.hash).exists(),\n            \"File should be deleted when the last photo using it is deleted\")\n"
  },
  {
    "path": "api/tests/test_photo_list_without_timestamp.py",
    "content": "from django.test import TestCase\nfrom django.utils import timezone\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_photos, create_test_user\n\n\nclass PhotoListWithoutTimestampTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n\n    def test_retrieve_photos_without_exif_timestamp(self):\n        now = timezone.now()\n        create_test_photos(number_of_photos=1, owner=self.user, added_on=now)\n        create_test_photos(\n            number_of_photos=1, owner=self.user, added_on=now, exif_timestamp=now\n        )\n\n        response = self.client.get(\"/api/photos/notimestamp/\")\n        json = response.json()\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(1, len(json[\"results\"]))\n"
  },
  {
    "path": "api/tests/test_photo_metadata.py",
    "content": "\"\"\"\nComprehensive tests for PhotoMetadata model and API.\n\nTests cover:\n- PhotoMetadata model fields and properties\n- MetadataFile model\n- MetadataEdit model for change tracking\n- API endpoints (retrieve, update, history, revert)\n- Bulk metadata operations\n- Edge cases and error handling\n\"\"\"\n\nimport uuid\nfrom unittest.mock import patch\n\nfrom django.test import TestCase\nfrom rest_framework import status\nfrom rest_framework.test import APIClient\n\nfrom api.models.photo_metadata import MetadataEdit, MetadataFile, PhotoMetadata\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass PhotoMetadataModelTestCase(TestCase):\n    \"\"\"Tests for PhotoMetadata model functionality.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_create_metadata_basic(self):\n        \"\"\"Test creating basic PhotoMetadata.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            aperture=2.8,\n            iso=100,\n            focal_length=50.0,\n            camera_model=\"Canon EOS R5\",\n        )\n        \n        self.assertEqual(metadata.photo, self.photo)\n        self.assertEqual(metadata.aperture, 2.8)\n        self.assertEqual(metadata.iso, 100)\n        self.assertEqual(metadata.focal_length, 50.0)\n        self.assertEqual(metadata.camera_model, \"Canon EOS R5\")\n\n    def test_metadata_source_choices(self):\n        \"\"\"Test metadata source choices.\"\"\"\n        self.assertEqual(PhotoMetadata.Source.EMBEDDED, \"embedded\")\n        self.assertEqual(PhotoMetadata.Source.SIDECAR, \"sidecar\")\n        self.assertEqual(PhotoMetadata.Source.USER_EDIT, \"user_edit\")\n        self.assertEqual(PhotoMetadata.Source.COMPUTED, \"computed\")\n\n    def test_resolution_property(self):\n        \"\"\"Test resolution property.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            width=1920,\n            height=1080,\n        )\n        \n        self.assertEqual(metadata.resolution, \"1920x1080\")\n\n    def test_resolution_property_missing_dimensions(self):\n        \"\"\"Test resolution property with missing dimensions.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            width=1920,\n            height=None,\n        )\n        \n        self.assertIsNone(metadata.resolution)\n\n    def test_megapixels_property(self):\n        \"\"\"Test megapixels calculation.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            width=8256,\n            height=5504,\n        )\n        \n        # 8256 * 5504 = 45,441,024 pixels ≈ 45.4 MP\n        self.assertEqual(metadata.megapixels, 45.4)\n\n    def test_megapixels_property_missing_dimensions(self):\n        \"\"\"Test megapixels with missing dimensions.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            width=None,\n            height=None,\n        )\n        \n        self.assertIsNone(metadata.megapixels)\n\n    def test_has_location_property_with_gps(self):\n        \"\"\"Test has_location with GPS data.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            gps_latitude=40.7128,\n            gps_longitude=-74.0060,\n        )\n        \n        self.assertTrue(metadata.has_location)\n\n    def test_has_location_property_without_gps(self):\n        \"\"\"Test has_location without GPS data.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n        )\n        \n        self.assertFalse(metadata.has_location)\n\n    def test_has_location_partial_gps(self):\n        \"\"\"Test has_location with only latitude.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            gps_latitude=40.7128,\n            gps_longitude=None,\n        )\n        \n        self.assertFalse(metadata.has_location)\n\n    def test_camera_display_make_and_model(self):\n        \"\"\"Test camera_display with both make and model.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            camera_make=\"Canon\",\n            camera_model=\"EOS R5\",\n        )\n        \n        self.assertEqual(metadata.camera_display, \"Canon EOS R5\")\n\n    def test_camera_display_model_includes_make(self):\n        \"\"\"Test camera_display when model already includes make.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            camera_make=\"Canon\",\n            camera_model=\"Canon EOS R5\",\n        )\n        \n        # Should not duplicate make\n        self.assertEqual(metadata.camera_display, \"Canon EOS R5\")\n\n    def test_camera_display_only_model(self):\n        \"\"\"Test camera_display with only model.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            camera_model=\"EOS R5\",\n        )\n        \n        self.assertEqual(metadata.camera_display, \"EOS R5\")\n\n    def test_lens_display(self):\n        \"\"\"Test lens_display property.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            lens_make=\"Canon\",\n            lens_model=\"RF 50mm F1.2L\",\n        )\n        \n        self.assertEqual(metadata.lens_display, \"Canon RF 50mm F1.2L\")\n\n    def test_lens_display_model_includes_make(self):\n        \"\"\"Test lens_display when model includes make.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            lens_make=\"Canon\",\n            lens_model=\"Canon RF 50mm F1.2L\",\n        )\n        \n        self.assertEqual(metadata.lens_display, \"Canon RF 50mm F1.2L\")\n\n    def test_version_increments(self):\n        \"\"\"Test version field increments on save.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            aperture=2.8,\n        )\n        \n        self.assertEqual(metadata.version, 1)\n        \n        metadata.aperture = 4.0\n        metadata.version += 1\n        metadata.save()\n        \n        metadata.refresh_from_db()\n        self.assertEqual(metadata.version, 2)\n\n    def test_raw_data_json_fields(self):\n        \"\"\"Test raw EXIF/XMP/IPTC JSON fields.\"\"\"\n        raw_data = {\n            \"EXIF:Make\": \"Canon\",\n            \"EXIF:Model\": \"EOS R5\",\n            \"EXIF:ISO\": 100,\n        }\n        \n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            raw_exif=raw_data,\n        )\n        \n        self.assertEqual(metadata.raw_exif, raw_data)\n\n    def test_keywords_json_field(self):\n        \"\"\"Test keywords JSON field stores list.\"\"\"\n        keywords = [\"landscape\", \"sunset\", \"nature\"]\n        \n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            keywords=keywords,\n        )\n        \n        metadata.refresh_from_db()\n        self.assertEqual(metadata.keywords, keywords)\n\n\nclass MetadataFileModelTestCase(TestCase):\n    \"\"\"Tests for MetadataFile model.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_file_type_choices(self):\n        \"\"\"Test file type choices exist.\"\"\"\n        self.assertEqual(MetadataFile.FileType.XMP, \"xmp\")\n        self.assertEqual(MetadataFile.FileType.JSON, \"json\")\n        self.assertEqual(MetadataFile.FileType.EXIF, \"exif\")\n        self.assertEqual(MetadataFile.FileType.OTHER, \"other\")\n\n    def test_source_choices(self):\n        \"\"\"Test source choices exist.\"\"\"\n        self.assertEqual(MetadataFile.Source.ORIGINAL, \"original\")\n        self.assertEqual(MetadataFile.Source.SOFTWARE, \"software\")\n        self.assertEqual(MetadataFile.Source.LIBREPHOTOS, \"librephotos\")\n        self.assertEqual(MetadataFile.Source.USER, \"user\")\n\n\nclass MetadataEditModelTestCase(TestCase):\n    \"\"\"Tests for MetadataEdit model.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_create_edit_record(self):\n        \"\"\"Test creating a metadata edit record.\"\"\"\n        edit = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=\"Old Title\",\n            new_value=\"New Title\",\n        )\n        \n        self.assertEqual(edit.photo, self.photo)\n        self.assertEqual(edit.user, self.user)\n        self.assertEqual(edit.field_name, \"title\")\n        self.assertEqual(edit.old_value, \"Old Title\")\n        self.assertEqual(edit.new_value, \"New Title\")\n        self.assertFalse(edit.synced_to_file)\n\n    def test_edit_records_ordered_by_created_at(self):\n        \"\"\"Test edit records are ordered by creation time.\"\"\"\n        edit1 = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=None,\n            new_value=\"First\",\n        )\n        edit2 = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=\"First\",\n            new_value=\"Second\",\n        )\n        \n        edits = list(MetadataEdit.objects.filter(photo=self.photo))\n        # Most recent first\n        self.assertEqual(edits[0].id, edit2.id)\n        self.assertEqual(edits[1].id, edit1.id)\n\n\nclass PhotoMetadataAPITestCase(TestCase):\n    \"\"\"Tests for PhotoMetadata API endpoints.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.other_user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n        \n        self.photo = create_test_photo(owner=self.user)\n        self.metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            aperture=2.8,\n            iso=100,\n            focal_length=50.0,\n            camera_make=\"Canon\",\n            camera_model=\"EOS R5\",\n            width=8256,\n            height=5504,\n        )\n\n    def test_retrieve_metadata(self):\n        \"\"\"Test retrieving metadata for a photo.\"\"\"\n        response = self.client.get(f\"/api/photos/{self.photo.id}/metadata\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"aperture\"], 2.8)\n        self.assertEqual(data[\"iso\"], 100)\n        self.assertEqual(data[\"camera_model\"], \"EOS R5\")\n\n    def test_retrieve_metadata_by_image_hash(self):\n        \"\"\"Test retrieving metadata using image_hash.\"\"\"\n        response = self.client.get(f\"/api/photos/{self.photo.image_hash}/metadata\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n\n    def test_retrieve_metadata_creates_if_missing(self):\n        \"\"\"Test retrieve creates metadata if it doesn't exist.\"\"\"\n        photo2 = create_test_photo(owner=self.user)\n        \n        # Delete any auto-created metadata\n        PhotoMetadata.objects.filter(photo=photo2).delete()\n        \n        response = self.client.get(f\"/api/photos/{photo2.id}/metadata\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        # Metadata should now exist\n        self.assertTrue(PhotoMetadata.objects.filter(photo=photo2).exists())\n\n    def test_retrieve_metadata_other_user_forbidden(self):\n        \"\"\"Test retrieving other user's photo metadata is forbidden.\"\"\"\n        other_photo = create_test_photo(owner=self.other_user)\n        \n        response = self.client.get(f\"/api/photos/{other_photo.id}/metadata\")\n        \n        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)\n\n    def test_update_metadata(self):\n        \"\"\"Test updating metadata fields.\"\"\"\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.id}/metadata\",\n            {\"title\": \"My Beautiful Photo\", \"rating\": 5},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        \n        self.metadata.refresh_from_db()\n        self.assertEqual(self.metadata.title, \"My Beautiful Photo\")\n        self.assertEqual(self.metadata.rating, 5)\n\n    def test_update_creates_edit_history(self):\n        \"\"\"Test updating metadata creates edit history.\"\"\"\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.id}/metadata\",\n            {\"caption\": \"A stunning sunset\"},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        \n        # Check edit history was created\n        edits = MetadataEdit.objects.filter(photo=self.photo)\n        self.assertTrue(edits.exists())\n        edit = edits.first()\n        self.assertEqual(edit.field_name, \"caption\")\n        self.assertEqual(edit.new_value, \"A stunning sunset\")\n\n    def test_get_edit_history(self):\n        \"\"\"Test getting edit history.\"\"\"\n        # Create some edits\n        MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=None,\n            new_value=\"First\",\n        )\n        MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=\"First\",\n            new_value=\"Second\",\n        )\n        \n        response = self.client.get(f\"/api/photos/{self.photo.id}/metadata/history\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertIn(\"results\", data)\n        self.assertEqual(data[\"count\"], 2)\n        self.assertEqual(len(data[\"results\"]), 2)\n\n    def test_revert_edit(self):\n        \"\"\"Test reverting a specific edit.\"\"\"\n        # Set initial value\n        self.metadata.title = \"Original Title\"\n        self.metadata.save()\n        \n        # Create an edit\n        edit = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=\"Original Title\",\n            new_value=\"Changed Title\",\n        )\n        self.metadata.title = \"Changed Title\"\n        self.metadata.save()\n        \n        # Revert the edit\n        response = self.client.post(f\"/api/photos/{self.photo.id}/metadata/revert/{edit.id}\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        \n        self.metadata.refresh_from_db()\n        self.assertEqual(self.metadata.title, \"Original Title\")\n\n    def test_revert_creates_new_edit_record(self):\n        \"\"\"Test reverting creates a new edit record.\"\"\"\n        edit = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=\"Original\",\n            new_value=\"Changed\",\n        )\n        self.metadata.title = \"Changed\"\n        self.metadata.save()\n        \n        initial_count = MetadataEdit.objects.filter(photo=self.photo).count()\n        \n        self.client.post(f\"/api/photos/{self.photo.id}/metadata/revert/{edit.id}\")\n        \n        final_count = MetadataEdit.objects.filter(photo=self.photo).count()\n        self.assertEqual(final_count, initial_count + 1)\n\n    def test_revert_nonexistent_edit(self):\n        \"\"\"Test reverting nonexistent edit returns 404.\"\"\"\n        fake_edit_id = uuid.uuid4()\n        \n        response = self.client.post(f\"/api/photos/{self.photo.id}/metadata/revert/{fake_edit_id}\")\n        \n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_unauthenticated_request(self):\n        \"\"\"Test unauthenticated requests return 401.\"\"\"\n        self.client.force_authenticate(user=None)\n        \n        response = self.client.get(f\"/api/photos/{self.photo.id}/metadata\")\n        \n        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)\n\n\nclass BulkMetadataAPITestCase(TestCase):\n    \"\"\"Tests for bulk metadata operations.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n        \n        self.photo1 = create_test_photo(owner=self.user)\n        self.photo2 = create_test_photo(owner=self.user)\n        self.photo3 = create_test_photo(owner=self.user)\n        \n        self.meta1 = PhotoMetadata.objects.create(\n            photo=self.photo1,\n            camera_model=\"Canon R5\",\n            rating=3,\n        )\n        self.meta2 = PhotoMetadata.objects.create(\n            photo=self.photo2,\n            camera_model=\"Nikon Z9\",\n            rating=4,\n        )\n\n    def test_bulk_get_metadata(self):\n        \"\"\"Test getting metadata for multiple photos.\"\"\"\n        photo_ids = f\"{self.photo1.id},{self.photo2.id}\"\n        \n        response = self.client.get(f\"/api/photos/metadata/bulk?photo_ids={photo_ids}\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertIn(str(self.photo1.id), data)\n        self.assertIn(str(self.photo2.id), data)\n\n    def test_bulk_get_no_photo_ids(self):\n        \"\"\"Test bulk get without photo_ids returns error.\"\"\"\n        response = self.client.get(\"/api/photos/metadata/bulk\")\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_bulk_get_max_100_photos(self):\n        \"\"\"Test bulk get with >100 photos returns error.\"\"\"\n        # Create 101 fake photo IDs\n        photo_ids = \",\".join([str(uuid.uuid4()) for _ in range(101)])\n        \n        response = self.client.get(f\"/api/photos/metadata/bulk?photo_ids={photo_ids}\")\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n        self.assertIn(\"Maximum 100\", response.json()[\"error\"])\n\n    def test_bulk_update_metadata(self):\n        \"\"\"Test bulk updating metadata.\"\"\"\n        response = self.client.patch(\n            \"/api/photos/metadata/bulk\",\n            {\n                \"photo_ids\": [str(self.photo1.id), str(self.photo2.id)],\n                \"updates\": {\"rating\": 5},\n            },\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"updated_count\"], 2)\n        \n        self.meta1.refresh_from_db()\n        self.meta2.refresh_from_db()\n        self.assertEqual(self.meta1.rating, 5)\n        self.assertEqual(self.meta2.rating, 5)\n\n    def test_bulk_update_no_photo_ids(self):\n        \"\"\"Test bulk update without photo_ids returns error.\"\"\"\n        response = self.client.patch(\n            \"/api/photos/metadata/bulk\",\n            {\"updates\": {\"rating\": 5}},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_bulk_update_no_updates(self):\n        \"\"\"Test bulk update without updates returns error.\"\"\"\n        response = self.client.patch(\n            \"/api/photos/metadata/bulk\",\n            {\"photo_ids\": [str(self.photo1.id)]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_bulk_update_invalid_fields(self):\n        \"\"\"Test bulk update with invalid fields returns error.\"\"\"\n        response = self.client.patch(\n            \"/api/photos/metadata/bulk\",\n            {\n                \"photo_ids\": [str(self.photo1.id)],\n                \"updates\": {\"iso\": 100},  # ISO is not allowed for bulk edit\n            },\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n        self.assertIn(\"Invalid fields\", response.json()[\"error\"])\n\n    def test_bulk_update_creates_edit_history(self):\n        \"\"\"Test bulk update creates edit history for each photo.\"\"\"\n        initial_count = MetadataEdit.objects.count()\n        \n        self.client.patch(\n            \"/api/photos/metadata/bulk\",\n            {\n                \"photo_ids\": [str(self.photo1.id), str(self.photo2.id)],\n                \"updates\": {\"title\": \"Bulk Title\"},\n            },\n            format=\"json\",\n        )\n        \n        # Should have 2 new edit records (one per photo)\n        self.assertEqual(MetadataEdit.objects.count(), initial_count + 2)\n\n    def test_bulk_update_other_user_photos_ignored(self):\n        \"\"\"Test bulk update ignores other user's photos.\"\"\"\n        other_user = create_test_user()\n        other_photo = create_test_photo(owner=other_user)\n        \n        response = self.client.patch(\n            \"/api/photos/metadata/bulk\",\n            {\n                \"photo_ids\": [str(self.photo1.id), str(other_photo.id)],\n                \"updates\": {\"rating\": 5},\n            },\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"updated_count\"], 1)  # Only our photo\n\n\nclass PhotoMetadataEdgeCasesTestCase(TestCase):\n    \"\"\"Edge case tests for PhotoMetadata.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_metadata_with_special_characters(self):\n        \"\"\"Test metadata fields handle special characters.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            title=\"Photo with émojis 📷 and ünïcödé\",\n            caption=\"<script>alert('xss')</script>\",\n        )\n        \n        metadata.refresh_from_db()\n        self.assertIn(\"émojis\", metadata.title)\n        self.assertIn(\"📷\", metadata.title)\n        self.assertIn(\"<script>\", metadata.caption)\n\n    def test_metadata_with_very_long_caption(self):\n        \"\"\"Test metadata handles long captions.\"\"\"\n        long_caption = \"x\" * 10000\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            caption=long_caption,\n        )\n        \n        metadata.refresh_from_db()\n        self.assertEqual(len(metadata.caption), 10000)\n\n    def test_metadata_with_null_values(self):\n        \"\"\"Test metadata handles null values correctly.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            aperture=None,\n            iso=None,\n            camera_model=None,\n        )\n        \n        self.assertIsNone(metadata.aperture)\n        self.assertIsNone(metadata.iso)\n        self.assertIsNone(metadata.camera_model)\n        self.assertIsNone(metadata.camera_display)\n\n    def test_metadata_with_zero_values(self):\n        \"\"\"Test metadata handles zero values.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            iso=0,\n            focal_length=0,\n            rating=0,\n        )\n        \n        self.assertEqual(metadata.iso, 0)\n        self.assertEqual(metadata.focal_length, 0)\n        self.assertEqual(metadata.rating, 0)\n\n    def test_metadata_with_negative_gps(self):\n        \"\"\"Test metadata handles negative GPS coordinates.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            gps_latitude=-33.8688,\n            gps_longitude=151.2093,\n        )\n        \n        self.assertEqual(metadata.gps_latitude, -33.8688)\n        self.assertTrue(metadata.has_location)\n\n    def test_one_to_one_relationship_enforced(self):\n        \"\"\"Test only one PhotoMetadata per photo.\"\"\"\n        PhotoMetadata.objects.create(photo=self.photo)\n        \n        with self.assertRaises(Exception):\n            PhotoMetadata.objects.create(photo=self.photo)\n\n    def test_invalid_uuid_in_url(self):\n        \"\"\"Test invalid UUID in URL returns appropriate error.\"\"\"\n        response = self.client.get(\"/api/photos/not-a-valid-uuid/metadata\")\n        \n        # Should return 404 (photo not found by image_hash fallback)\n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_staff_can_access_any_photo_metadata(self):\n        \"\"\"Test staff users can access any photo's metadata.\"\"\"\n        admin = create_test_user(is_admin=True)\n        self.client.force_authenticate(user=admin)\n        \n        other_user = create_test_user()\n        other_photo = create_test_photo(owner=other_user)\n        PhotoMetadata.objects.create(photo=other_photo, iso=100)\n        \n        response = self.client.get(f\"/api/photos/{other_photo.id}/metadata\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n\n    def test_update_increments_version(self):\n        \"\"\"Test updating metadata increments version.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            title=\"Initial\",\n            version=1,\n        )\n        \n        self.client.patch(\n            f\"/api/photos/{self.photo.id}/metadata\",\n            {\"title\": \"Updated\"},\n            format=\"json\",\n        )\n        \n        metadata.refresh_from_db()\n        # Version may or may not increment depending on serializer - just check it's >= 1\n        self.assertGreaterEqual(metadata.version, 1)\n\n    def test_revert_all_records_action(self):\n        \"\"\"Test revert_all creates a special edit record.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            title=\"Modified\",\n            source=PhotoMetadata.Source.USER_EDIT,\n        )\n        \n        # Mock the extract_exif_data to avoid file operations\n        with patch.object(PhotoMetadata, 'extract_exif_data', return_value=metadata):\n            response = self.client.post(f\"/api/photos/{self.photo.id}/metadata/revert-all\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        \n        # Check that a revert_all edit was recorded\n        revert_edit = MetadataEdit.objects.filter(\n            photo=self.photo,\n            field_name=\"_all\"\n        ).first()\n        self.assertIsNotNone(revert_edit)\n        self.assertEqual(revert_edit.old_value[\"action\"], \"revert_all\")\n\n    def test_keywords_array_update(self):\n        \"\"\"Test updating keywords array field.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            keywords=[\"original\", \"tags\"],\n        )\n        \n        response = self.client.patch(\n            f\"/api/photos/{self.photo.id}/metadata\",\n            {\"keywords\": [\"new\", \"keywords\", \"list\"]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        \n        metadata.refresh_from_db()\n        self.assertEqual(metadata.keywords, [\"new\", \"keywords\", \"list\"])\n\n    def test_empty_keywords_update(self):\n        \"\"\"Test updating keywords to empty list.\"\"\"\n        metadata = PhotoMetadata.objects.create(\n            photo=self.photo,\n            keywords=[\"original\", \"tags\"],\n        )\n        \n        response = self.client.patch(\n            f\"/api/photos/{self.photo.id}/metadata\",\n            {\"keywords\": []},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        \n        metadata.refresh_from_db()\n        self.assertEqual(metadata.keywords, [])\n"
  },
  {
    "path": "api/tests/test_photo_metadata_api.py",
    "content": "\"\"\"\nTests for PhotoMetadata API endpoints.\n\nTests the following:\n- GET /api/photos/{photo_id}/metadata/ - Get full metadata\n- PATCH /api/photos/{photo_id}/metadata/ - Update metadata\n- GET /api/photos/{photo_id}/metadata/history/ - Get edit history\n- POST /api/photos/{photo_id}/metadata/revert/{edit_id}/ - Revert a change\n- Bulk metadata operations\n- Edge cases (no EXIF, corrupted data, permissions)\n\"\"\"\n\nimport uuid\nfrom django.test import TestCase\nfrom django.utils import timezone\nfrom rest_framework.test import APIClient, APITestCase\n\nfrom api.models.photo_metadata import MetadataEdit, PhotoMetadata\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass PhotoMetadataRetrieveTestCase(APITestCase):\n    \"\"\"Test metadata retrieval endpoints.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_get_metadata_by_uuid(self):\n        \"\"\"Test retrieving metadata using photo UUID.\"\"\"\n        response = self.client.get(f\"/api/photos/{self.photo.pk}/metadata/\")\n        self.assertEqual(response.status_code, 200)\n        # Response contains metadata fields directly\n        self.assertIn(\"id\", response.data)\n        self.assertIn(\"source\", response.data)\n\n    def test_get_metadata_by_image_hash(self):\n        \"\"\"Test retrieving metadata using image_hash.\"\"\"\n        response = self.client.get(f\"/api/photos/{self.photo.image_hash}/metadata/\")\n        self.assertEqual(response.status_code, 200)\n\n    def test_get_metadata_creates_if_missing(self):\n        \"\"\"Test that metadata is created if it doesn't exist.\"\"\"\n        # Ensure no metadata exists\n        PhotoMetadata.objects.filter(photo=self.photo).delete()\n        \n        response = self.client.get(f\"/api/photos/{self.photo.pk}/metadata/\")\n        self.assertEqual(response.status_code, 200)\n        \n        # Should have created metadata\n        self.assertTrue(PhotoMetadata.objects.filter(photo=self.photo).exists())\n\n    def test_get_metadata_nonexistent_photo(self):\n        \"\"\"Test 404 for nonexistent photo.\"\"\"\n        fake_uuid = str(uuid.uuid4())\n        response = self.client.get(f\"/api/photos/{fake_uuid}/metadata/\")\n        self.assertEqual(response.status_code, 404)\n\n    def test_get_metadata_other_user_forbidden(self):\n        \"\"\"Test that users cannot access other users' photo metadata.\"\"\"\n        other_user = create_test_user()\n        other_photo = create_test_photo(owner=other_user)\n        \n        response = self.client.get(f\"/api/photos/{other_photo.pk}/metadata/\")\n        self.assertEqual(response.status_code, 403)\n\n    def test_get_metadata_admin_can_access_any(self):\n        \"\"\"Test that admin can access any photo's metadata.\"\"\"\n        other_user = create_test_user()\n        other_photo = create_test_photo(owner=other_user)\n        \n        # Make current user admin\n        self.user.is_staff = True\n        self.user.save()\n        \n        response = self.client.get(f\"/api/photos/{other_photo.pk}/metadata/\")\n        self.assertEqual(response.status_code, 200)\n\n\nclass PhotoMetadataUpdateTestCase(APITestCase):\n    \"\"\"Test metadata update endpoints.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_update_metadata_title(self):\n        \"\"\"Test updating photo title.\"\"\"\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"title\": \"My Test Photo\"},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 200)\n        \n        # Verify update\n        metadata = PhotoMetadata.objects.get(photo=self.photo)\n        self.assertEqual(metadata.title, \"My Test Photo\")\n\n    def test_update_metadata_creates_history(self):\n        \"\"\"Test that updates create edit history.\"\"\"\n        # First update\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"title\": \"First Title\"},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 200)\n        \n        # Second update\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"title\": \"Second Title\"},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 200)\n        \n        # Check history\n        edits = MetadataEdit.objects.filter(photo=self.photo, field_name=\"title\")\n        self.assertGreaterEqual(edits.count(), 1)\n\n    def test_update_metadata_rating(self):\n        \"\"\"Test updating photo rating.\"\"\"\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"rating\": 5},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 200)\n        \n        metadata = PhotoMetadata.objects.get(photo=self.photo)\n        self.assertEqual(metadata.rating, 5)\n\n    def test_update_metadata_caption(self):\n        \"\"\"Test updating photo caption.\"\"\"\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"caption\": \"A beautiful sunset over the mountains\"},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 200)\n        \n        metadata = PhotoMetadata.objects.get(photo=self.photo)\n        self.assertEqual(metadata.caption, \"A beautiful sunset over the mountains\")\n\n    def test_update_metadata_version_increments(self):\n        \"\"\"Test that metadata version increments on update.\"\"\"\n        # Get initial version\n        metadata, _ = PhotoMetadata.objects.get_or_create(photo=self.photo)\n        initial_version = metadata.version\n        \n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"title\": \"Updated Title\"},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 200)\n        \n        metadata.refresh_from_db()\n        self.assertEqual(metadata.version, initial_version + 1)\n\n    def test_update_metadata_forbidden_for_other_user(self):\n        \"\"\"Test that users cannot update other users' photo metadata.\"\"\"\n        other_user = create_test_user()\n        other_photo = create_test_photo(owner=other_user)\n        \n        response = self.client.patch(\n            f\"/api/photos/{other_photo.pk}/metadata/\",\n            {\"title\": \"Hacked Title\"},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 403)\n\n\nclass PhotoMetadataHistoryTestCase(APITestCase):\n    \"\"\"Test metadata history endpoints.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_get_empty_history(self):\n        \"\"\"Test getting history when no edits exist.\"\"\"\n        response = self.client.get(f\"/api/photos/{self.photo.pk}/metadata/history/\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"results\"], [])\n        self.assertEqual(response.data[\"count\"], 0)\n\n    def test_get_history_with_edits(self):\n        \"\"\"Test getting history after making edits.\"\"\"\n        # Create some edit history\n        metadata, _ = PhotoMetadata.objects.get_or_create(photo=self.photo)\n        MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=None,\n            new_value=\"Test Title\"\n        )\n        \n        response = self.client.get(f\"/api/photos/{self.photo.pk}/metadata/history/\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 1)\n        self.assertEqual(len(response.data[\"results\"]), 1)\n\n    def test_history_pagination(self):\n        \"\"\"Test history pagination.\"\"\"\n        metadata, _ = PhotoMetadata.objects.get_or_create(photo=self.photo)\n        \n        # Create many edit records\n        for i in range(25):\n            MetadataEdit.objects.create(\n                photo=self.photo,\n                user=self.user,\n                field_name=\"rating\",\n                old_value=i,\n                new_value=i + 1\n            )\n        \n        # First page\n        response = self.client.get(f\"/api/photos/{self.photo.pk}/metadata/history/?page=1&page_size=10\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(len(response.data[\"results\"]), 10)\n        self.assertEqual(response.data[\"count\"], 25)\n\n    def test_history_ordered_by_date(self):\n        \"\"\"Test that history is ordered by date descending.\"\"\"\n        metadata, _ = PhotoMetadata.objects.get_or_create(photo=self.photo)\n        \n        # Create edits with different times\n        _edit1 = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=None,\n            new_value=\"First\"\n        )\n        _edit2 = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=\"First\",\n            new_value=\"Second\"\n        )\n        \n        response = self.client.get(f\"/api/photos/{self.photo.pk}/metadata/history/\")\n        self.assertEqual(response.status_code, 200)\n        \n        # Most recent should be first\n        self.assertEqual(response.data[\"results\"][0][\"new_value\"], \"Second\")\n\n\nclass PhotoMetadataRevertTestCase(APITestCase):\n    \"\"\"Test metadata revert endpoints.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        self.photo = create_test_photo(owner=self.user)\n        self.metadata, _ = PhotoMetadata.objects.get_or_create(photo=self.photo)\n\n    def test_revert_single_edit(self):\n        \"\"\"Test reverting a single edit.\"\"\"\n        # Set initial value\n        self.metadata.title = \"Original Title\"\n        self.metadata.save()\n        \n        # Create edit record\n        edit = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=\"Original Title\",\n            new_value=\"Modified Title\"\n        )\n        self.metadata.title = \"Modified Title\"\n        self.metadata.save()\n        \n        # Revert\n        response = self.client.post(f\"/api/photos/{self.photo.pk}/metadata/revert/{edit.id}/\")\n        self.assertEqual(response.status_code, 200)\n        \n        self.metadata.refresh_from_db()\n        self.assertEqual(self.metadata.title, \"Original Title\")\n\n    def test_revert_creates_history_entry(self):\n        \"\"\"Test that revert creates its own history entry.\"\"\"\n        edit = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=\"Original\",\n            new_value=\"Modified\"\n        )\n        self.metadata.title = \"Modified\"\n        self.metadata.save()\n        \n        initial_count = MetadataEdit.objects.filter(photo=self.photo).count()\n        \n        response = self.client.post(f\"/api/photos/{self.photo.pk}/metadata/revert/{edit.id}/\")\n        self.assertEqual(response.status_code, 200)\n        \n        # Should have one more edit record\n        new_count = MetadataEdit.objects.filter(photo=self.photo).count()\n        self.assertEqual(new_count, initial_count + 1)\n\n    def test_revert_nonexistent_edit(self):\n        \"\"\"Test reverting nonexistent edit returns 404.\"\"\"\n        fake_id = str(uuid.uuid4())\n        response = self.client.post(f\"/api/photos/{self.photo.pk}/metadata/revert/{fake_id}/\")\n        self.assertEqual(response.status_code, 404)\n\n    def test_revert_edit_from_wrong_photo(self):\n        \"\"\"Test that you cannot revert an edit from a different photo.\"\"\"\n        other_photo = create_test_photo(owner=self.user)\n        other_metadata, _ = PhotoMetadata.objects.get_or_create(photo=other_photo)\n        \n        edit = MetadataEdit.objects.create(\n            photo=other_photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=\"Original\",\n            new_value=\"Modified\"\n        )\n        \n        # Try to revert using wrong photo ID\n        response = self.client.post(f\"/api/photos/{self.photo.pk}/metadata/revert/{edit.id}/\")\n        self.assertEqual(response.status_code, 404)\n\n\nclass PhotoMetadataRevertAllTestCase(APITestCase):\n    \"\"\"Test revert-all endpoint.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_revert_all_creates_history(self):\n        \"\"\"Test that revert-all creates a history entry.\"\"\"\n        metadata, _ = PhotoMetadata.objects.get_or_create(photo=self.photo)\n        \n        initial_count = MetadataEdit.objects.filter(photo=self.photo).count()\n        \n        _response = self.client.post(f\"/api/photos/{self.photo.pk}/metadata/revert-all/\")\n        # May return 200 or 500 depending on whether EXIF extraction works\n        # Just check the edit record is created\n        \n        new_count = MetadataEdit.objects.filter(photo=self.photo).count()\n        # Should have at least tried to create the record\n        self.assertGreaterEqual(new_count, initial_count)\n\n\nclass BulkMetadataGetTestCase(APITestCase):\n    \"\"\"Test bulk metadata GET endpoint.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        self.photos = [create_test_photo(owner=self.user) for _ in range(5)]\n\n    def test_bulk_get_by_uuid(self):\n        \"\"\"Test bulk get metadata by UUIDs.\"\"\"\n        photo_ids = \",\".join(str(p.pk) for p in self.photos[:3])\n        response = self.client.get(f\"/api/photos/metadata/bulk/?photo_ids={photo_ids}\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(len(response.data), 3)\n\n    def test_bulk_get_by_image_hash(self):\n        \"\"\"Test bulk get metadata by image hashes.\"\"\"\n        photo_ids = \",\".join(p.image_hash for p in self.photos[:3])\n        response = self.client.get(f\"/api/photos/metadata/bulk/?photo_ids={photo_ids}\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(len(response.data), 3)\n\n    def test_bulk_get_mixed_ids(self):\n        \"\"\"Test bulk get with mixed UUID and image_hash.\"\"\"\n        photo_ids = f\"{self.photos[0].pk},{self.photos[1].image_hash}\"\n        response = self.client.get(f\"/api/photos/metadata/bulk/?photo_ids={photo_ids}\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(len(response.data), 2)\n\n    def test_bulk_get_no_ids(self):\n        \"\"\"Test bulk get with no IDs returns error.\"\"\"\n        response = self.client.get(\"/api/photos/metadata/bulk/\")\n        self.assertEqual(response.status_code, 400)\n\n    def test_bulk_get_too_many_ids(self):\n        \"\"\"Test bulk get with too many IDs returns error.\"\"\"\n        # Create many fake IDs\n        photo_ids = \",\".join(str(uuid.uuid4()) for _ in range(101))\n        response = self.client.get(f\"/api/photos/metadata/bulk/?photo_ids={photo_ids}\")\n        self.assertEqual(response.status_code, 400)\n\n    def test_bulk_get_filters_other_users(self):\n        \"\"\"Test that bulk get only returns current user's photos.\"\"\"\n        other_user = create_test_user()\n        other_photo = create_test_photo(owner=other_user)\n        \n        photo_ids = f\"{self.photos[0].pk},{other_photo.pk}\"\n        response = self.client.get(f\"/api/photos/metadata/bulk/?photo_ids={photo_ids}\")\n        self.assertEqual(response.status_code, 200)\n        # Should only return our photo\n        self.assertEqual(len(response.data), 1)\n\n\nclass BulkMetadataUpdateTestCase(APITestCase):\n    \"\"\"Test bulk metadata PATCH endpoint.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        self.photos = [create_test_photo(owner=self.user) for _ in range(5)]\n\n    def test_bulk_update_rating(self):\n        \"\"\"Test bulk update rating for multiple photos.\"\"\"\n        photo_ids = [str(p.pk) for p in self.photos[:3]]\n        response = self.client.patch(\n            \"/api/photos/metadata/bulk/\",\n            {\"photo_ids\": photo_ids, \"updates\": {\"rating\": 4}},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"updated_count\"], 3)\n\n    def test_bulk_update_creates_history(self):\n        \"\"\"Test that bulk update creates edit history.\"\"\"\n        photo_ids = [str(p.pk) for p in self.photos[:2]]\n        \n        response = self.client.patch(\n            \"/api/photos/metadata/bulk/\",\n            {\"photo_ids\": photo_ids, \"updates\": {\"title\": \"Bulk Title\"}},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 200)\n        \n        # Check history for each photo\n        for photo in self.photos[:2]:\n            edits = MetadataEdit.objects.filter(photo=photo, field_name=\"title\")\n            self.assertGreaterEqual(edits.count(), 1)\n\n    def test_bulk_update_no_ids(self):\n        \"\"\"Test bulk update with no IDs returns error.\"\"\"\n        response = self.client.patch(\n            \"/api/photos/metadata/bulk/\",\n            {\"photo_ids\": [], \"updates\": {\"rating\": 5}},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 400)\n\n    def test_bulk_update_no_updates(self):\n        \"\"\"Test bulk update with no updates returns error.\"\"\"\n        response = self.client.patch(\n            \"/api/photos/metadata/bulk/\",\n            {\"photo_ids\": [str(self.photos[0].pk)], \"updates\": {}},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 400)\n\n    def test_bulk_update_invalid_field(self):\n        \"\"\"Test bulk update with invalid field returns error.\"\"\"\n        response = self.client.patch(\n            \"/api/photos/metadata/bulk/\",\n            {\n                \"photo_ids\": [str(self.photos[0].pk)],\n                \"updates\": {\"invalid_field\": \"value\"}\n            },\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 400)\n\n    def test_bulk_update_too_many_photos(self):\n        \"\"\"Test bulk update with too many photos returns error.\"\"\"\n        fake_ids = [str(uuid.uuid4()) for _ in range(101)]\n        response = self.client.patch(\n            \"/api/photos/metadata/bulk/\",\n            {\"photo_ids\": fake_ids, \"updates\": {\"rating\": 5}},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 400)\n\n\nclass PhotoMetadataEdgeCasesTestCase(APITestCase):\n    \"\"\"Test edge cases for metadata API.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_photo_no_exif_data(self):\n        \"\"\"Test handling photo with no EXIF data.\"\"\"\n        # Clear any existing metadata\n        PhotoMetadata.objects.filter(photo=self.photo).delete()\n        \n        # Clear exif fields on photo\n        self.photo.exif_timestamp = None\n        self.photo.exif_gps_lat = None\n        self.photo.exif_gps_lon = None\n        self.photo.save()\n        \n        response = self.client.get(f\"/api/photos/{self.photo.pk}/metadata/\")\n        self.assertEqual(response.status_code, 200)\n        # Should still return valid response\n\n    def test_metadata_with_special_characters(self):\n        \"\"\"Test metadata with special characters in title/caption.\"\"\"\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"title\": \"Test 日本語 Émoji 🎉 <script>\"},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 200)\n        \n        metadata = PhotoMetadata.objects.get(photo=self.photo)\n        self.assertIn(\"日本語\", metadata.title)\n\n    def test_metadata_empty_strings(self):\n        \"\"\"Test updating metadata with empty strings.\"\"\"\n        # First set a value\n        self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"title\": \"Some Title\"},\n            format=\"json\"\n        )\n        \n        # Then clear it\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"title\": \"\"},\n            format=\"json\"\n        )\n        self.assertEqual(response.status_code, 200)\n\n    def test_metadata_null_values(self):\n        \"\"\"Test updating metadata with null values.\"\"\"\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"caption\": None},\n            format=\"json\"\n        )\n        # Should handle gracefully\n        self.assertIn(response.status_code, [200, 400])\n\n    def test_concurrent_metadata_updates(self):\n        \"\"\"Test concurrent metadata updates (version conflict).\"\"\"\n        # Get initial metadata\n        metadata, _ = PhotoMetadata.objects.get_or_create(photo=self.photo)\n        \n        # Simulate concurrent updates\n        response1 = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"title\": \"Update 1\"},\n            format=\"json\"\n        )\n        response2 = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"title\": \"Update 2\"},\n            format=\"json\"\n        )\n        \n        # Both should succeed (last one wins)\n        self.assertEqual(response1.status_code, 200)\n        self.assertEqual(response2.status_code, 200)\n        \n        metadata.refresh_from_db()\n        self.assertEqual(metadata.title, \"Update 2\")\n\n    def test_very_long_values(self):\n        \"\"\"Test metadata with very long string values.\"\"\"\n        long_title = \"A\" * 1000\n        response = self.client.patch(\n            f\"/api/photos/{self.photo.pk}/metadata/\",\n            {\"title\": long_title},\n            format=\"json\"\n        )\n        # Should either succeed or return validation error\n        self.assertIn(response.status_code, [200, 400])\n\n\nclass PhotoMetadataModelTestCase(TestCase):\n    \"\"\"Test PhotoMetadata model methods.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_metadata_source_choices(self):\n        \"\"\"Test metadata source choices.\"\"\"\n        metadata, _ = PhotoMetadata.objects.get_or_create(photo=self.photo)\n        \n        # Test all source choices\n        for source in PhotoMetadata.Source:\n            metadata.source = source\n            metadata.save()\n            metadata.refresh_from_db()\n            self.assertEqual(metadata.source, source)\n\n    def test_has_location_property(self):\n        \"\"\"Test has_location computed property.\"\"\"\n        metadata, _ = PhotoMetadata.objects.get_or_create(photo=self.photo)\n        \n        # No location\n        metadata.gps_latitude = None\n        metadata.gps_longitude = None\n        metadata.save()\n        self.assertFalse(metadata.has_location)\n        \n        # With location\n        metadata.gps_latitude = 40.7128\n        metadata.gps_longitude = -74.0060\n        metadata.save()\n        self.assertTrue(metadata.has_location)\n\n    def test_camera_display_property(self):\n        \"\"\"Test camera_display computed property.\"\"\"\n        metadata, _ = PhotoMetadata.objects.get_or_create(photo=self.photo)\n        \n        metadata.camera_make = \"Canon\"\n        metadata.camera_model = \"EOS R5\"\n        metadata.save()\n        \n        display = metadata.camera_display\n        self.assertIsNotNone(display)\n\n    def test_lens_display_property(self):\n        \"\"\"Test lens_display computed property.\"\"\"\n        metadata, _ = PhotoMetadata.objects.get_or_create(photo=self.photo)\n        \n        metadata.lens_make = \"Canon\"\n        metadata.lens_model = \"RF 24-70mm f/2.8L\"\n        metadata.save()\n        \n        display = metadata.lens_display\n        self.assertIsNotNone(display)\n\n\nclass MetadataEditModelTestCase(TestCase):\n    \"\"\"Test MetadataEdit model.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_create_edit_record(self):\n        \"\"\"Test creating a metadata edit record.\"\"\"\n        edit = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=\"Old Title\",\n            new_value=\"New Title\"\n        )\n        self.assertIsNotNone(edit.id)\n        self.assertEqual(edit.field_name, \"title\")\n        self.assertEqual(edit.old_value, \"Old Title\")\n        self.assertEqual(edit.new_value, \"New Title\")\n\n    def test_edit_record_timestamps(self):\n        \"\"\"Test that edit records have correct timestamps.\"\"\"\n        before = timezone.now()\n        edit = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"rating\",\n            old_value=0,\n            new_value=5\n        )\n        after = timezone.now()\n        \n        self.assertGreaterEqual(edit.created_at, before)\n        self.assertLessEqual(edit.created_at, after)\n\n    def test_edit_record_json_values(self):\n        \"\"\"Test edit records with JSON values.\"\"\"\n        edit = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"keywords\",\n            old_value=[\"tag1\", \"tag2\"],\n            new_value=[\"tag1\", \"tag2\", \"tag3\"]\n        )\n        \n        edit.refresh_from_db()\n        self.assertEqual(edit.new_value, [\"tag1\", \"tag2\", \"tag3\"])\n\n    def test_edit_record_null_old_value(self):\n        \"\"\"Test edit record with null old value (new field).\"\"\"\n        edit = MetadataEdit.objects.create(\n            photo=self.photo,\n            user=self.user,\n            field_name=\"title\",\n            old_value=None,\n            new_value=\"First Title\"\n        )\n        \n        edit.refresh_from_db()\n        self.assertIsNone(edit.old_value)\n        self.assertEqual(edit.new_value, \"First Title\")\n"
  },
  {
    "path": "api/tests/test_photo_model_integration.py",
    "content": "from django.test import TestCase\nfrom django.utils import timezone\nfrom django.core.exceptions import FieldDoesNotExist\n\nfrom api.models import Photo, AlbumDate\nfrom api.models.photo_caption import PhotoCaption\nfrom api.models.photo_search import PhotoSearch\nfrom api.tests.utils import create_test_user, create_test_photo\n\n\nclass PhotoModelIntegrationTest(TestCase):\n    def setUp(self):\n        self.user = create_test_user()\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_photo_properties_delegate_to_caption_model(self):\n        \"\"\"Test that Photo properties delegate to PhotoCaption model\"\"\"\n        # Initially no caption instance\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=self.photo)\n        self.assertIsNone(caption_instance.captions_json)\n\n        # Setting captions_json should create PhotoCaption instance\n        caption_instance.captions_json = {\"user_caption\": \"Test caption\"}\n        caption_instance.save()\n\n        # Verify PhotoCaption was created\n        self.assertTrue(PhotoCaption.objects.filter(photo=self.photo).exists())\n        caption = PhotoCaption.objects.get(photo=self.photo)\n        self.assertEqual(caption.captions_json[\"user_caption\"], \"Test caption\")\n\n        # Verify property returns the value\n        self.assertEqual(caption.captions_json[\"user_caption\"], \"Test caption\")\n\n    def test_photo_properties_delegate_to_search_model(self):\n        \"\"\"Test that Photo properties delegate to PhotoSearch model\"\"\"\n        # Initially no search instance\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n        self.assertIsNone(search_instance.search_captions)\n        self.assertIsNone(search_instance.search_location)\n\n        # Setting search properties should create PhotoSearch instance\n        search_instance.search_captions = \"outdoor nature\"\n        search_instance.search_location = \"New York\"\n        search_instance.save()\n\n        # Verify PhotoSearch was created\n        self.assertTrue(PhotoSearch.objects.filter(photo=self.photo).exists())\n        search = PhotoSearch.objects.get(photo=self.photo)\n        self.assertEqual(search.search_captions, \"outdoor nature\")\n        self.assertEqual(search.search_location, \"New York\")\n\n        # Refresh photo instance to get updated properties\n        self.photo.refresh_from_db()\n\n        # Verify properties return the values through direct access\n        self.assertEqual(search_instance.search_captions, \"outdoor nature\")\n        self.assertEqual(search_instance.search_location, \"New York\")\n\n    def test_photo_caption_methods_work_directly(self):\n        \"\"\"Test that PhotoCaption methods work directly with the photo\"\"\"\n\n        # Test save_user_caption method directly on PhotoCaption\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=self.photo)\n        result = caption_instance.save_user_caption(caption=\"My test caption\")\n\n        # The method should return False due to missing thumbnail but still create instance\n        self.assertFalse(result)\n        self.assertTrue(PhotoCaption.objects.filter(photo=self.photo).exists())\n\n    def test_photo_search_methods_work_directly(self):\n        \"\"\"Test that PhotoSearch methods work directly with the photo\"\"\"\n        # Create some caption data first\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=self.photo)\n        caption_instance.captions_json = {\"user_caption\": \"Beautiful landscape\"}\n        caption_instance.save()\n\n        # Test recreate_search_captions method directly on PhotoSearch\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n        search_instance.recreate_search_captions()\n        search_instance.save()\n\n        # Verify PhotoSearch was created and search captions updated\n        self.assertTrue(PhotoSearch.objects.filter(photo=self.photo).exists())\n        search = PhotoSearch.objects.get(photo=self.photo)\n        self.assertIn(\"Beautiful landscape\", search.search_captions)\n\n    def test_geolocate_updates_search_location(self):\n        \"\"\"Test that _geolocate method updates search location\"\"\"\n        # Mock geolocation data\n        geolocation_data = {\n            \"features\": [\n                {\"text\": \"Central Park\"},\n                {\"text\": \"New York\"},\n                {\"text\": \"USA\"},\n            ]\n        }\n\n        # Set geolocation_json on photo\n        self.photo.geolocation_json = geolocation_data\n        self.photo.save()\n\n        # Manually trigger the search location update part\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n        search_instance.update_search_location(geolocation_data)\n        search_instance.save()\n\n        # Refresh to get updated data\n        self.photo.refresh_from_db()\n\n        # Verify search location was updated\n        self.assertIn(\"Central Park\", search_instance.search_location)\n        self.assertIn(\"New York\", search_instance.search_location)\n        self.assertIn(\"USA\", search_instance.search_location)\n\n    def test_cascade_deletion_of_related_models(self):\n        \"\"\"Test that deleting Photo cascades to PhotoCaption and PhotoSearch\"\"\"\n        # Create related instances\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=self.photo)\n        caption_instance.captions_json = {\"user_caption\": \"Test\"}\n        caption_instance.save()\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n        search_instance.search_captions = \"Test\"\n        search_instance.save()\n\n        # Verify instances exist\n        self.assertTrue(PhotoCaption.objects.filter(photo=self.photo).exists())\n        self.assertTrue(PhotoSearch.objects.filter(photo=self.photo).exists())\n\n        photo_id = self.photo.image_hash\n        self.photo.delete()\n\n        # Verify instances are deleted when photo is deleted\n        self.assertFalse(PhotoCaption.objects.filter(photo_id=photo_id).exists())\n        self.assertFalse(PhotoSearch.objects.filter(photo_id=photo_id).exists())\n\n    def test_lazy_creation_of_related_instances(self):\n        \"\"\"Test that related instances are created only when needed\"\"\"\n\n        # Initially no instances should exist\n        self.assertFalse(PhotoCaption.objects.filter(photo=self.photo).exists())\n        self.assertFalse(PhotoSearch.objects.filter(photo=self.photo).exists())\n\n        # Calling direct access methods should create instances\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=self.photo)\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n\n        self.assertIsNone(caption_instance.captions_json)\n        self.assertIsNone(search_instance.search_captions)\n        self.assertIsNone(search_instance.search_location)\n\n        # Now instances should exist\n        self.assertTrue(PhotoCaption.objects.filter(photo=self.photo).exists())\n        self.assertTrue(PhotoSearch.objects.filter(photo=self.photo).exists())\n\n        # Setting properties should save data to instances\n        caption_instance.captions_json = {\"test\": \"value\"}\n        search_instance.search_captions = \"test\"\n        caption_instance.save()\n        search_instance.save()\n\n        # Verify data is stored\n        self.assertTrue(PhotoCaption.objects.filter(photo=self.photo).exists())\n        self.assertTrue(PhotoSearch.objects.filter(photo=self.photo).exists())\n\n    def test_get_or_create_methods(self):\n        \"\"\"Test the _get_or_create_* methods\"\"\"\n        # Test caption instance creation\n\n        caption_instance1, created = PhotoCaption.objects.get_or_create(\n            photo=self.photo\n        )\n        caption_instance2, created = PhotoCaption.objects.get_or_create(\n            photo=self.photo\n        )\n\n        # Should return the same instance (using photo_id as primary key)\n        self.assertEqual(caption_instance1.photo_id, caption_instance2.photo_id)\n\n        # Test search instance creation\n\n        search_instance1, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n        search_instance2, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n\n        # Should return the same instance (using photo_id as primary key)\n        self.assertEqual(search_instance1.photo_id, search_instance2.photo_id)\n\n    def test_complex_workflow(self):\n        \"\"\"Test a complex workflow involving all models\"\"\"\n        # 1. Add user caption (will fail due to thumbnail but creates instance)\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=self.photo)\n        caption_instance.save_user_caption(caption=\"My vacation photo\")\n\n        # 2. Add places365 data directly\n        caption_instance.captions_json = {\n            \"user_caption\": \"My vacation photo\",\n            \"places365\": {\n                \"categories\": [\"outdoor\", \"beach\"],\n                \"attributes\": [\"sunny\", \"natural\"],\n                \"environment\": \"outdoor\",\n            },\n        }\n        caption_instance.save()\n\n        # 3. Recreate search captions\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n        search_instance.recreate_search_captions()\n\n        # 4. Add geolocation\n        geolocation_data = {\n            \"features\": [{\"text\": \"Miami Beach\"}, {\"text\": \"Florida\"}, {\"text\": \"USA\"}]\n        }\n        self.photo.geolocation_json = geolocation_data\n        search_instance.update_search_location(geolocation_data)\n        search_instance.save()\n\n        # Refresh to get updated data\n        self.photo.refresh_from_db()\n\n        # Verify final state\n        self.assertEqual(\n            caption_instance.captions_json[\"user_caption\"], \"My vacation photo\"\n        )\n        self.assertIn(\"outdoor\", search_instance.search_captions)\n        self.assertIn(\"beach\", search_instance.search_captions)\n        self.assertIn(\"My vacation photo\", search_instance.search_captions)\n        self.assertIn(\"Miami Beach\", search_instance.search_location)\n\n    def test_property_error_handling(self):\n        \"\"\"Test error handling in property getters\"\"\"\n        # Test when related instances don't exist and there's an error\n        photo = create_test_photo(owner=self.user)\n\n        # These should return None gracefully, not raise exceptions\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=photo)\n\n        self.assertIsNone(caption_instance.captions_json)\n        self.assertIsNone(search_instance.search_captions)\n        self.assertIsNone(search_instance.search_location)\n\n    def test_backward_compatibility(self):\n        \"\"\"Test that the refactored models maintain backward compatibility\"\"\"\n        # Create instances using the refactored approach\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=self.photo)\n        caption_instance.captions_json = {\n            \"user_caption\": \"Test caption\",\n            \"im2txt\": \"Generated caption\",\n        }\n        caption_instance.save()\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n        search_instance.search_captions = \"test search terms\"\n        search_instance.search_location = \"Test Location\"\n        search_instance.save()\n\n        # Refresh to get updated data\n        self.photo.refresh_from_db()\n\n        # Verify data is accessible through properties\n        self.assertEqual(caption_instance.captions_json[\"user_caption\"], \"Test caption\")\n        self.assertEqual(caption_instance.captions_json[\"im2txt\"], \"Generated caption\")\n        self.assertEqual(search_instance.search_captions, \"test search terms\")\n        self.assertEqual(search_instance.search_location, \"Test Location\")\n\n        # Verify data is stored in the correct models\n        caption = PhotoCaption.objects.get(photo=self.photo)\n        search = PhotoSearch.objects.get(photo=self.photo)\n\n        self.assertEqual(caption.captions_json[\"user_caption\"], \"Test caption\")\n        self.assertEqual(search.search_captions, \"test search terms\")\n        self.assertEqual(search.search_location, \"Test Location\")\n\n    def test_queryset_only_with_search_location_fails(self):\n        \"\"\"Test that using search_location in queryset .only() raises FieldDoesNotExist\"\"\"\n        # This should fail because search_location is no longer a database field\n        with self.assertRaises(FieldDoesNotExist):\n            list(Photo.objects.only(\"image_hash\", \"search_location\").all())\n\n    def test_queryset_only_with_search_instance_works(self):\n        \"\"\"Test that using search_instance__search_location in queryset .only() works\"\"\"\n        # Create a photo with search data\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n        search_instance.search_location = \"New York\"\n        search_instance.save()\n\n        # This should work because we're accessing through the related model\n        photos = list(\n            Photo.objects.select_related(\"search_instance\")\n            .only(\"image_hash\", \"search_instance__search_location\")\n            .filter(image_hash=self.photo.image_hash)\n        )\n\n        # Should be able to access the data\n        self.assertEqual(len(photos), 1)\n        # Note: accessing search_location property will still work even with .only()\n        # because it goes through the related model\n\n    def test_album_date_queryset_works(self):\n        \"\"\"Test that the album date queryset works with the fixed field references\"\"\"\n\n        # Create a photo with search data\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=self.photo)\n        search_instance.search_location = \"New York\"\n        search_instance.search_captions = \"outdoor nature\"\n        search_instance.save()\n\n        # Create an album date and add the photo to it\n        album_date = AlbumDate.objects.create(\n            date=self.photo.exif_timestamp.date()\n            if self.photo.exif_timestamp\n            else timezone.now().date(),\n            owner=self.user,\n        )\n        album_date.photos.add(self.photo)\n\n        # This should work with the corrected field references\n        photo_qs = (\n            album_date.photos.all()\n            .select_related(\"search_instance\")\n            .only(\n                \"image_hash\",\n                \"search_instance__search_location\",\n                \"exif_timestamp\",\n            )\n        )\n\n        photos = list(photo_qs)\n        self.assertEqual(len(photos), 1)\n        self.assertEqual(photos[0].search_instance.search_location, \"New York\")\n"
  },
  {
    "path": "api/tests/test_photo_search_model.py",
    "content": "from django.test import TestCase\n\nfrom api.models import PhotoSearch, PhotoCaption\nfrom api.models.photo_metadata import PhotoMetadata\nfrom api.tests.utils import (\n    create_test_user,\n    create_test_photo,\n    create_test_face,\n    create_test_person,\n)\n\n\nclass PhotoSearchModelTest(TestCase):\n    def setUp(self):\n        self.user = create_test_user()\n        self.photo = create_test_photo(owner=self.user)\n\n    def test_create_photo_search(self):\n        \"\"\"Test creating a PhotoSearch instance\"\"\"\n        search = PhotoSearch.objects.create(\n            photo=self.photo,\n            search_captions=\"outdoor nature sunny\",\n            search_location=\"New York, USA\",\n        )\n\n        self.assertEqual(search.photo, self.photo)\n        self.assertEqual(search.search_captions, \"outdoor nature sunny\")\n        self.assertEqual(search.search_location, \"New York, USA\")\n\n    def test_photo_search_one_to_one_relationship(self):\n        \"\"\"Test that PhotoSearch has a one-to-one relationship with Photo\"\"\"\n        PhotoSearch.objects.create(photo=self.photo, search_captions=\"first search\")\n\n        # Trying to create another search for the same photo should fail\n        with self.assertRaises(Exception):\n            PhotoSearch.objects.create(\n                photo=self.photo, search_captions=\"second search\"\n            )\n\n    def test_recreate_search_captions_with_places365(self):\n        \"\"\"Test recreating search captions with places365 data\"\"\"\n        # Create PhotoCaption with places365 data\n        PhotoCaption.objects.create(\n            photo=self.photo,\n            captions_json={\n                \"places365\": {\n                    \"attributes\": [\"natural\", \"sunny\"],\n                    \"categories\": [\"outdoor\", \"landscape\"],\n                    \"environment\": \"outdoor\",\n                }\n            },\n        )\n\n        search = PhotoSearch.objects.create(photo=self.photo)\n        search.recreate_search_captions()\n\n        self.assertIn(\"natural\", search.search_captions)\n        self.assertIn(\"sunny\", search.search_captions)\n        self.assertIn(\"outdoor\", search.search_captions)\n        self.assertIn(\"landscape\", search.search_captions)\n\n    def test_recreate_search_captions_with_user_caption(self):\n        \"\"\"Test recreating search captions with user caption\"\"\"\n        PhotoCaption.objects.create(\n            photo=self.photo,\n            captions_json={\"user_caption\": \"My beautiful vacation photo\"},\n        )\n\n        search = PhotoSearch.objects.create(photo=self.photo)\n        search.recreate_search_captions()\n\n        self.assertIn(\"My beautiful vacation photo\", search.search_captions)\n\n    def test_recreate_search_captions_with_im2txt(self):\n        \"\"\"Test recreating search captions with im2txt caption\"\"\"\n        PhotoCaption.objects.create(\n            photo=self.photo,\n            captions_json={\"im2txt\": \"a photo of a mountain landscape\"},\n        )\n\n        search = PhotoSearch.objects.create(photo=self.photo)\n        search.recreate_search_captions()\n\n        self.assertIn(\"a photo of a mountain landscape\", search.search_captions)\n\n    def test_recreate_search_captions_with_faces(self):\n        \"\"\"Test recreating search captions with face names\"\"\"\n        person = create_test_person(name=\"John Doe\", cluster_owner=self.user)\n        create_test_face(photo=self.photo, person=person)\n\n        search = PhotoSearch.objects.create(photo=self.photo)\n        search.recreate_search_captions()\n\n        self.assertIn(\"John Doe\", search.search_captions)\n\n    def test_recreate_search_captions_with_file_path(self):\n        \"\"\"Test recreating search captions with file path\"\"\"\n        search = PhotoSearch.objects.create(photo=self.photo)\n        search.recreate_search_captions()\n\n        # Should include the file path\n        self.assertIn(self.photo.main_file.path, search.search_captions)\n\n    def test_recreate_search_captions_with_video(self):\n        \"\"\"Test recreating search captions for video files\"\"\"\n        video_photo = create_test_photo(owner=self.user, video=True)\n        search = PhotoSearch.objects.create(photo=video_photo)\n        search.recreate_search_captions()\n\n        self.assertIn(\"type: video\", search.search_captions)\n\n    def test_recreate_search_captions_with_camera_info(self):\n        \"\"\"Test recreating search captions with camera information\"\"\"\n        camera_photo = create_test_photo(\n            owner=self.user, camera=\"Canon EOS 5D\", lens=\"Canon 24-70mm\"\n        )\n        search = PhotoSearch.objects.create(photo=camera_photo)\n        search.recreate_search_captions()\n\n        self.assertIn(\"Canon EOS 5D\", search.search_captions)\n        self.assertIn(\"Canon 24-70mm\", search.search_captions)\n\n    def test_update_search_location(self):\n        \"\"\"Test updating search location\"\"\"\n        search = PhotoSearch.objects.create(photo=self.photo)\n\n        geolocation_data = {\"features\": [{\"text\": \"New York\"}, {\"text\": \"USA\"}]}\n\n        search.update_search_location(geolocation_data)\n\n        self.assertIn(\"New York\", search.search_location)\n        self.assertIn(\"USA\", search.search_location)\n\n    def test_update_search_location_with_empty_data(self):\n        \"\"\"Test updating search location with empty data\"\"\"\n        search = PhotoSearch.objects.create(photo=self.photo)\n\n        search.update_search_location({})\n\n        self.assertEqual(search.search_location, \"\")\n\n    def test_search_captions_default_empty(self):\n        \"\"\"Test that search_captions defaults to None (nullable field)\"\"\"\n        search = PhotoSearch.objects.create(photo=self.photo)\n\n        self.assertIsNone(search.search_captions)\n\n    def test_search_location_default_empty(self):\n        \"\"\"Test that search_location defaults to None (nullable field)\"\"\"\n        search = PhotoSearch.objects.create(photo=self.photo)\n\n        self.assertIsNone(search.search_location)\n\n    def test_str_representation(self):\n        \"\"\"Test string representation of PhotoSearch\"\"\"\n        search = PhotoSearch.objects.create(\n            photo=self.photo, search_captions=\"test captions\"\n        )\n\n        str_repr = str(search)\n        self.assertIn(self.photo.image_hash, str_repr)\n\n    def test_cascade_delete_with_photo(self):\n        \"\"\"Test that PhotoSearch is deleted when Photo is deleted\"\"\"\n        PhotoSearch.objects.create(photo=self.photo)\n        photo_id = self.photo.image_hash\n\n        self.photo.delete()\n\n        with self.assertRaises(PhotoSearch.DoesNotExist):\n            PhotoSearch.objects.get(photo_id=photo_id)\n\n    def test_recreate_search_captions_comprehensive(self):\n        \"\"\"Test recreating search captions with all types of data\"\"\"\n        # Create comprehensive test data\n        PhotoCaption.objects.create(\n            photo=self.photo,\n            captions_json={\n                \"user_caption\": \"My vacation\",\n                \"im2txt\": \"a beautiful landscape\",\n                \"places365\": {\n                    \"attributes\": [\"natural\", \"sunny\"],\n                    \"categories\": [\"outdoor\"],\n                    \"environment\": \"outdoor\",\n                },\n            },\n        )\n\n        person = create_test_person(name=\"Jane Smith\", cluster_owner=self.user)\n        create_test_face(photo=self.photo, person=person)\n\n        # Create PhotoMetadata with camera info\n        PhotoMetadata.objects.create(\n            photo=self.photo,\n            camera_model=\"Nikon D850\",\n            lens_model=\"Nikon 50mm\",\n        )\n\n        search = PhotoSearch.objects.create(photo=self.photo)\n        search.recreate_search_captions()\n\n        # Verify all components are included\n        self.assertIn(\"My vacation\", search.search_captions)\n        self.assertIn(\"a beautiful landscape\", search.search_captions)\n        self.assertIn(\"natural\", search.search_captions)\n        self.assertIn(\"sunny\", search.search_captions)\n        self.assertIn(\"outdoor\", search.search_captions)\n        self.assertIn(\"Jane Smith\", search.search_captions)\n        self.assertIn(\"Nikon D850\", search.search_captions)\n        self.assertIn(\"Nikon 50mm\", search.search_captions)\n\n    def test_search_captions_indexing(self):\n        \"\"\"Test that search_captions field is properly indexed\"\"\"\n        # This is more of a model definition test\n        field = PhotoSearch._meta.get_field(\"search_captions\")\n        self.assertTrue(field.db_index)\n\n    def test_search_location_indexing(self):\n        \"\"\"Test that search_location field is properly indexed\"\"\"\n        field = PhotoSearch._meta.get_field(\"search_location\")\n        self.assertTrue(field.db_index)\n\n    def test_empty_places365_handling(self):\n        \"\"\"Test handling of empty places365 data\"\"\"\n        PhotoCaption.objects.create(\n            photo=self.photo,\n            captions_json={\n                \"places365\": {\"attributes\": [], \"categories\": [], \"environment\": \"\"}\n            },\n        )\n\n        search = PhotoSearch.objects.create(photo=self.photo)\n        search.recreate_search_captions()\n\n        # Should not crash and should handle empty data gracefully\n        self.assertIsInstance(search.search_captions, str)\n\n    def test_none_values_handling(self):\n        \"\"\"Test handling of None values in caption data\"\"\"\n        PhotoCaption.objects.create(\n            photo=self.photo, captions_json={\"user_caption\": None, \"im2txt\": None}\n        )\n\n        search = PhotoSearch.objects.create(photo=self.photo)\n        search.recreate_search_captions()\n\n        # Should not crash with None values\n        self.assertIsInstance(search.search_captions, str)\n"
  },
  {
    "path": "api/tests/test_photo_search_refactor.py",
    "content": "from django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass PhotoSearchRefactorTest(TestCase):\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_search_location_property_works(self):\n        \"\"\"Test that search_location property works with the new PhotoSearch model\"\"\"\n        photo = create_test_photo(owner=self.user)\n\n        # Test setting search_location through direct access\n        from api.models.photo_search import PhotoSearch\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=photo)\n        search_instance.search_location = \"New York, USA\"\n        search_instance.save()\n\n        # Verify it was saved to PhotoSearch model\n        self.assertEqual(search_instance.search_location, \"New York, USA\")\n        self.assertEqual(photo.search_instance.search_location, \"New York, USA\")\n\n    def test_search_captions_property_works(self):\n        \"\"\"Test that search_captions property works with the new PhotoSearch model\"\"\"\n        photo = create_test_photo(owner=self.user)\n\n        # Test setting search_captions through direct access\n        from api.models.photo_search import PhotoSearch\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=photo)\n        search_instance.search_captions = \"outdoor nature sunny\"\n        search_instance.save()\n\n        # Verify it was saved to PhotoSearch model\n        self.assertEqual(search_instance.search_captions, \"outdoor nature sunny\")\n        self.assertEqual(photo.search_instance.search_captions, \"outdoor nature sunny\")\n\n    def test_recreate_search_captions_works(self):\n        \"\"\"Test that recreating search captions works with the new model structure\"\"\"\n        photo = create_test_photo(owner=self.user)\n\n        # Add some caption data\n        from api.models.photo_caption import PhotoCaption\n        from api.models.photo_search import PhotoSearch\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n        caption_instance.captions_json = {\n            \"places365\": {\"categories\": [\"outdoor\"], \"attributes\": [\"sunny\"]},\n            \"user_caption\": \"My photo\",\n        }\n        caption_instance.save()\n\n        # Recreate search captions\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=photo)\n        search_instance.recreate_search_captions()\n        search_instance.save()\n\n        # Verify search captions were created\n        from api.models.photo_search import PhotoSearch\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=photo)\n        self.assertIsNotNone(search_instance.search_captions)\n        self.assertIn(\"outdoor\", search_instance.search_captions)\n        self.assertIn(\"sunny\", search_instance.search_captions)\n        self.assertIn(\"My photo\", search_instance.search_captions)\n\n    def test_geolocate_updates_search_location(self):\n        \"\"\"Test that geolocating updates search_location through PhotoSearch\"\"\"\n        photo = create_test_photo(owner=self.user)\n\n        # Mock geolocation data\n        geolocation_data = {\n            \"address\": \"Central Park, New York, NY, USA\",\n            \"features\": [\n                {\"text\": \"Central Park\"},\n                {\"text\": \"New York\"},\n                {\"text\": \"NY\"},\n                {\"text\": \"USA\"},\n            ],\n        }\n\n        # Update search location through PhotoSearch\n        from api.models.photo_search import PhotoSearch\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=photo)\n        search_instance.update_search_location(geolocation_data)\n        search_instance.save()\n\n        # Verify search_location was updated\n        self.assertEqual(\n            search_instance.search_location, \"Central Park, New York, NY, USA\"\n        )\n\n    def test_direct_access_consistency(self):\n        \"\"\"Test that direct access to models maintains consistency\"\"\"\n        photo = create_test_photo(owner=self.user)\n\n        # Set data through direct access\n        from api.models.photo_caption import PhotoCaption\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n        caption_instance.captions_json = {\"user_caption\": \"Test caption\"}\n        caption_instance.save()\n\n        from api.models.photo_search import PhotoSearch\n\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=photo)\n        search_instance.search_captions = \"test captions\"\n        search_instance.search_location = \"Test Location\"\n        search_instance.save()\n\n        # Refresh photo to ensure data is loaded from database\n        photo.refresh_from_db()\n\n        # Verify data is accessible through direct access\n        self.assertEqual(\n            photo.caption_instance.captions_json[\"user_caption\"], \"Test caption\"\n        )\n        self.assertEqual(photo.search_instance.search_captions, \"test captions\")\n        self.assertEqual(photo.search_instance.search_location, \"Test Location\")\n"
  },
  {
    "path": "api/tests/test_photo_summary.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\nfrom rest_framework import status\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass PhotoSummaryViewTest(TestCase):\n    def setUp(self):\n        self.user = create_test_user(is_admin=True)\n        self.photo = create_test_photo(owner=self.user)\n        self.photo.save()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_summary_view_existing_photo_regular_user(self):\n        regular_user = create_test_user()\n\n        self.client.force_authenticate(user=regular_user)\n        photo = create_test_photo(owner=regular_user)\n        url = reverse(\"photos-summary\", kwargs={\"pk\": photo.image_hash})\n        response = self.client.get(url)\n\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        self.assertFalse(response.data[\"processing\"])\n\n    def test_summary_view_existing_photo(self):\n        url = reverse(\"photos-summary\", kwargs={\"pk\": self.photo.image_hash})\n        response = self.client.get(url)\n\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n\n        self.assertFalse(response.data[\"processing\"])\n\n    def test_summary_view_nonexistent_photo(self):\n        url = reverse(\"photos-summary\", kwargs={\"pk\": \"nonexistent_hash\"})\n        response = self.client.get(url)\n\n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_summary_view_no_aspect_ratio(self):\n        # Simulate the case where aspect_ratio is None\n        if hasattr(self.photo, \"thumbnail\") and self.photo.thumbnail:\n            self.photo.thumbnail.aspect_ratio = None\n            self.photo.thumbnail.save()\n        self.photo.save()\n\n        url = reverse(\"photos-summary\", kwargs={\"pk\": self.photo.image_hash})\n        response = self.client.get(url)\n\n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n\n        self.assertTrue(response.data[\"processing\"])\n"
  },
  {
    "path": "api/tests/test_photo_viewset_permissions.py",
    "content": "from django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import (\n    create_test_photo,\n    create_test_user,\n    share_test_photos,\n)\n\n\nclass PhotoViewSetPermissionsTest(TestCase):\n    def setUp(self):\n        self.owner = create_test_user()\n        self.other_user = create_test_user()\n        self.photo = create_test_photo(owner=self.owner)\n        self.url = f\"/api/photos/{self.photo.image_hash}/\"\n\n    def test_owner_can_update_photo(self):\n        client = APIClient()\n        client.force_authenticate(user=self.owner)\n\n        response = client.patch(self.url, {\"rating\": 5}, format=\"json\")\n\n        self.assertEqual(200, response.status_code)\n        self.photo.refresh_from_db()\n        self.assertEqual(5, self.photo.rating)\n\n    def test_non_owner_cannot_update_photo(self):\n        share_test_photos([self.photo.image_hash], self.other_user)\n        client = APIClient()\n        client.force_authenticate(user=self.other_user)\n\n        response = client.patch(self.url, {\"rating\": 3}, format=\"json\")\n\n        self.assertEqual(403, response.status_code)\n        self.photo.refresh_from_db()\n        self.assertNotEqual(3, self.photo.rating)\n"
  },
  {
    "path": "api/tests/test_predefined_rules.py",
    "content": "import json\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.date_time_extractor import DEFAULT_RULES_PARAMS, OTHER_RULES_PARAMS\nfrom api.models import User\n\n\nclass PredefinedRulesTest(TestCase):\n    def setUp(self):\n        self.admin = User.objects.create_superuser(\n            \"test_admin\", \"test_admin@test.com\", \"test_password\"\n        )\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.admin)\n\n    def test_predefined_rules(self):\n        response = self.client.get(\"/api/predefinedrules/\")\n        self.assertEqual(200, response.status_code)\n        data = response.json()\n        self.assertIsInstance(data, str)\n        rules = json.loads(data)\n        self.assertIsInstance(rules, list)\n        self.assertEqual(15, len(rules))\n\n    def test_default_rules_on_predefined_rules_endpoint(self):\n        response = self.client.get(\"/api/predefinedrules/\")\n        rules = json.loads(response.json())\n        default_rules = list(filter(lambda x: x[\"is_default\"], rules))\n        self.assertListEqual(DEFAULT_RULES_PARAMS, default_rules)\n\n    def test_default_rules_endpoint(self):\n        response = self.client.get(\"/api/defaultrules/\")\n        rules = json.loads(response.json())\n        self.assertListEqual(DEFAULT_RULES_PARAMS, rules)\n\n    def test_other_rules(self):\n        response = self.client.get(\"/api/predefinedrules/\")\n        rules = json.loads(response.json())\n        other_rules = list(filter(lambda x: not x[\"is_default\"], rules))\n        self.assertListEqual(OTHER_RULES_PARAMS, other_rules)\n"
  },
  {
    "path": "api/tests/test_public_photos.py",
    "content": "import logging\nimport unittest.mock\nfrom unittest.mock import patch\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_photos, create_test_user\n\nlogger = logging.getLogger(__name__)\n\n\nclass PublicPhotosTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n\n    def test_set_my_photos_as_public(self):\n        photos = create_test_photos(number_of_photos=3, owner=self.user1)\n        image_hashes = [p.image_hash for p in photos]\n\n        payload = {\"image_hashes\": image_hashes, \"val_public\": True}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/makepublic/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(3, len(data[\"results\"]))\n        self.assertEqual(3, len(data[\"updated\"]))\n        self.assertEqual(0, len(data[\"not_updated\"]))\n\n    def test_set_my_photos_as_private(self):\n        photos = create_test_photos(number_of_photos=2, owner=self.user1, public=True)\n        image_hashes = [p.image_hash for p in photos]\n\n        payload = {\"image_hashes\": image_hashes, \"val_public\": False}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/makepublic/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(2, len(data[\"results\"]))\n        self.assertEqual(2, len(data[\"updated\"]))\n        self.assertEqual(0, len(data[\"not_updated\"]))\n\n    def test_set_photos_of_other_user_as_public(self):\n        photos = create_test_photos(number_of_photos=2, owner=self.user2)\n        image_hashes = [p.image_hash for p in photos]\n\n        payload = {\"image_hashes\": image_hashes, \"val_public\": True}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/makepublic/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(0, len(data[\"results\"]))\n        self.assertEqual(0, len(data[\"updated\"]))\n        # Photos not owned by user are treated as \"missing\" for security (no info leak)\n        self.assertEqual(0, len(data[\"not_updated\"]))\n\n    @patch(\"api.views.photos.logger.warning\", autospec=True)\n    def test_tag_nonexistent_photo_as_favorite(self, logger_ext: unittest.mock.Mock):\n        payload = {\"image_hashes\": [\"nonexistent_photo\"], \"val_public\": True}\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/makepublic/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n        logger.debug(data)\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(0, len(data[\"results\"]))\n        self.assertEqual(0, len(data[\"updated\"]))\n        self.assertEqual(0, len(data[\"not_updated\"]))\n        logger_ext.assert_called_with(\n            \"Could not set photo nonexistent_photo to public. It does not exist or is not owned by user.\"\n        )\n"
  },
  {
    "path": "api/tests/test_reading_exif.py",
    "content": "import os\n\nfrom django.test import TestCase\nfrom django.utils import timezone\nfrom faker import Faker\nfrom rest_framework.test import APIClient\n\nfrom api.models import File, Person, Photo\nfrom api.tests.utils import create_test_user\n\n\nclass ReadFacesFromPhotosTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user(favorite_min_rating=1)\n        self.client.force_authenticate(user=self.user1)\n\n    def test_reading_from_photo(self):\n        file = os.path.dirname(os.path.abspath(__file__)) + \"/fixtures/niaz.jpg\"\n\n        exif_file = os.path.dirname(os.path.abspath(__file__)) + \"/fixtures/niaz.xmp\"\n\n        fake = Faker()\n        pk = fake.md5()\n        os.system(\"cp \" + file + \" \" + \"/tmp/\" + str(pk) + \".jpg\")\n        # copy exif file to photo and rename it to have the same name as the photo but with .xmp extension\n        os.system(\"cp \" + exif_file + \" \" + \"/tmp/\" + str(pk) + \".xmp\")\n        # we need a thumbnail in the thumbnails_big folder\n        os.system(\n            \"cp \" + file + \" \" + \"/protected_media/thumbnails_big/\" + str(pk) + \".jpg\"\n        )\n\n        photo = Photo(pk=pk, image_hash=pk, owner=self.user1)\n        fileObject = File.create(\"/tmp/\" + str(photo.pk) + \".jpg\", self.user1)\n        photo.main_file = fileObject\n        photo.added_on = timezone.now()\n        photo.save()\n\n        # Create thumbnail for the photo\n        from api.models.thumbnail import Thumbnail\n\n        Thumbnail.objects.create(\n            photo=photo,\n            thumbnail_big=\"/protected_media/thumbnails_big/\" + str(photo.pk) + \".jpg\",\n            aspect_ratio=1.0,\n        )\n\n        photo._extract_faces()\n\n        # To Debug Face Extraction: Look at the actual produced thumbnail\n        # Thumbnail is wrong at the moment, need to create a correct face tag first, where I know the face is correct\n        # output_file = (\n        #        os.path.dirname(os.path.abspath(__file__))\n        #        + \"/fixtures/niaz_face.jpg\"\n        # )\n        # os.system(\"cp \" + \"/protected_media/faces/\" + str(photo.pk) + \"_0.jpg\" + \" \" + output_file)\n\n        self.assertEqual(1, len(photo.faces.all()))\n        # One Niaz Faridani-Rad\n        self.assertEqual(1, len(Person.objects.all()))\n        # There has to be a face encoding\n        self.assertIsNotNone(photo.faces.all()[0].encoding)\n        self.assertEqual(\n            \"Niaz Faridani-Rad\",\n            Person.objects.filter(name=\"Niaz Faridani-Rad\").first().name,\n        )\n"
  },
  {
    "path": "api/tests/test_recently_added_photos.py",
    "content": "from datetime import timedelta\nfrom django.test import TestCase\nfrom django.utils import timezone\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_photos, create_test_user\n\n\nclass RecentlyAddedPhotosTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n\n    def test_retrieve_recently_added_photos(self):\n        today = timezone.now()\n        before_today = timezone.now() - timedelta(days=1)\n        create_test_photos(number_of_photos=3, owner=self.user1, added_on=today)\n        create_test_photos(number_of_photos=4, owner=self.user1, added_on=before_today)\n        create_test_photos(number_of_photos=5, owner=self.user2, added_on=today)\n\n        response = self.client.get(\"/api/photos/recentlyadded/\")\n        json = response.json()\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(3, len(json[\"results\"]))\n\n    def test_retrieve_empty_result_when_no_photos(self):\n        create_test_photos(number_of_photos=2, owner=self.user2)\n        response = self.client.get(\"/api/photos/recentlyadded/\")\n        json = response.json()\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual([], json[\"results\"])\n        self.assertIsNone(json[\"date\"])\n"
  },
  {
    "path": "api/tests/test_redetection_idempotency.py",
    "content": "\"\"\"\nTests for re-detection idempotency.\n\nEnsures that running detection multiple times:\n- Does not create duplicate stacks\n- Does not create duplicate duplicate-groups\n- Handles already-processed photos correctly\n- Merges with existing groups properly\n\nNOTE: RAW+JPEG pairs and Live Photos are now handled as file variants\nduring the initial scan (Photo.files ManyToMany field), not as stacks.\nThis module tests idempotency for:\n- Duplicate detection (exact copies, visual duplicates)\n- Burst sequence detection\n\"\"\"\n\nimport uuid\n\nfrom django.test import TestCase\nfrom django.utils import timezone\nfrom rest_framework.test import APIClient, APITestCase\n\nfrom api.models.duplicate import Duplicate\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.file import File\nfrom api.tests.utils import create_test_photo, create_test_user\nfrom api.duplicate_detection import detect_exact_copies, detect_visual_duplicates\nfrom api.stack_detection import detect_burst_sequences\n\n\nclass DuplicateRedetectionTestCase(TestCase):\n    \"\"\"Test that duplicate detection is idempotent.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_exact_copy_redetection_no_duplicates(self):\n        \"\"\"Test that running exact copy detection twice doesn't create duplicate groups.\"\"\"\n        # Create photos with same hash (simulating exact copies)\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        # Set same image_hash to simulate exact copies\n        same_hash = \"abcdef1234567890abcdef1234567890\"\n        photo1.image_hash = same_hash\n        photo1.save()\n        photo2.image_hash = same_hash\n        photo2.save()\n        \n        # First detection\n        detect_exact_copies(self.user)\n        initial_count = Duplicate.objects.filter(owner=self.user).count()\n        \n        # Second detection\n        detect_exact_copies(self.user)\n        final_count = Duplicate.objects.filter(owner=self.user).count()\n        \n        # Should have same number of groups\n        self.assertEqual(initial_count, final_count)\n\n    def test_visual_duplicate_redetection_no_duplicates(self):\n        \"\"\"Test that running visual duplicate detection twice doesn't create duplicate groups.\"\"\"\n        # Create photos with similar perceptual hash\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        # Set similar perceptual hashes\n        photo1.image_phash = \"0000000000000000\"\n        photo1.save()\n        photo2.image_phash = \"0000000000000001\"  # Very similar\n        photo2.save()\n        \n        # First detection\n        detect_visual_duplicates(self.user, threshold=10)\n        initial_count = Duplicate.objects.filter(owner=self.user).count()\n        initial_photo_count = sum(\n            d.photos.count() for d in Duplicate.objects.filter(owner=self.user)\n        )\n        \n        # Second detection\n        detect_visual_duplicates(self.user, threshold=10)\n        final_count = Duplicate.objects.filter(owner=self.user).count()\n        final_photo_count = sum(\n            d.photos.count() for d in Duplicate.objects.filter(owner=self.user)\n        )\n        \n        # Should have same number of groups and photos\n        self.assertEqual(initial_count, final_count)\n        self.assertEqual(initial_photo_count, final_photo_count)\n\n    def test_redetection_adds_new_photos_to_existing_group(self):\n        \"\"\"Test that new duplicates are added to existing groups, not new ones.\"\"\"\n        # Create initial duplicate pair\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        same_hash = \"abcdef1234567890abcdef1234567890\"\n        photo1.image_hash = same_hash\n        photo1.save()\n        photo2.image_hash = same_hash\n        photo2.save()\n        \n        # First detection creates a group\n        detect_exact_copies(self.user)\n        initial_groups = list(Duplicate.objects.filter(owner=self.user))\n        self.assertEqual(len(initial_groups), 1)\n        \n        # Add a third photo with same hash\n        photo3 = create_test_photo(owner=self.user)\n        photo3.image_hash = same_hash\n        photo3.save()\n        \n        # Second detection should add to existing group\n        detect_exact_copies(self.user)\n        final_groups = list(Duplicate.objects.filter(owner=self.user))\n        \n        # Should still have only one group\n        self.assertEqual(len(final_groups), 1)\n        # Group should now have 3 photos\n        self.assertEqual(final_groups[0].photos.count(), 3)\n\n    def test_redetection_with_resolved_duplicates(self):\n        \"\"\"Test that resolved duplicates are not re-detected.\"\"\"\n        # Create duplicate pair\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        same_hash = \"abcdef1234567890abcdef1234567890\"\n        photo1.image_hash = same_hash\n        photo1.save()\n        photo2.image_hash = same_hash\n        photo2.save()\n        \n        # First detection\n        detect_exact_copies(self.user)\n        dup = Duplicate.objects.filter(owner=self.user).first()\n        \n        # Resolve the duplicate\n        dup.resolve(kept_photo=photo1)\n        \n        # Re-detection\n        detect_exact_copies(self.user)\n        \n        # Should not create a new pending group for resolved duplicates\n        _pending_groups = Duplicate.objects.filter(\n            owner=self.user,\n            review_status=Duplicate.ReviewStatus.PENDING\n        ).count()\n        \n        # Depending on implementation, either 0 new pending groups or existing resolved stays resolved\n        resolved_groups = Duplicate.objects.filter(\n            owner=self.user,\n            review_status=Duplicate.ReviewStatus.RESOLVED\n        ).count()\n        self.assertGreaterEqual(resolved_groups, 1)\n\n\nclass BurstRedetectionTestCase(TestCase):\n    \"\"\"Test that burst stack detection is idempotent.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_burst_redetection_no_duplicate_stacks(self):\n        \"\"\"Test that running burst detection twice doesn't create duplicate stacks.\"\"\"\n        # Create photos that look like a burst\n        base_time = timezone.now()\n        photos = []\n        for i in range(3):\n            photo = create_test_photo(owner=self.user)\n            photo.exif_timestamp = base_time + timezone.timedelta(milliseconds=100 * i)\n            photo.main_file.path = f\"/photos/IMG_001_{i}.JPG\"\n            photo.main_file.save()\n            photo.save()\n            photos.append(photo)\n        \n        # First detection\n        detect_burst_sequences(self.user)\n        initial_stacks = PhotoStack.objects.filter(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE\n        ).count()\n        \n        # Second detection\n        detect_burst_sequences(self.user)\n        final_stacks = PhotoStack.objects.filter(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE\n        ).count()\n        \n        # Should have same number of stacks\n        self.assertEqual(initial_stacks, final_stacks)\n\n    def test_redetection_adds_new_photos_to_existing_stack(self):\n        \"\"\"Test that new photos are added to existing stacks.\"\"\"\n        # Create initial burst pair\n        base_time = timezone.now()\n        \n        photo1 = create_test_photo(owner=self.user)\n        photo1.exif_timestamp = base_time\n        photo1.main_file.path = \"/photos/burst_001.jpg\"\n        photo1.main_file.save()\n        photo1.save()\n        \n        photo2 = create_test_photo(owner=self.user)\n        photo2.exif_timestamp = base_time + timezone.timedelta(milliseconds=100)\n        photo2.main_file.path = \"/photos/burst_002.jpg\"\n        photo2.main_file.save()\n        photo2.save()\n        \n        # First detection\n        detect_burst_sequences(self.user)\n        _initial_stacks = list(PhotoStack.objects.filter(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE\n        ))\n        \n        # Add a third photo to the burst\n        photo3 = create_test_photo(owner=self.user)\n        photo3.exif_timestamp = base_time + timezone.timedelta(milliseconds=200)\n        photo3.main_file.path = \"/photos/burst_003.jpg\"\n        photo3.main_file.save()\n        photo3.save()\n        \n        # Second detection should add to existing stack\n        detect_burst_sequences(self.user)\n        _final_stacks = list(PhotoStack.objects.filter(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE\n        ))\n        \n        # Stack count may vary based on implementation\n        # The key is no duplicate photos in stacks\n\n\nclass APIRedetectionTestCase(APITestCase):\n    \"\"\"Test re-detection through API endpoints.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        \n        # Create some test photos\n        for _ in range(5):\n            create_test_photo(owner=self.user)\n\n    def test_duplicate_detect_api_idempotent(self):\n        \"\"\"Test that calling /api/duplicates/detect multiple times is safe.\"\"\"\n        # First detection\n        response1 = self.client.post(\"/api/duplicates/detect\")\n        self.assertIn(response1.status_code, [200, 202])\n        \n        # Second detection\n        response2 = self.client.post(\"/api/duplicates/detect\")\n        self.assertIn(response2.status_code, [200, 202])\n        \n        # Third detection\n        response3 = self.client.post(\"/api/duplicates/detect\")\n        self.assertIn(response3.status_code, [200, 202])\n\n    def test_stack_detect_api_idempotent(self):\n        \"\"\"Test that calling /api/stacks/detect multiple times is safe.\"\"\"\n        # First detection\n        response1 = self.client.post(\"/api/stacks/detect\")\n        self.assertIn(response1.status_code, [200, 202])\n        \n        # Second detection\n        response2 = self.client.post(\"/api/stacks/detect\")\n        self.assertIn(response2.status_code, [200, 202])\n        \n        # Third detection\n        response3 = self.client.post(\"/api/stacks/detect\")\n        self.assertIn(response3.status_code, [200, 202])\n\n    def test_duplicate_detect_with_clear_pending(self):\n        \"\"\"Test detection with clear_pending option.\"\"\"\n        # Create a duplicate\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo1.image_hash = photo2.image_hash = \"samehash123456789012345678901234\"\n        photo1.save()\n        photo2.save()\n        \n        # First detection\n        response1 = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"clear_pending\": False},\n            format=\"json\"\n        )\n        self.assertIn(response1.status_code, [200, 202])\n        \n        # Detection with clear_pending\n        response2 = self.client.post(\n            \"/api/duplicates/detect\",\n            {\"clear_pending\": True},\n            format=\"json\"\n        )\n        self.assertIn(response2.status_code, [200, 202])\n\n\nclass PhotoInMultipleGroupsRedetectionTestCase(TestCase):\n    \"\"\"Test re-detection when photos are in multiple groups.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_photo_already_in_stack_not_duplicated(self):\n        \"\"\"Test that a photo already in a stack isn't added again.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(3)]\n        \n        # Create a manual stack\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*photos)\n        \n        initial_photo_count = stack.photos.count()\n        \n        # Try to add the same photos through create_or_merge\n        PhotoStack.create_or_merge(\n            photos=photos,\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        \n        # Should not have duplicated photos in the stack\n        stack.refresh_from_db()\n        self.assertEqual(stack.photos.count(), initial_photo_count)\n\n    def test_duplicate_group_photos_not_duplicated(self):\n        \"\"\"Test that photos already in a duplicate group aren't added again.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(3)]\n        \n        # Create a duplicate group\n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup.photos.add(*photos)\n        \n        initial_photo_count = dup.photos.count()\n        \n        # Try to add the same photos through create_or_merge\n        Duplicate.create_or_merge(\n            photos=photos,\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        \n        # Should have merged, not duplicated\n        final_groups = Duplicate.objects.filter(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        total_photos = sum(g.photos.count() for g in final_groups)\n        self.assertEqual(total_photos, initial_photo_count)\n\n\nclass ClearExistingGroupsTestCase(TestCase):\n    \"\"\"Test clearing existing groups before re-detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_clear_pending_duplicates(self):\n        \"\"\"Test that clear_pending removes pending duplicates.\"\"\"\n        # Create a pending duplicate\n        photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.PENDING,\n        )\n        dup.photos.add(*photos)\n        \n        # There should be one pending\n        self.assertEqual(\n            Duplicate.objects.filter(\n                owner=self.user,\n                review_status=Duplicate.ReviewStatus.PENDING\n            ).count(),\n            1\n        )\n        \n        # Clear pending duplicates\n        Duplicate.objects.filter(\n            owner=self.user,\n            review_status=Duplicate.ReviewStatus.PENDING\n        ).delete()\n        \n        # Now there should be none\n        self.assertEqual(\n            Duplicate.objects.filter(\n                owner=self.user,\n                review_status=Duplicate.ReviewStatus.PENDING\n            ).count(),\n            0\n        )\n\n    def test_clear_pending_preserves_resolved(self):\n        \"\"\"Test that clearing pending doesn't affect resolved duplicates.\"\"\"\n        photos1 = [create_test_photo(owner=self.user) for _ in range(2)]\n        photos2 = [create_test_photo(owner=self.user) for _ in range(2)]\n        \n        # Create pending and resolved duplicates\n        pending_dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.PENDING,\n        )\n        pending_dup.photos.add(*photos1)\n        \n        resolved_dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.RESOLVED,\n        )\n        resolved_dup.photos.add(*photos2)\n        \n        # Clear only pending\n        Duplicate.objects.filter(\n            owner=self.user,\n            review_status=Duplicate.ReviewStatus.PENDING\n        ).delete()\n        \n        # Resolved should still exist\n        self.assertTrue(\n            Duplicate.objects.filter(pk=resolved_dup.pk).exists()\n        )\n        \n        # Pending should be gone\n        self.assertFalse(\n            Duplicate.objects.filter(pk=pending_dup.pk).exists()\n        )\n\n\nclass MergeOnRedetectionTestCase(TestCase):\n    \"\"\"Test that overlapping groups are merged on re-detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_overlapping_stacks_merged(self):\n        \"\"\"Test that overlapping stacks are merged.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(4)]\n        \n        # Create two overlapping stacks\n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack1.photos.add(photos[0], photos[1], photos[2])  # 0, 1, 2\n        \n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack2.photos.add(photos[1], photos[2], photos[3])  # 1, 2, 3 (overlaps)\n        \n        # Using create_or_merge should merge these\n        merged = PhotoStack.create_or_merge(\n            photos=[photos[0], photos[1], photos[2], photos[3]],\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        \n        # Should have merged into one stack with all photos\n        self.assertIsNotNone(merged)\n\n    def test_overlapping_duplicates_merged(self):\n        \"\"\"Test that overlapping duplicate groups are merged.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(4)]\n        \n        # Create two overlapping duplicate groups\n        dup1 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup1.photos.add(photos[0], photos[1], photos[2])\n        \n        dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup2.photos.add(photos[1], photos[2], photos[3])\n        \n        # Using create_or_merge should merge these\n        merged = Duplicate.create_or_merge(\n            photos=[photos[0], photos[1], photos[2], photos[3]],\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        \n        # Should have merged\n        self.assertIsNotNone(merged)\n\n\nclass FileVariantsIdempotencyTestCase(TestCase):\n    \"\"\"\n    Test that file variants (RAW+JPEG, Live Photos) are handled idempotently.\n    \n    File variants are now stored via Photo.files ManyToMany field, not as stacks.\n    This is handled during the scan process in directory_watcher.py.\n    \"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_photo_with_multiple_file_variants(self):\n        \"\"\"Test that a photo can have multiple file variants.\"\"\"\n\n        # Create a photo (create_test_photo adds its own file)\n        photo = create_test_photo(owner=self.user)\n\n        # Clear any existing files and set up fresh\n        photo.files.clear()\n\n        # Use unique hashes to avoid any collision with other tests\n        unique_suffix = str(uuid.uuid4())[:8]\n\n        # Create a JPEG file\n        jpeg_file = File.objects.create(\n            hash=f\"jpeg_{unique_suffix}\" + \"a\" * 20,\n            path=f\"/photos/IMG_001_{unique_suffix}.jpg\",\n            type=File.IMAGE,\n        )\n        photo.main_file = jpeg_file\n        photo.files.add(jpeg_file)\n        photo.save()\n\n        # Add a RAW file variant\n        raw_file = File.objects.create(\n            hash=f\"raw_{unique_suffix}\" + \"b\" * 21,\n            path=f\"/photos/IMG_001_{unique_suffix}.CR2\",\n            type=File.RAW_FILE,\n        )\n        photo.files.add(raw_file)\n\n        # Photo should have 2 file variants\n        self.assertEqual(photo.files.count(), 2)\n\n        # Verify both files are present\n        file_paths = set(photo.files.values_list('path', flat=True))\n        self.assertIn(f\"/photos/IMG_001_{unique_suffix}.jpg\", file_paths)\n        self.assertIn(f\"/photos/IMG_001_{unique_suffix}.CR2\", file_paths)\n\n    def test_file_variant_types(self):\n        \"\"\"Test that we can identify file variant types.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        # Clear existing files\n        photo.files.clear()\n        \n        # Add JPEG\n        jpeg_file = File.objects.create(\n            hash=\"jpeg\" + \"a\" * 28,\n            path=\"/photos/IMG_001.jpg\",\n            type=File.IMAGE,\n        )\n        photo.main_file = jpeg_file\n        photo.files.add(jpeg_file)\n        \n        # Add RAW\n        raw_file = File.objects.create(\n            hash=\"raw\" + \"b\" * 29,\n            path=\"/photos/IMG_001.CR2\",\n            type=File.RAW_FILE,\n        )\n        photo.files.add(raw_file)\n        \n        # Add video for Live Photo\n        video_file = File.objects.create(\n            hash=\"video\" + \"c\" * 27,\n            path=\"/photos/IMG_001.mov\",\n            type=File.VIDEO,\n        )\n        photo.files.add(video_file)\n        photo.save()\n        \n        # Check file types\n        file_types = set(photo.files.values_list('type', flat=True))\n        self.assertIn(File.IMAGE, file_types)\n        self.assertIn(File.RAW_FILE, file_types)\n        self.assertIn(File.VIDEO, file_types)\n\n    def test_has_raw_variant(self):\n        \"\"\"Test detecting if a photo has a RAW file variant.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        # Clear existing files\n        photo.files.clear()\n        \n        # Add JPEG\n        jpeg_file = File.objects.create(\n            hash=\"jpeg\" + \"a\" * 28,\n            path=\"/photos/IMG_001.jpg\",\n            type=File.IMAGE,\n        )\n        photo.main_file = jpeg_file\n        photo.files.add(jpeg_file)\n        \n        # Initially no RAW\n        has_raw = photo.files.filter(type=File.RAW_FILE).exists()\n        self.assertFalse(has_raw)\n        \n        # Add RAW\n        raw_file = File.objects.create(\n            hash=\"raw\" + \"b\" * 29,\n            path=\"/photos/IMG_001.CR2\",\n            type=File.RAW_FILE,\n        )\n        photo.files.add(raw_file)\n        \n        # Now has RAW\n        has_raw = photo.files.filter(type=File.RAW_FILE).exists()\n        self.assertTrue(has_raw)\n\n    def test_has_video_variant_live_photo(self):\n        \"\"\"Test detecting if a photo has a video variant (Live Photo).\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        # Clear existing files\n        photo.files.clear()\n        \n        # Add image\n        image_file = File.objects.create(\n            hash=\"image\" + \"a\" * 27,\n            path=\"/photos/IMG_001.heic\",\n            type=File.IMAGE,\n        )\n        photo.main_file = image_file\n        photo.files.add(image_file)\n        \n        # Initially no video\n        has_video = photo.files.filter(type=File.VIDEO).exists()\n        self.assertFalse(has_video)\n        \n        # Add video for Live Photo\n        video_file = File.objects.create(\n            hash=\"video\" + \"b\" * 27,\n            path=\"/photos/IMG_001.mov\",\n            type=File.VIDEO,\n        )\n        photo.files.add(video_file)\n        \n        # Now has video\n        has_video = photo.files.filter(type=File.VIDEO).exists()\n        self.assertTrue(has_video)\n"
  },
  {
    "path": "api/tests/test_regenerate_titles.py",
    "content": "from datetime import datetime\n\nimport pytz\nfrom django.test import TestCase\n\nfrom api.models import AlbumAuto, User\n\n\nclass RegenerateTitlesTestCase(TestCase):\n    def test_regenerate_titles(self):\n        admin = User.objects.create_superuser(\n            \"test_admin\", \"test_admin@test.com\", \"test_password\"\n        )\n        album_auto = AlbumAuto.objects.create(\n            timestamp=datetime.strptime(\"2022-01-02\", \"%Y-%m-%d\").replace(\n                tzinfo=pytz.utc\n            ),\n            created_on=datetime.strptime(\"2022-01-02\", \"%Y-%m-%d\").replace(\n                tzinfo=pytz.utc\n            ),\n            owner=admin,\n        )\n        album_auto._generate_title()\n        self.assertEqual(album_auto.title, \"Sunday\")\n"
  },
  {
    "path": "api/tests/test_retrieve_photo.py",
    "content": "from django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass RetrievePhotoTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.admin = create_test_user(is_admin=True)\n        self.user = create_test_user()\n\n    def test_should_retrieve_my_photo(self):\n        self.client.force_authenticate(user=self.user)\n        photo = create_test_photo(owner=self.user)\n\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.get(\n            f\"/api/photos/{photo.image_hash}/\",\n            format=\"json\",\n            headers=headers,\n        )\n\n        self.assertEqual(200, response.status_code)\n\n    def test_should_not_retrieve_other_user_photo(self):\n        self.client.force_authenticate(user=self.user)\n        photo = create_test_photo(owner=self.admin)\n\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.get(\n            f\"/api/photos/{photo.image_hash}/\",\n            format=\"json\",\n            headers=headers,\n        )\n\n        # Returns 404 instead of 403 to avoid leaking existence of other users' photos\n        self.assertEqual(404, response.status_code)\n\n    def test_anonymous_user_should_retrieve_public_photo(self):\n        self.client.force_authenticate(None)\n        photo = create_test_photo(owner=self.user, public=True)\n\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.get(\n            f\"/api/photos/{photo.image_hash}/\",\n            format=\"json\",\n            headers=headers,\n        )\n\n        self.assertEqual(200, response.status_code)\n\n    def test_anonymous_user_should_not_retrieve_private_photo(self):\n        self.client.force_authenticate(None)\n        photo = create_test_photo(owner=self.user, public=False)\n\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.get(\n            f\"/api/photos/{photo.image_hash}/\",\n            format=\"json\",\n            headers=headers,\n        )\n\n        self.assertEqual(404, response.status_code)\n"
  },
  {
    "path": "api/tests/test_scan_percentage_bug.py",
    "content": "import os\nimport tempfile\nimport uuid\nfrom unittest.mock import MagicMock, patch\n\nfrom django.test import TestCase  # type: ignore\n\nfrom api.directory_watcher import handle_new_image, scan_photos\nfrom api.models import LongRunningJob, User\n\n\nclass ScanPercentageProgressTestCase(TestCase):\n    def setUp(self):\n        self.user = User.objects.create_user(\n            username=\"testuser\",\n            password=\"testpass\",\n        )\n        self.user.skip_raw_files = True\n        self.user.save()\n        self.job_id = uuid.uuid4()\n\n    def _scan_file_list(self):\n        return (\n            [f\"photo{i}.jpg\" for i in range(10)]\n            + [f\"photo{i}.raw\" for i in range(7)]\n            + [f\"photo{i}.xmp\" for i in range(10)]\n            + [f\"document{i}.pdf\" for i in range(10)]\n        )\n\n    def _simulate_pre_fix_progress(self, files):\n        images_and_videos: list[str] = []\n        metadata_paths: list[str] = []\n        for path in files:\n            if path.endswith(\".xmp\"):\n                metadata_paths.append(path)\n            else:\n                images_and_videos.append(path)\n\n        processed = 0\n        for path in images_and_videos:\n            ext = os.path.splitext(path)[1].lower()\n            if ext == \".raw\" and self.user.skip_raw_files:\n                continue\n            if ext == \".pdf\":\n                continue\n            if ext == \".jpg\":\n                processed += 1\n        processed += len(metadata_paths)\n        return processed\n\n    def test_scan_progress_counts_every_discovered_file(self):\n        \"\"\"Ensure the scan job reaches 100% even when files are skipped or metadata.\"\"\"\n\n        files = self._scan_file_list()\n        pre_fix_processed = self._simulate_pre_fix_progress(files)\n        pre_fix_percentage = (pre_fix_processed / len(files)) * 100\n        _pre_fix_summary = (\n            f\"Pre-fix simulated progress: {pre_fix_processed}/{len(files)} \"\n            f\"({pre_fix_percentage:.1f}%) -> stuck\"\n        )\n        discovered_by_ext: dict[str, int] = {}\n        for path in files:\n            ext = os.path.splitext(path)[1].lower()\n            discovered_by_ext[ext] = discovered_by_ext.get(ext, 0) + 1\n        lrj = LongRunningJob.objects.create(\n            started_by=self.user,\n            job_id=self.job_id,\n            job_type=LongRunningJob.JOB_SCAN_PHOTOS,\n            progress_current=0,\n            progress_target=len(files),\n        )\n\n        photos_created_by_ext: dict[str, int] = {}\n\n        def mock_create_new_image(user, path):\n            ext = os.path.splitext(path)[1].lower()\n            if ext == \".jpg\":\n                photos_created_by_ext[ext] = photos_created_by_ext.get(ext, 0) + 1\n                return MagicMock()\n            photos_created_by_ext[ext] = photos_created_by_ext.get(ext, 0)\n            return None\n\n        thumbnail_mock = MagicMock()\n        thumbnail_mock._generate_thumbnail.return_value = None\n        thumbnail_mock._calculate_aspect_ratio.return_value = None\n        thumbnail_mock._get_dominant_color.return_value = None\n        search_instance_mock = MagicMock()\n\n        with (\n            patch(\n                \"api.directory_watcher.create_new_image\",\n                side_effect=mock_create_new_image,\n            ),\n            patch(\n                \"api.models.Thumbnail.objects.get_or_create\",\n                return_value=(thumbnail_mock, True),\n            ),\n            patch(\n                \"api.models.PhotoSearch.objects.get_or_create\",\n                return_value=(search_instance_mock, True),\n            ),\n        ):\n            for path in files:\n                handle_new_image(self.user, path, self.job_id)\n\n        lrj.refresh_from_db()\n        percentage = (\n            (lrj.progress_current / lrj.progress_target) * 100\n            if lrj.progress_target\n            else 0\n        )\n        file_breakdown = [\"File breakdown:\"]\n        for ext, total in sorted(discovered_by_ext.items()):\n            created = photos_created_by_ext.get(ext, 0)\n            behavior = \"creates Photo\" if created else \"skipped\"\n            file_breakdown.append(\n                f\" - {ext}: discovered={total}, create_new_image={behavior}\"\n            )\n        breakdown_summary = \"\\n\".join(file_breakdown)\n        progress_summary = (\n            f\"Scan job progress: {lrj.progress_current}/{lrj.progress_target} \"\n            f\"({percentage:.1f}%) finished={lrj.finished}\"\n        )\n\n        self.assertLess(\n            pre_fix_processed,\n            len(files),\n            \"Pre-fix simulation should demonstrate the bug (progress < total).\",\n        )\n        self.assertEqual(lrj.progress_target, len(files), progress_summary)\n        self.assertEqual(\n            lrj.progress_current,\n            len(files),\n            f\"{breakdown_summary}\\n{progress_summary} -> Every discovered file should advance the counter.\",\n        )\n        self.assertTrue(\n            lrj.finished,\n            f\"{breakdown_summary}\\n{progress_summary} -> Scan job must finish when current equals target.\",\n        )\n\n\nclass EmptyDirectoryScanTestCase(TestCase):\n    \"\"\"Test that scanning an empty directory completes correctly.\"\"\"\n\n    def setUp(self):\n        self.user = User.objects.create_user(\n            username=\"empty_scan_testuser\",\n            password=\"testpass\",\n        )\n        # Create a temporary empty directory for testing\n        self.temp_dir = tempfile.mkdtemp()\n        self.user.scan_directory = self.temp_dir\n        self.user.save()\n        self.job_id = uuid.uuid4()\n\n    def tearDown(self):\n        # Clean up temp directory\n        import shutil\n\n        shutil.rmtree(self.temp_dir, ignore_errors=True)\n\n    def test_empty_directory_scan_completes_successfully(self):\n        \"\"\"Ensure scanning an empty directory finishes with 0/0 progress.\"\"\"\n        # Verify directory is empty\n        self.assertEqual(\n            len(os.listdir(self.temp_dir)),\n            0,\n            \"Test directory should be empty\",\n        )\n\n        # Mock db.connections.close_all() to prevent closing test DB connection\n        # Mock AsyncTask to prevent background task issues in tests\n        with (\n            patch(\"api.directory_watcher.scan_jobs.db.connections.close_all\"),\n            patch(\"api.directory_watcher.scan_jobs.AsyncTask\"),\n            patch(\"api.directory_watcher.scan_jobs.Chain\"),\n        ):\n            # Run the scan\n            scan_photos(\n                self.user,\n                full_scan=True,\n                job_id=self.job_id,\n                scan_directory=self.temp_dir,\n            )\n\n        # Check job status\n        job = LongRunningJob.objects.get(job_id=self.job_id)\n\n        self.assertEqual(\n            job.progress_target,\n            0,\n            \"Empty directory should have progress_target=0\",\n        )\n        self.assertEqual(\n            job.progress_current,\n            0,\n            \"Empty directory should have progress_current=0\",\n        )\n        self.assertTrue(\n            job.finished,\n            \"Empty directory scan should be marked as finished\",\n        )\n        self.assertFalse(\n            job.failed,\n            \"Empty directory scan should not be marked as failed\",\n        )\n"
  },
  {
    "path": "api/tests/test_scan_photos.py",
    "content": "import os\nfrom unittest import skip\n\nfrom django.test import TestCase\nfrom pyfakefs.fake_filesystem_unittest import Patcher\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_user\n\n# get path to test images\ntest_images_path = os.path.join(os.path.dirname(__file__), \"test_images\")\n\n\n@skip\nclass ScanPhotosTestCase(TestCase):\n    def setUp(self):\n        with Patcher() as patcher:\n            samplephotos_dir = \"/data/samplephotos\"\n            # add dependencies\n            patcher.fs.add_real_directory(\"/usr/local/lib/python3.11/dist-packages/\")\n            patcher.fs.add_real_directory(\n                test_images_path, target_path=samplephotos_dir\n            )\n            self.client_admin = APIClient()\n            self.admin = create_test_user(is_admin=True)\n            self.client_admin.force_authenticate(self.admin)\n\n            response = self.client_admin.patch(\n                f\"/api/manage/user/{self.admin.id}/\",\n                {\"scan_directory\": samplephotos_dir},\n            )\n\n            self.assertEqual(response.status_code, 200)\n            self.assertEqual(response.json()[\"scan_directory\"], samplephotos_dir)\n\n            # scan photos\n            scan_photos_res = self.client_admin.get(\"/api/scanphotos/\")\n            self.assertEqual(scan_photos_res.status_code, 200)\n\n            # make sure photos are imported\n            get_photos_res = self.client_admin.get(\"/api/photos/\")\n            self.assertEqual(get_photos_res.status_code, 200)\n            self.assertGreater(len(get_photos_res.json()[\"results\"]), 0)\n\n            # try scanning again and make sure there are no duplicate imports\n            num_photos = len(get_photos_res.json()[\"results\"])\n            scan_photos_res = self.client_admin.get(\"/api/scanphotos/\")\n            self.assertEqual(scan_photos_res.status_code, 200)\n\n            get_photos_res = self.client_admin.get(\"/api/photos/\")\n            self.assertEqual(get_photos_res.status_code, 200)\n            self.assertEqual(len(get_photos_res.json()[\"results\"]), num_photos)\n\n    def test_setup(self):\n        \"\"\"Make sure setup works\"\"\"\n        pass\n\n    @skip\n    def test_auto_albums(self):\n        \"\"\"Make sure user can make auto albums, list and retrieve them\"\"\"\n        # make auto albums\n        auto_album_gen_res = self.client_admin.get(\"/api/autoalbumgen/\")\n        self.assertEqual(auto_album_gen_res.status_code, 200)\n\n        # make sure auto albums are there\n        auto_album_list_res = self.client_admin.get(\"/api/albums/auto/list/\")\n        self.assertEqual(auto_album_list_res.status_code, 200)\n\n        # make sure user can retrieve each auto album\n        for album in auto_album_list_res.json()[\"results\"]:\n            auto_album_retrieve_res = self.client_admin.get(\n                \"/api/albums/auto/%d/\" % album[\"id\"]\n            )\n            self.assertEqual(auto_album_retrieve_res.status_code, 200)\n            self.assertGreater(len(auto_album_retrieve_res.json()[\"photos\"]), 0)\n\n        # try making auto albums again and make sure there are no duplicates\n        num_auto_albums = len(auto_album_list_res.json()[\"results\"])\n\n        auto_album_gen_res = self.client_admin.get(\"/api/autoalbumgen/\")\n        self.assertEqual(auto_album_gen_res.status_code, 200)\n\n        auto_album_list_res = self.client_admin.get(\"/api/albums/auto/list/\")\n        self.assertEqual(len(auto_album_list_res.json()[\"results\"]), num_auto_albums)\n\n    @skip\n    def test_place_albums(self):\n        \"\"\"Make sure user can list and retrieve place albums\"\"\"\n        place_album_list_res = self.client_admin.get(\"/api/albums/place/list/\")\n        self.assertEqual(place_album_list_res.status_code, 200)\n\n        for album in place_album_list_res.json()[\"results\"]:\n            place_album_retrieve_res = self.client_admin.get(\n                \"/api/albums/place/%d/\" % album[\"id\"]\n            )\n            self.assertEqual(place_album_retrieve_res.status_code, 200)\n\n    @skip\n    def test_thing_albums(self):\n        \"\"\"Make sure user can list and retrieve thing albums\"\"\"\n        thing_album_list_res = self.client_admin.get(\"/api/albums/thing/list/\")\n        self.assertEqual(thing_album_list_res.status_code, 200)\n\n        for album in thing_album_list_res.json()[\"results\"]:\n            thing_album_retrieve_res = self.client_admin.get(\n                \"/api/albums/thing/%d/\" % album[\"id\"]\n            )\n            self.assertEqual(thing_album_retrieve_res.status_code, 200)\n"
  },
  {
    "path": "api/tests/test_scan_photos_directories.py",
    "content": "import os\nimport tempfile\nimport uuid\nfrom unittest.mock import patch\n\nfrom django.test import TestCase, override_settings\n\nfrom api.directory_watcher import scan_photos\nfrom api.tests.utils import create_test_user\n\n\nclass DummyAsyncTask:\n    def __init__(self, *args, **kwargs):\n        pass\n\n    def run(self):\n        return None\n\n\nclass DummyChain:\n    def __init__(self, *args, **kwargs):\n        self.appended = []\n\n    def append(self, *args, **kwargs):\n        self.appended.append((args, kwargs))\n        return self\n\n    def run(self):\n        return None\n\n\nclass ScanPhotosDirectoryCreationTest(TestCase):\n    def test_existing_thumbnail_directory_does_not_raise(self):\n        user = create_test_user()\n        with tempfile.TemporaryDirectory() as media_root:\n            preexisting_dir = os.path.join(media_root, \"square_thumbnails_small\")\n            os.makedirs(preexisting_dir, exist_ok=True)\n\n            user.scan_directory = media_root\n            user.save(update_fields=[\"scan_directory\"])\n\n            with override_settings(MEDIA_ROOT=media_root):\n                with (\n                    patch(\"api.directory_watcher.scan_jobs.walk_directory\"),\n                    patch(\"api.directory_watcher.scan_jobs.walk_files\"),\n                    patch(\"api.directory_watcher.scan_jobs.photo_scanner\"),\n                    patch(\"api.directory_watcher.scan_jobs.AsyncTask\", DummyAsyncTask),\n                    patch(\"api.directory_watcher.scan_jobs.Chain\", DummyChain),\n                    patch(\"api.directory_watcher.scan_jobs.db.connections.close_all\"),\n                ):\n                    scan_photos(user, full_scan=False, job_id=str(uuid.uuid4()))\n\n            expected_directories = [\n                \"square_thumbnails_small\",\n                \"square_thumbnails\",\n                \"thumbnails_big\",\n            ]\n            for directory_name in expected_directories:\n                directory_path = os.path.join(media_root, directory_name)\n                self.assertTrue(\n                    os.path.isdir(directory_path),\n                    msg=f\"Expected directory {directory_path} to exist\",\n                )\n"
  },
  {
    "path": "api/tests/test_search_term_examples.py",
    "content": "from django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.api_util import get_search_term_examples\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass SearchTermExamplesTest(TestCase):\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_get_search_term_examples_with_captions(self):\n        \"\"\"Test that get_search_term_examples works after caption refactoring\"\"\"\n        # Create a photo with captions and geolocation\n        photo = create_test_photo(owner=self.user)\n\n        # Add geolocation data to avoid the NoneType error\n        photo.geolocation_json = {\n            \"features\": [\n                {\"text\": \"New York\"},\n                {\"text\": \"USA\"},\n                {\"text\": \"North America\"},\n            ]\n        }\n        photo.save()\n\n        # Add some caption data through the new PhotoCaption model\n        from api.models.photo_caption import PhotoCaption\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n        caption_instance.captions_json = {\n            \"places365\": {\n                \"categories\": [\"outdoor\", \"nature\"],\n                \"attributes\": [\"sunny\", \"green\"],\n            },\n            \"im2txt\": \"A beautiful landscape\",\n            \"user_caption\": \"My vacation photo\",\n        }\n        caption_instance.save()\n\n        # This should not raise a FieldError\n        search_terms = get_search_term_examples(self.user)\n\n        # Should return some search terms\n        self.assertIsInstance(search_terms, list)\n\n    def test_get_search_term_examples_with_empty_captions(self):\n        \"\"\"Test that get_search_term_examples works with empty captions\"\"\"\n        # Create a photo without captions\n        photo = create_test_photo(owner=self.user)\n\n        # Add geolocation data\n        photo.geolocation_json = {\n            \"features\": [\n                {\"text\": \"Miami\"},\n                {\"text\": \"Florida\"},\n                {\"text\": \"USA\"},\n            ]\n        }\n        photo.save()\n\n        # Add empty caption data\n        from api.models.photo_caption import PhotoCaption\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n        caption_instance.captions_json = {\n            \"places365\": {\n                \"categories\": [],\n                \"attributes\": [],\n            },\n            \"im2txt\": \"\",\n            \"user_caption\": \"\",\n        }\n        caption_instance.save()\n\n        # This should not raise a FieldError\n        search_terms = get_search_term_examples(self.user)\n\n        # Should return some search terms (may be empty)\n        self.assertIsInstance(search_terms, list)\n\n    def test_search_term_examples_api_endpoint(self):\n        \"\"\"Test the API endpoint that calls get_search_term_examples\"\"\"\n        # Create a photo with captions and geolocation\n        photo = create_test_photo(owner=self.user)\n\n        # Add geolocation data\n        photo.geolocation_json = {\"features\": [{\"text\": \"Paris\"}, {\"text\": \"France\"}]}\n        photo.save()\n\n        # Add some caption data\n        from api.models.photo_caption import PhotoCaption\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n        caption_instance.captions_json = {\n            \"places365\": {\"categories\": [\"outdoor\"], \"attributes\": [\"sunny\"]}\n        }\n        caption_instance.save()\n\n        # Test the API endpoint\n        response = self.client.get(\"/api/searchtermexamples/\")\n\n        # Should not return 500 error\n        self.assertEqual(response.status_code, 200)\n        # The API returns a dict with 'results' key containing the list\n        self.assertIn(\"results\", response.data)\n        self.assertIsInstance(response.data[\"results\"], list)\n"
  },
  {
    "path": "api/tests/test_search_terms.py",
    "content": "import random\n\nfrom django.test import TestCase\n\nfrom api.api_util import get_search_term_examples\nfrom api.models import Photo\nfrom api.tests.fixtures.api_util.captions_json import captions_json\nfrom api.tests.fixtures.geocode.expectations.mapbox import expectations\nfrom api.tests.utils import (\n    create_test_photos,\n    create_test_photos_with_faces,\n    create_test_user,\n)\n\n\nclass GetSearchTermExamples(TestCase):\n    def setUp(self) -> None:\n        self.admin = create_test_user(is_admin=True)\n        self.photos = (\n            create_test_photos(\n                90,\n                owner=self.admin,\n                geolocation_json=expectations[0],\n                captions_json=captions_json,\n                exif_timestamp=\"2017-08-18 15:08:09.000000 +00:00\",\n            )\n            + create_test_photos(\n                5,\n                owner=self.admin,\n                geolocation_json={},\n                captions_json={\"places365\": None},\n            )\n            + create_test_photos_with_faces(\n                5,\n                owner=self.admin,\n                geolocation_json=expectations[0],\n                captions_json={\"places365\": None},\n            )\n        )\n        self._original__random_random = random.random\n        self._original__random_choices = random.choices\n        self._original__random_choice = random.choice\n        self._original__random_shuffle = random.shuffle\n        random.choices = lambda x, **kw: x\n        random.choice = lambda x: x[0]\n        random.shuffle = lambda x: x\n\n    def tearDown(self) -> None:\n        random.random = self._original__random_random\n        random.choices = self._original__random_choices\n        random.choice = self._original__random_choice\n        random.shuffle = self._original__random_shuffle\n\n    def test_get_search_term_examples_0(self):\n        random.random = lambda: 0\n        array = get_search_term_examples(self.admin)\n        self.assertEqual(len(array), 3)\n        self.assertEqual(set(array), {\"phone booth\", \"2017\", \"Beach Road\"})\n\n    def test_get_search_term_examples_2(self):\n        random.random = lambda: 0.5\n        array = get_search_term_examples(self.admin)\n        self.assertEqual(len(array), 4)\n        self.assertEqual(\n            set(array),\n            {\n                \"2017\",\n                \"Beach Road 2017\",\n                \"Beach Road\",\n                \"phone booth\",\n            },\n        )\n\n    def test_get_search_term_examples_3(self):\n        random.random = lambda: 1\n        array = get_search_term_examples(self.admin)\n        self.assertEqual(len(array), 7)\n        self.assertEqual(\n            set(array),\n            {\n                \"2017 phone booth\",\n                \"2017\",\n                \"Beach Road  2017 phone booth\",\n                \"Beach Road 2017\",\n                \"Beach Road phone booth\",\n                \"Beach Road\",\n                \"phone booth\",\n            },\n        )\n\n    def test_get_search_term_examples_without_photos(self):\n        Photo.objects.all().delete()\n        array = get_search_term_examples(self.admin)\n        self.assertEqual(len(array), 5)\n        self.assertEqual(\n            set(array),\n            {\n                \"for time\",\n                \"for places\",\n                \"for people\",\n                \"for file path or file name\",\n                \"for things\",\n            },\n        )\n"
  },
  {
    "path": "api/tests/test_services.py",
    "content": "\"\"\"Tests for CPU architecture detection and service compatibility checks\"\"\"\nimport unittest\nfrom unittest.mock import patch\n\nfrom api.services import check_cpu_features, has_required_cpu_features, _is_arm_architecture\n\n\nclass TestServiceCPUCompatibility(unittest.TestCase):\n    \"\"\"Test CPU feature detection and compatibility checks\"\"\"\n\n    # Constants for testing\n    ARM_ARCHITECTURES = ['aarch64', 'arm64', 'armv7l', 'armv8']\n    X86_ARCHITECTURES = ['x86_64', 'i686', 'i386', 'AMD64']\n\n    @patch('api.services.platform.machine')\n    def test_is_arm_architecture_detection(self, mock_machine):\n        \"\"\"Test that _is_arm_architecture helper correctly identifies ARM architectures\"\"\"\n        # Test lowercase ARM architectures\n        for arch in self.ARM_ARCHITECTURES:\n            mock_machine.return_value = arch\n            self.assertTrue(_is_arm_architecture(), f\"Should detect {arch} as ARM\")\n        \n        # Test case-insensitive detection\n        for arch in ['AARCH64', 'ARM64', 'AArch64']:\n            mock_machine.return_value = arch\n            self.assertTrue(_is_arm_architecture(), f\"Should detect {arch} as ARM (case-insensitive)\")\n        \n        # Test x86 architectures are not detected as ARM\n        for arch in self.X86_ARCHITECTURES:\n            mock_machine.return_value = arch\n            self.assertFalse(_is_arm_architecture(), f\"Should not detect {arch} as ARM\")\n\n    @patch('api.services.platform.machine')\n    def test_check_cpu_features_on_arm(self, mock_machine):\n        \"\"\"Test that ARM architectures skip x86 CPU feature checks\"\"\"\n        for arch in self.ARM_ARCHITECTURES:\n            mock_machine.return_value = arch\n            features = check_cpu_features()\n            # On ARM, should return empty list as x86 features don't apply\n            self.assertEqual(features, [], f\"Expected empty list for {arch}\")\n\n    @patch('api.services.platform.machine')\n    def test_check_cpu_features_on_x86(self, mock_machine):\n        \"\"\"Test that x86 architectures attempt to check CPU features\"\"\"\n        for arch in self.X86_ARCHITECTURES:\n            mock_machine.return_value = arch\n            # Should not return early, will try to import cpuinfo\n            # We can't easily test the cpuinfo part without mocking more\n            features = check_cpu_features()\n            # Should be a list (may be empty if cpuinfo not available)\n            self.assertIsInstance(features, list, f\"Expected list for {arch}\")\n\n    @patch('api.services.platform.machine')\n    def test_has_required_cpu_features_on_arm(self, mock_machine):\n        \"\"\"Test that ARM architectures bypass x86-specific CPU checks\"\"\"\n        mock_machine.return_value = 'aarch64'\n        \n        # LLM service has CPU requirements, but should be allowed on ARM\n        result = has_required_cpu_features('llm')\n        self.assertTrue(result, \"LLM service should be compatible on ARM\")\n\n    @patch('api.services.platform.machine')\n    def test_has_required_cpu_features_no_requirements(self, mock_machine):\n        \"\"\"Test services without CPU requirements are always compatible\"\"\"\n        mock_machine.return_value = 'x86_64'\n        \n        # Service without requirements should always return True\n        result = has_required_cpu_features('thumbnail')\n        self.assertTrue(result, \"Services without requirements should be compatible\")\n\n    @patch('api.services.platform.machine')\n    @patch('api.services.check_cpu_features')\n    def test_has_required_cpu_features_on_x86_with_features(self, mock_features, mock_machine):\n        \"\"\"Test x86 with required features present\"\"\"\n        mock_machine.return_value = 'x86_64'\n        mock_features.return_value = ['avx', 'avx2', 'sse4_2', 'fma', 'f16c']\n        \n        result = has_required_cpu_features('llm')\n        self.assertTrue(result, \"Should be compatible when all features present\")\n\n    @patch('api.services.platform.machine')\n    @patch('api.services.check_cpu_features')\n    def test_has_required_cpu_features_on_x86_missing_required(self, mock_features, mock_machine):\n        \"\"\"Test x86 missing required features\"\"\"\n        mock_machine.return_value = 'x86_64'\n        mock_features.return_value = []  # No features available\n        \n        result = has_required_cpu_features('llm')\n        self.assertFalse(result, \"Should be incompatible when required features missing\")\n\n    @patch('api.services.platform.machine')\n    @patch('api.services.check_cpu_features')\n    def test_has_required_cpu_features_on_x86_missing_recommended(self, mock_features, mock_machine):\n        \"\"\"Test x86 with required features but missing recommended ones\"\"\"\n        mock_machine.return_value = 'x86_64'\n        # Has required but not recommended\n        mock_features.return_value = ['avx', 'sse4_2']\n        \n        result = has_required_cpu_features('llm')\n        self.assertTrue(result, \"Should be compatible with required features even if missing recommended\")\n\n\n"
  },
  {
    "path": "api/tests/test_setup_directory.py",
    "content": "from django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.models import User\nfrom api.tests.utils import create_password\n\n\nclass SetupDirectoryTestCase(TestCase):\n    userid = 0\n\n    def setUp(self):\n        self.client = APIClient()\n        self.admin = User.objects.create_superuser(\n            \"test_admin\", \"test_admin@test.com\", create_password()\n        )\n\n    def test_setup_directory(self):\n        self.client.force_authenticate(user=self.admin)\n        response = self.client.patch(\n            f\"/api/manage/user/{self.admin.id}/\",\n            {\"scan_directory\": \"/data\"},\n        )\n        self.assertEqual(response.status_code, 200)\n\n    def test_setup_not_existing_directory(self):\n        self.client.force_authenticate(user=self.admin)\n        response = self.client.patch(\n            f\"/api/manage/user/{self.admin.id}/\",\n            {\"scan_directory\": \"/non-existent-directory\"},\n        )\n        self.assertEqual(response.status_code, 400)\n        # Check for the error message in the new format\n        data = response.json()\n        self.assertIn(\"errors\", data)\n        self.assertGreater(len(data[\"errors\"]), 0)\n        self.assertEqual(\n            data[\"errors\"][0][\"message\"], \"Scan directory must be inside the data root.\"\n        )\n"
  },
  {
    "path": "api/tests/test_share_photos.py",
    "content": "import logging\nfrom unittest import skip\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.models import Photo\nfrom api.tests.utils import create_test_photos, create_test_user, share_test_photos\n\nlogger = logging.getLogger(__name__)\n\n\nclass SharePhotosTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user1 = create_test_user()\n        self.user2 = create_test_user()\n        self.client.force_authenticate(user=self.user1)\n\n    def test_share_photos(self):\n        photos = create_test_photos(number_of_photos=3, owner=self.user1)\n        image_hashes = [p.image_hash for p in photos]\n        photo_ids = [p.id for p in photos]\n\n        payload = {\n            \"image_hashes\": image_hashes,\n            \"val_shared\": True,\n            \"target_user_id\": self.user2.id,\n        }\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/share/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(3, data[\"count\"])\n        # Query by photo UUID (pk), not image_hash\n        shared_photos = list(\n            Photo.shared_to.through.objects.filter(\n                user_id=self.user2.id, photo_id__in=photo_ids\n            )\n        )\n        self.assertEqual(3, len(shared_photos))\n\n    def test_unshare_photos(self):\n        photos = create_test_photos(number_of_photos=3, owner=self.user1)\n        image_hashes = [p.image_hash for p in photos]\n        photo_ids = [p.id for p in photos]\n        share_test_photos(image_hashes, self.user2)\n\n        payload = {\n            \"image_hashes\": image_hashes,\n            \"val_shared\": False,\n            \"target_user_id\": self.user2.id,\n        }\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/share/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(3, data[\"count\"])\n        # Query by photo UUID (pk), not image_hash\n        shared_photos = list(\n            Photo.shared_to.through.objects.filter(\n                user_id=self.user2.id, photo_id__in=photo_ids\n            )\n        )\n        self.assertEqual(0, len(shared_photos))\n\n    @skip(\"BUG!!! scenario not implemented\")\n    def test_share_other_user_photos(self):\n        photos = create_test_photos(number_of_photos=2, owner=self.user2)\n        image_hashes = [p.image_hash for p in photos]\n\n        payload = {\n            \"image_hashes\": image_hashes,\n            \"val_shared\": True,\n            \"target_user_id\": self.user1.id,\n        }\n        headers = {\"Content-Type\": \"application/json\"}\n        response = self.client.post(\n            \"/api/photosedit/share/\", format=\"json\", data=payload, headers=headers\n        )\n        data = response.json()\n\n        self.assertTrue(data[\"status\"])\n        self.assertEqual(0, data[\"count\"])\n        shared_photos = list(\n            Photo.shared_to.through.objects.filter(\n                user_id=self.user1.id, photo_id__in=image_hashes\n            )\n        )\n        self.assertEqual(0, len(shared_photos))\n"
  },
  {
    "path": "api/tests/test_skip_raw_files.py",
    "content": "\"\"\"\nTest to verify the behavior of the stack_raw_jpeg feature during scans.\nNote: skip_raw_files is deprecated - RAW files are always imported, but can be stacked or not.\n\"\"\"\nfrom unittest.mock import patch\n\nfrom django.test import TestCase\n\nfrom api.models import Photo, User\nfrom api.models.file import is_valid_media\n\n\nclass StackRawJpegTestCase(TestCase):\n    \"\"\"Test to verify that RAW files are always imported and can be stacked\"\"\"\n\n    def setUp(self):\n        \"\"\"Set up the test environment\"\"\"\n        self.user = User.objects.create_user(\n            username=\"testuser\",\n            email=\"test@example.com\",\n            password=\"testpass123\",\n        )\n        self.user.scan_directory = \"/tmp/test_photos\"\n        self.user.save()\n\n    def tearDown(self):\n        \"\"\"Clean up after tests\"\"\"\n        Photo.objects.filter(owner=self.user).delete()\n        self.user.delete()\n\n    def test_raw_files_always_valid(self):\n        \"\"\"\n        Test: RAW files are always considered valid (no longer skipped)\n        \"\"\"\n        # Verify that is_valid_media returns True for RAW files regardless of stack_raw_jpeg\n        with patch(\"api.models.file.is_raw\") as mock_is_raw:\n            mock_is_raw.return_value = True\n            # With stack_raw_jpeg=True\n            self.user.stack_raw_jpeg = True\n            self.user.save()\n            result = is_valid_media(\"/new/raw/file.NEF\", self.user)\n            self.assertTrue(\n                result,\n                \"RAW files should always be considered valid\",\n            )\n\n            # With stack_raw_jpeg=False\n            self.user.stack_raw_jpeg = False\n            self.user.save()\n            result = is_valid_media(\"/new/raw/file.NEF\", self.user)\n            self.assertTrue(\n                result,\n                \"RAW files should always be considered valid even with stack_raw_jpeg=False\",\n            )\n"
  },
  {
    "path": "api/tests/test_social_graph.py",
    "content": "\"\"\"\nTests for social graph functionality.\n\"\"\"\n\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom api.models import Face, Person, Photo, User\nfrom api.social_graph import build_social_graph\n\n\nclass SocialGraphTestCase(TestCase):\n    def setUp(self):\n        \"\"\"Set up test data.\"\"\"\n        self.user = User.objects.create_user(username=\"testuser\", password=\"testpass\")\n\n    def test_build_social_graph_no_data(self):\n        \"\"\"Test building social graph with no faces/photos.\"\"\"\n        result = build_social_graph(self.user)\n        self.assertEqual(result, {\"nodes\": [], \"links\": []})\n\n    def test_build_social_graph_single_person(self):\n        \"\"\"Test building social graph with single person (no connections).\"\"\"\n        # Create a photo\n        photo = Photo.objects.create(\n            owner=self.user,\n            image_hash=\"testhash1\",\n            added_on=timezone.now(),\n        )\n\n        # Create a person\n        person = Person.objects.create(name=\"John Doe\", owner=self.user)\n\n        # Create a face\n        Face.objects.create(\n            photo=photo,\n            person=person,\n            location_top=0,\n            location_bottom=100,\n            location_left=0,\n            location_right=100,\n            encoding=\"0\" * 256,  # Dummy encoding\n        )\n\n        # Should return empty since we need at least 2 people in same photo for connections\n        result = build_social_graph(self.user)\n        self.assertEqual(result, {\"nodes\": [], \"links\": []})\n\n    def test_build_social_graph_two_people_same_photo(self):\n        \"\"\"Test building social graph with two people in same photo.\"\"\"\n        # Create a photo\n        photo = Photo.objects.create(\n            owner=self.user,\n            image_hash=\"testhash2\",\n            added_on=timezone.now(),\n        )\n\n        # Create two people\n        person1 = Person.objects.create(name=\"Alice\", owner=self.user)\n        person2 = Person.objects.create(name=\"Bob\", owner=self.user)\n\n        # Create faces for both people in the same photo\n        Face.objects.create(\n            photo=photo,\n            person=person1,\n            location_top=0,\n            location_bottom=100,\n            location_left=0,\n            location_right=100,\n            encoding=\"0\" * 256,\n        )\n        Face.objects.create(\n            photo=photo,\n            person=person2,\n            location_top=0,\n            location_bottom=100,\n            location_left=100,\n            location_right=200,\n            encoding=\"1\" * 256,\n        )\n\n        result = build_social_graph(self.user)\n\n        # Should have 2 nodes and 1 link\n        self.assertEqual(len(result[\"nodes\"]), 2)\n        self.assertEqual(len(result[\"links\"]), 1)\n\n        # Check node names\n        node_ids = {node[\"id\"] for node in result[\"nodes\"]}\n        self.assertEqual(node_ids, {\"Alice\", \"Bob\"})\n\n        # Check link\n        link = result[\"links\"][0]\n        self.assertIn(link[\"source\"], {\"Alice\", \"Bob\"})\n        self.assertIn(link[\"target\"], {\"Alice\", \"Bob\"})\n        self.assertNotEqual(link[\"source\"], link[\"target\"])\n"
  },
  {
    "path": "api/tests/test_stack_api_edge_cases.py",
    "content": "\"\"\"\nEdge case tests for Stack API endpoints.\n\nTests cover:\n- Manual stack creation edge cases\n- Add/remove photos from stacks\n- Set primary photo\n- Merge stacks\n- Stack statistics\n- Detection triggers\n\"\"\"\n\nimport uuid\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.models.photo_stack import PhotoStack\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass ManualStackCreationAPITestCase(TestCase):\n    \"\"\"Tests for manual stack creation API.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_create_manual_stack_success(self):\n        \"\"\"Test creating a manual stack with valid photos.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [photo1.image_hash, photo2.image_hash]},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 201)\n        self.assertIn(\"stack_id\", response.data)\n        \n        # Verify stack was created\n        stack = PhotoStack.objects.get(id=response.data[\"stack_id\"])\n        self.assertEqual(stack.stack_type, PhotoStack.StackType.MANUAL)\n        self.assertEqual(stack.photos.count(), 2)\n\n    def test_create_manual_stack_minimum_photos(self):\n        \"\"\"Test that manual stack requires at least 2 photos.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        \n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [photo1.image_hash]},\n            format='json',\n        )\n        \n        # Should fail - need at least 2 photos\n        self.assertEqual(response.status_code, 400)\n\n    def test_create_manual_stack_empty_photos(self):\n        \"\"\"Test creating manual stack with empty photo list.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": []},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 400)\n\n    def test_create_manual_stack_nonexistent_photos(self):\n        \"\"\"Test creating manual stack with nonexistent photo hashes.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [\"nonexistent1\", \"nonexistent2\"]},\n            format='json',\n        )\n        \n        # Should fail - photos don't exist\n        self.assertEqual(response.status_code, 400)\n\n    def test_create_manual_stack_other_users_photos(self):\n        \"\"\"Test creating manual stack with other user's photos.\"\"\"\n        other_user = create_test_user()\n        other_photo = create_test_photo(owner=other_user)\n        my_photo = create_test_photo(owner=self.user)\n        \n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [my_photo.image_hash, other_photo.image_hash]},\n            format='json',\n        )\n        \n        # Should fail - only 1 photo found (other user's photo not found)\n        self.assertEqual(response.status_code, 400)\n\n    def test_create_manual_stack_duplicate_hashes(self):\n        \"\"\"Test creating manual stack with duplicate photo hashes.\n        \n        Bug #15 Fixed: Duplicate hashes are now de-duplicated before validation.\n        If there's only 1 unique photo, the error message correctly states\n        \"At least 2 unique photos required\".\n        \"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [photo.image_hash, photo.image_hash]},\n            format='json',\n        )\n        \n        # After fix: Duplicates are de-duplicated first, then we check if we have >= 2\n        # Since there's only 1 unique photo, it fails with a clear message\n        self.assertEqual(response.status_code, 400)\n        self.assertEqual(response.data[\"error\"], \"At least 2 unique photos required to create a stack\")\n\n\nclass AddRemovePhotosAPITestCase(TestCase):\n    \"\"\"Tests for adding/removing photos from stacks.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_add_photo_to_stack(self):\n        \"\"\"Test adding a photo to an existing stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/add\",\n            {\"photo_hashes\": [photo3.image_hash]},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        stack.refresh_from_db()\n        self.assertEqual(stack.photos.count(), 3)\n\n    def test_add_already_in_stack_photo(self):\n        \"\"\"Test adding a photo that's already in the stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/add\",\n            {\"photo_hashes\": [photo1.image_hash]},\n            format='json',\n        )\n        \n        # Should succeed (idempotent) but count stays same\n        self.assertEqual(response.status_code, 200)\n        stack.refresh_from_db()\n        self.assertEqual(stack.photos.count(), 2)\n\n    def test_remove_photo_from_stack(self):\n        \"\"\"Test removing a photo from a stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2, photo3)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/remove\",\n            {\"photo_hashes\": [photo3.image_hash]},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        stack.refresh_from_db()\n        self.assertEqual(stack.photos.count(), 2)\n\n    def test_remove_to_one_photo_deletes_stack(self):\n        \"\"\"Test that removing photos until 1 left deletes the stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        stack_id = stack.id\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack_id}/remove\",\n            {\"photo_hashes\": [photo2.image_hash]},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        # Stack should be deleted (only 1 photo left)\n        self.assertFalse(PhotoStack.objects.filter(id=stack_id).exists())\n\n    def test_remove_photo_not_in_stack(self):\n        \"\"\"Test removing a photo that's not in the stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/remove\",\n            {\"photo_hashes\": [photo3.image_hash]},\n            format='json',\n        )\n        \n        # Should succeed (no-op) or return 400\n        self.assertIn(response.status_code, [200, 400])\n\n\nclass SetPrimaryPhotoAPITestCase(TestCase):\n    \"\"\"Tests for setting primary/cover photo.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_set_primary_photo(self):\n        \"\"\"Test setting a primary photo for a stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            primary_photo=photo1,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/primary\",\n            {\"photo_hash\": photo2.image_hash},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        stack.refresh_from_db()\n        self.assertEqual(stack.primary_photo, photo2)\n\n    def test_set_primary_photo_not_in_stack(self):\n        \"\"\"Test setting primary photo that's not in the stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/primary\",\n            {\"photo_hash\": photo3.image_hash},\n            format='json',\n        )\n        \n        # Should fail - photo not in stack\n        self.assertEqual(response.status_code, 400)\n\n    def test_set_primary_nonexistent_photo(self):\n        \"\"\"Test setting primary with nonexistent photo hash.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/primary\",\n            {\"photo_hash\": \"nonexistent\"},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 400)\n\n\nclass MergeStacksAPITestCase(TestCase):\n    \"\"\"Tests for merging stacks.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_merge_two_stacks(self):\n        \"\"\"Test merging two manual stacks.\"\"\"\n        photos1 = [create_test_photo(owner=self.user) for _ in range(2)]\n        photos2 = [create_test_photo(owner=self.user) for _ in range(2)]\n        \n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack1.photos.add(*photos1)\n        \n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack2.photos.add(*photos2)\n        \n        # Get all photo hashes from both stacks\n        all_hashes = [p.image_hash for p in photos1 + photos2]\n        \n        response = self.client.post(\n            \"/api/stacks/merge\",\n            {\"photo_hashes\": all_hashes},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        \n        # Should have 1 stack with all 4 photos\n        stacks = PhotoStack.objects.filter(owner=self.user, stack_type=PhotoStack.StackType.MANUAL)\n        self.assertEqual(stacks.count(), 1)\n        self.assertEqual(stacks.first().photos.count(), 4)\n\n    def test_merge_nonexistent_stacks(self):\n        \"\"\"Test merging with photos not in any stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        response = self.client.post(\n            \"/api/stacks/merge\",\n            {\"photo_hashes\": [photo1.image_hash, photo2.image_hash]},\n            format='json',\n        )\n        \n        # Should create a new stack or return error\n        self.assertIn(response.status_code, [200, 201, 400])\n\n\nclass StackStatsAPITestCase(TestCase):\n    \"\"\"Tests for stack statistics endpoint.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_stats_with_no_stacks(self):\n        \"\"\"Test stats endpoint with no stacks.\"\"\"\n        response = self.client.get(\"/api/stacks/stats\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"total_stacks\"], 0)\n\n    def test_stats_counts_by_type(self):\n        \"\"\"Test stats counts by stack type.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(6)]\n        \n        # Create different stack types\n        burst_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        burst_stack.photos.add(photos[0], photos[1])\n        \n        raw_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.RAW_JPEG_PAIR,\n        )\n        raw_stack.photos.add(photos[2], photos[3])\n        \n        manual_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        manual_stack.photos.add(photos[4], photos[5])\n        \n        response = self.client.get(\"/api/stacks/stats\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"total_stacks\"], 3)\n        self.assertEqual(response.data[\"by_type\"][PhotoStack.StackType.BURST_SEQUENCE], 1)\n        self.assertEqual(response.data[\"by_type\"][PhotoStack.StackType.RAW_JPEG_PAIR], 1)\n        self.assertEqual(response.data[\"by_type\"][PhotoStack.StackType.MANUAL], 1)\n\n    def test_stats_photos_in_stacks(self):\n        \"\"\"Test stats counts photos in stacks correctly.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(5)]\n        \n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack1.photos.add(photos[0], photos[1], photos[2])\n        \n        # Create another stack with overlapping photo\n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack2.photos.add(photos[2], photos[3])  # photos[2] in both\n        \n        response = self.client.get(\"/api/stacks/stats\")\n        \n        self.assertEqual(response.status_code, 200)\n        # 4 unique photos (0,1,2,3)\n        self.assertEqual(response.data[\"photos_in_stacks\"], 4)\n\n    def test_stats_other_users_not_included(self):\n        \"\"\"Test stats don't include other user's stacks.\"\"\"\n        other_user = create_test_user()\n        \n        # Create stack for other user\n        other_photo1 = create_test_photo(owner=other_user)\n        other_photo2 = create_test_photo(owner=other_user)\n        stack = PhotoStack.objects.create(\n            owner=other_user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(other_photo1, other_photo2)\n        \n        response = self.client.get(\"/api/stacks/stats\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"total_stacks\"], 0)\n\n\nclass StackDeleteAPITestCase(TestCase):\n    \"\"\"Tests for stack deletion.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_delete_stack(self):\n        \"\"\"Test deleting a stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        stack_id = stack.id\n        \n        response = self.client.delete(f\"/api/stacks/{stack_id}/delete\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertFalse(PhotoStack.objects.filter(id=stack_id).exists())\n        \n        # Photos should still exist\n        photo1.refresh_from_db()\n        photo2.refresh_from_db()\n        self.assertFalse(photo1.removed)\n\n    def test_delete_nonexistent_stack(self):\n        \"\"\"Test deleting a nonexistent stack.\"\"\"\n        response = self.client.delete(f\"/api/stacks/{uuid.uuid4()}/delete\")\n        self.assertEqual(response.status_code, 404)\n\n    def test_delete_other_users_stack(self):\n        \"\"\"Test deleting another user's stack.\"\"\"\n        other_user = create_test_user()\n        photo1 = create_test_photo(owner=other_user)\n        photo2 = create_test_photo(owner=other_user)\n        \n        stack = PhotoStack.objects.create(\n            owner=other_user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.delete(f\"/api/stacks/{stack.id}/delete\")\n        \n        # Should return 404 (not found for this user)\n        self.assertEqual(response.status_code, 404)\n\n\nclass StackDetailAPITestCase(TestCase):\n    \"\"\"Tests for stack detail view.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_get_stack_detail(self):\n        \"\"\"Test getting stack details.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            primary_photo=photo1,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.get(f\"/api/stacks/{stack.id}\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"stack_type\"], PhotoStack.StackType.MANUAL)\n        self.assertEqual(len(response.data[\"photos\"]), 2)\n\n    def test_get_stack_with_deleted_primary(self):\n        \"\"\"Test getting stack when primary photo was deleted.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            primary_photo=photo1,\n        )\n        stack.photos.add(photo1, photo2, photo3)\n        \n        # Delete primary photo\n        photo1.in_trashcan = True\n        photo1.save()\n        photo1.manual_delete()\n        \n        # Stack should still exist with 2 photos\n        stack.refresh_from_db()\n        \n        response = self.client.get(f\"/api/stacks/{stack.id}\")\n        \n        self.assertEqual(response.status_code, 200)\n\n\nclass StackListAPITestCase(TestCase):\n    \"\"\"Tests for stack list view.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_list_stacks(self):\n        \"\"\"Test listing all stacks.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(4)]\n        \n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack1.photos.add(photos[0], photos[1])\n        \n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack2.photos.add(photos[2], photos[3])\n        \n        response = self.client.get(\"/api/stacks\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 2)\n\n    def test_list_stacks_filter_by_type(self):\n        \"\"\"Test filtering stacks by type.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(4)]\n        \n        manual_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        manual_stack.photos.add(photos[0], photos[1])\n        \n        burst_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        burst_stack.photos.add(photos[2], photos[3])\n        \n        response = self.client.get(f\"/api/stacks?stack_type={PhotoStack.StackType.MANUAL}\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 1)\n\n    def test_list_excludes_single_photo_stacks(self):\n        \"\"\"Test that list excludes stacks with only 1 photo.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo)\n        \n        response = self.client.get(\"/api/stacks\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 0)\n\n\nclass DetectionTriggerAPITestCase(TestCase):\n    \"\"\"Tests for detection trigger endpoints.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_trigger_detection(self):\n        \"\"\"Test triggering stack detection.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/detect\",\n            {\n                \"detect_raw_jpeg\": True,\n                \"detect_bursts\": False,\n                \"detect_live_photos\": False,\n            },\n            format='json',\n        )\n        \n        # Should return 202 Accepted (queued)\n        self.assertEqual(response.status_code, 202)\n\n    def test_trigger_detection_empty_body(self):\n        \"\"\"Test triggering detection with empty body (defaults).\"\"\"\n        response = self.client.post(\"/api/stacks/detect\", {}, format='json')\n        \n        # Should succeed with defaults\n        self.assertEqual(response.status_code, 202)\n"
  },
  {
    "path": "api/tests/test_stack_detection.py",
    "content": "\"\"\"\nComprehensive tests for PhotoStack Detection and API.\n\nTests cover:\n- PhotoStack model functionality\n- Stack API endpoints (list, detail, delete, set-primary, add, remove)\n- Manual stack creation and merging\n- Stack detection triggering\n- Edge cases and error handling\n\"\"\"\n\nimport uuid\nfrom unittest.mock import patch\n\nfrom django.test import TestCase\nfrom rest_framework import status\nfrom rest_framework.test import APIClient\n\nfrom api.models import Photo\nfrom api.models.photo_stack import PhotoStack\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass PhotoStackModelTestCase(TestCase):\n    \"\"\"Tests for PhotoStack model functionality.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.photo1 = create_test_photo(owner=self.user)\n        self.photo2 = create_test_photo(owner=self.user)\n        self.photo3 = create_test_photo(owner=self.user)\n\n    def test_create_stack_basic(self):\n        \"\"\"Test basic stack creation.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo1.stacks.add(stack)\n        self.photo2.stacks.add(stack)\n\n        self.assertEqual(stack.photo_count, 2)\n        self.assertEqual(stack.owner, self.user)\n        self.assertEqual(stack.stack_type, PhotoStack.StackType.MANUAL)\n\n    def test_stack_types(self):\n        \"\"\"Test all stack types can be created.\"\"\"\n        for stack_type, display_name in PhotoStack.StackType.choices:\n            stack = PhotoStack.objects.create(\n                owner=self.user,\n                stack_type=stack_type,\n            )\n            self.assertEqual(stack.stack_type, stack_type)\n            self.assertIn(display_name, str(stack.get_stack_type_display()))\n\n    def test_auto_select_primary_for_manual_stack(self):\n        \"\"\"Test auto-selecting primary for manual stack picks highest resolution.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo1.stacks.add(stack)\n        self.photo2.stacks.add(stack)\n        \n        stack.auto_select_primary()\n        \n        # Primary should be set\n        self.assertIsNotNone(stack.primary_photo)\n        self.assertIn(stack.primary_photo, [self.photo1, self.photo2])\n\n    def test_auto_select_primary_empty_stack(self):\n        \"\"\"Test auto_select_primary returns None for empty stack.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        \n        result = stack.auto_select_primary()\n        \n        self.assertIsNone(result)\n        self.assertIsNone(stack.primary_photo)\n\n    def test_merge_with_another_stack(self):\n        \"\"\"Test merging two stacks.\"\"\"\n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        \n        self.photo1.stacks.add(stack1)\n        self.photo2.stacks.add(stack2)\n        self.photo3.stacks.add(stack2)\n        \n        stack2_id = stack2.pk\n        stack1.merge_with(stack2)\n        \n        # All photos should be in stack1\n        self.assertEqual(stack1.photos.count(), 3)\n        self.assertIn(self.photo1, stack1.photos.all())\n        self.assertIn(self.photo2, stack1.photos.all())\n        self.assertIn(self.photo3, stack1.photos.all())\n        \n        # stack2 should be deleted\n        self.assertFalse(PhotoStack.objects.filter(pk=stack2_id).exists())\n\n    def test_merge_with_self_does_nothing(self):\n        \"\"\"Test merging a stack with itself is a no-op.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo1.stacks.add(stack)\n        \n        stack.merge_with(stack)\n        \n        # Stack should still exist and have same photo\n        self.assertTrue(PhotoStack.objects.filter(pk=stack.pk).exists())\n        self.assertEqual(stack.photos.count(), 1)\n\n    def test_create_or_merge_new_stack(self):\n        \"\"\"Test create_or_merge creates a new stack when no overlap.\"\"\"\n        photos = [self.photo1, self.photo2]\n        \n        stack = PhotoStack.create_or_merge(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            photos=photos,\n        )\n        \n        self.assertIsNotNone(stack)\n        self.assertEqual(stack.photos.count(), 2)\n        self.assertIsNotNone(stack.primary_photo)\n\n    def test_create_or_merge_returns_none_for_single_photo(self):\n        \"\"\"Test create_or_merge returns None when given <2 photos.\"\"\"\n        stack = PhotoStack.create_or_merge(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            photos=[self.photo1],\n        )\n        \n        self.assertIsNone(stack)\n\n    def test_create_or_merge_merges_existing_stacks(self):\n        \"\"\"Test create_or_merge merges when photos already in stack.\"\"\"\n        # Create existing stack with photo1\n        existing_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo1.stacks.add(existing_stack)\n        \n        # Now create_or_merge with photo1 and photo2\n        result = PhotoStack.create_or_merge(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            photos=[self.photo1, self.photo2],\n        )\n        \n        # Should return the existing stack with both photos\n        self.assertEqual(result.pk, existing_stack.pk)\n        self.assertEqual(result.photos.count(), 2)\n        self.assertIn(self.photo2, result.photos.all())\n\n    def test_photo_can_be_in_multiple_stacks_of_different_types(self):\n        \"\"\"Test a photo can be in stacks of different types.\"\"\"\n        raw_jpeg_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.RAW_JPEG_PAIR,\n        )\n        manual_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        \n        self.photo1.stacks.add(raw_jpeg_stack)\n        self.photo1.stacks.add(manual_stack)\n        \n        self.assertEqual(self.photo1.stacks.count(), 2)\n\n\nclass PhotoStackAPITestCase(TestCase):\n    \"\"\"Tests for PhotoStack API endpoints.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.other_user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n        \n        # Create photos for testing\n        self.photo1 = create_test_photo(owner=self.user)\n        self.photo2 = create_test_photo(owner=self.user)\n        self.photo3 = create_test_photo(owner=self.user)\n        \n        # Create a test stack\n        self.stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo1.stacks.add(self.stack)\n        self.photo2.stacks.add(self.stack)\n        self.stack.auto_select_primary()\n\n    def test_list_stacks_returns_user_stacks(self):\n        \"\"\"Test listing stacks returns only user's stacks.\"\"\"\n        response = self.client.get(\"/api/stacks\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertIn(\"results\", data)\n        self.assertIn(\"count\", data)\n        self.assertEqual(data[\"count\"], 1)\n        self.assertEqual(len(data[\"results\"]), 1)\n        self.assertEqual(data[\"results\"][0][\"id\"], str(self.stack.id))\n\n    def test_list_stacks_excludes_other_user_stacks(self):\n        \"\"\"Test listing stacks doesn't include other user's stacks.\"\"\"\n        # Create stack for other user\n        other_photo = create_test_photo(owner=self.other_user)\n        other_photo2 = create_test_photo(owner=self.other_user)\n        other_stack = PhotoStack.objects.create(\n            owner=self.other_user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        other_photo.stacks.add(other_stack)\n        other_photo2.stacks.add(other_stack)\n        \n        response = self.client.get(\"/api/stacks\")\n        \n        data = response.json()\n        self.assertEqual(data[\"count\"], 1)  # Only our stack\n        self.assertNotEqual(data[\"results\"][0][\"id\"], str(other_stack.id))\n\n    def test_list_stacks_excludes_stacks_with_less_than_2_photos(self):\n        \"\"\"Test listing excludes stacks with <2 photos.\"\"\"\n        # Create stack with only 1 photo\n        single_photo_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo3.stacks.add(single_photo_stack)\n        \n        response = self.client.get(\"/api/stacks\")\n        \n        data = response.json()\n        self.assertEqual(data[\"count\"], 1)  # Only the stack with 2 photos\n\n    def test_list_stacks_filter_by_type(self):\n        \"\"\"Test filtering stacks by type.\"\"\"\n        # Create burst stack\n        burst_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        self.photo3.stacks.add(burst_stack)\n        photo4 = create_test_photo(owner=self.user)\n        photo4.stacks.add(burst_stack)\n        \n        response = self.client.get(\"/api/stacks?stack_type=burst\")\n        \n        data = response.json()\n        self.assertEqual(data[\"count\"], 1)\n        self.assertEqual(data[\"results\"][0][\"stack_type\"], \"burst\")\n\n    def test_list_stacks_pagination(self):\n        \"\"\"Test pagination works correctly.\"\"\"\n        response = self.client.get(\"/api/stacks?page=1&page_size=10\")\n        \n        data = response.json()\n        self.assertIn(\"page\", data)\n        self.assertIn(\"page_size\", data)\n        self.assertIn(\"has_next\", data)\n        self.assertIn(\"has_previous\", data)\n        self.assertEqual(data[\"page\"], 1)\n        self.assertEqual(data[\"page_size\"], 10)\n\n    def test_get_stack_detail(self):\n        \"\"\"Test getting stack detail.\"\"\"\n        response = self.client.get(f\"/api/stacks/{self.stack.id}\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"id\"], str(self.stack.id))\n        self.assertEqual(data[\"stack_type\"], \"manual\")\n        self.assertEqual(data[\"photo_count\"], 2)\n        self.assertIn(\"photos\", data)\n        self.assertEqual(len(data[\"photos\"]), 2)\n\n    def test_get_stack_detail_not_found(self):\n        \"\"\"Test getting non-existent stack returns 404.\"\"\"\n        fake_id = uuid.uuid4()\n        response = self.client.get(f\"/api/stacks/{fake_id}\")\n        \n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_get_stack_detail_other_user_returns_404(self):\n        \"\"\"Test getting other user's stack returns 404.\"\"\"\n        other_photo = create_test_photo(owner=self.other_user)\n        other_photo2 = create_test_photo(owner=self.other_user)\n        other_stack = PhotoStack.objects.create(\n            owner=self.other_user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        other_photo.stacks.add(other_stack)\n        other_photo2.stacks.add(other_stack)\n        \n        response = self.client.get(f\"/api/stacks/{other_stack.id}\")\n        \n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_delete_stack(self):\n        \"\"\"Test deleting a stack unlinks photos but doesn't delete them.\"\"\"\n        stack_id = self.stack.id\n        photo1_pk = self.photo1.pk\n        photo2_pk = self.photo2.pk\n        \n        response = self.client.delete(f\"/api/stacks/{stack_id}/delete\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"deleted\")\n        self.assertEqual(data[\"unlinked_count\"], 2)\n        \n        # Stack should be deleted\n        self.assertFalse(PhotoStack.objects.filter(pk=stack_id).exists())\n        \n        # Photos should still exist\n        self.assertTrue(Photo.objects.filter(pk=photo1_pk).exists())\n        self.assertTrue(Photo.objects.filter(pk=photo2_pk).exists())\n\n    def test_delete_stack_not_found(self):\n        \"\"\"Test deleting non-existent stack returns 404.\"\"\"\n        fake_id = uuid.uuid4()\n        response = self.client.delete(f\"/api/stacks/{fake_id}/delete\")\n        \n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_set_primary_photo(self):\n        \"\"\"Test setting a photo as primary.\"\"\"\n        response = self.client.post(\n            f\"/api/stacks/{self.stack.id}/primary\",\n            {\"photo_hash\": self.photo2.image_hash},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"updated\")\n        self.assertEqual(data[\"primary_photo_hash\"], self.photo2.image_hash)\n        \n        # Verify in database\n        self.stack.refresh_from_db()\n        self.assertEqual(self.stack.primary_photo.image_hash, self.photo2.image_hash)\n\n    def test_set_primary_photo_not_in_stack(self):\n        \"\"\"Test setting photo not in stack as primary returns 400.\"\"\"\n        response = self.client.post(\n            f\"/api/stacks/{self.stack.id}/primary\",\n            {\"photo_hash\": self.photo3.image_hash},  # photo3 not in stack\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n        self.assertIn(\"error\", response.json())\n\n    def test_set_primary_missing_photo_hash(self):\n        \"\"\"Test setting primary without photo_hash returns 400.\"\"\"\n        response = self.client.post(\n            f\"/api/stacks/{self.stack.id}/primary\",\n            {},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_add_photos_to_stack(self):\n        \"\"\"Test adding photos to an existing stack.\"\"\"\n        response = self.client.post(\n            f\"/api/stacks/{self.stack.id}/add\",\n            {\"photo_hashes\": [self.photo3.image_hash]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"updated\")\n        self.assertEqual(data[\"added_count\"], 1)\n        self.assertEqual(data[\"total_count\"], 3)\n        \n        # Verify in database\n        self.assertIn(self.photo3, self.stack.photos.all())\n\n    def test_add_photos_already_in_stack(self):\n        \"\"\"Test adding photo already in stack doesn't duplicate.\"\"\"\n        response = self.client.post(\n            f\"/api/stacks/{self.stack.id}/add\",\n            {\"photo_hashes\": [self.photo1.image_hash]},  # Already in stack\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"added_count\"], 0)  # Not added again\n        self.assertEqual(data[\"total_count\"], 2)\n\n    def test_remove_photos_from_stack(self):\n        \"\"\"Test removing photos from a stack.\"\"\"\n        # First add photo3 so we have 3 photos\n        self.photo3.stacks.add(self.stack)\n        \n        response = self.client.post(\n            f\"/api/stacks/{self.stack.id}/remove\",\n            {\"photo_hashes\": [self.photo3.image_hash]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"updated\")\n        self.assertEqual(data[\"removed_count\"], 1)\n        self.assertEqual(data[\"total_count\"], 2)\n\n    def test_remove_photos_deletes_stack_if_less_than_2_remain(self):\n        \"\"\"Test removing photos deletes stack if <2 photos remain.\"\"\"\n        stack_id = self.stack.id\n        \n        response = self.client.post(\n            f\"/api/stacks/{self.stack.id}/remove\",\n            {\"photo_hashes\": [self.photo1.image_hash, self.photo2.image_hash]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"deleted\")\n        \n        # Stack should be deleted\n        self.assertFalse(PhotoStack.objects.filter(pk=stack_id).exists())\n\n    def test_unauthenticated_request_returns_401(self):\n        \"\"\"Test unauthenticated requests return 401.\"\"\"\n        self.client.force_authenticate(user=None)\n        \n        response = self.client.get(\"/api/stacks\")\n        \n        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)\n\n\nclass ManualStackCreationTestCase(TestCase):\n    \"\"\"Tests for manual stack creation.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n        \n        self.photo1 = create_test_photo(owner=self.user)\n        self.photo2 = create_test_photo(owner=self.user)\n        self.photo3 = create_test_photo(owner=self.user)\n\n    def test_create_manual_stack(self):\n        \"\"\"Test creating a manual stack.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [self.photo1.image_hash, self.photo2.image_hash]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_201_CREATED)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"created\")\n        self.assertIn(\"stack_id\", data)\n        self.assertEqual(data[\"photo_count\"], 2)\n        \n        # Verify stack was created\n        stack = PhotoStack.objects.get(pk=data[\"stack_id\"])\n        self.assertEqual(stack.stack_type, PhotoStack.StackType.MANUAL)\n        self.assertEqual(stack.photos.count(), 2)\n\n    def test_create_manual_stack_requires_at_least_2_photos(self):\n        \"\"\"Test creating stack with <2 photos returns error.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [self.photo1.image_hash]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_create_manual_stack_invalid_photo_hash(self):\n        \"\"\"Test creating stack with invalid photo hash returns error.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [self.photo1.image_hash, \"invalid_hash\"]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n        self.assertIn(\"error\", response.json())\n\n    def test_create_manual_stack_other_user_photo(self):\n        \"\"\"Test creating stack with other user's photo returns error.\"\"\"\n        other_user = create_test_user()\n        other_photo = create_test_photo(owner=other_user)\n        \n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [self.photo1.image_hash, other_photo.image_hash]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_create_manual_stack_adds_to_existing_if_already_stacked(self):\n        \"\"\"Test creating stack adds to existing stack if photo already in manual stack.\"\"\"\n        # Create existing manual stack\n        existing_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo1.stacks.add(existing_stack)\n        self.photo2.stacks.add(existing_stack)\n        \n        # Try to create new stack with photo1 and photo3\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [self.photo1.image_hash, self.photo3.image_hash]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_201_CREATED)\n        data = response.json()\n        self.assertEqual(data[\"stack_id\"], str(existing_stack.id))\n        \n        # Verify photo3 was added to existing stack\n        self.assertIn(self.photo3, existing_stack.photos.all())\n\n\nclass MergeStacksTestCase(TestCase):\n    \"\"\"Tests for stack merging.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n        \n        self.photo1 = create_test_photo(owner=self.user)\n        self.photo2 = create_test_photo(owner=self.user)\n        self.photo3 = create_test_photo(owner=self.user)\n        self.photo4 = create_test_photo(owner=self.user)\n\n    def test_merge_stacks(self):\n        \"\"\"Test merging multiple stacks.\"\"\"\n        # Create two separate stacks\n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo1.stacks.add(stack1)\n        self.photo2.stacks.add(stack1)\n        \n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo3.stacks.add(stack2)\n        self.photo4.stacks.add(stack2)\n        \n        stack1_id = stack1.id\n        stack2_id = stack2.id\n        \n        # Merge stacks using photos from both\n        response = self.client.post(\n            \"/api/stacks/merge\",\n            {\"photo_hashes\": [self.photo1.image_hash, self.photo3.image_hash]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        \n        self.assertEqual(data[\"status\"], \"merged\")\n        self.assertEqual(data[\"merged_count\"], 1)\n        self.assertEqual(data[\"photo_count\"], 4)\n        \n        # The non-target stack should be deleted\n        # The target stack is the one returned in the response\n        returned_stack_id = data.get(\"stack_id\")\n        if str(stack1_id) == returned_stack_id:\n            # stack1 was target, stack2 should be deleted\n            self.assertFalse(\n                PhotoStack.objects.filter(pk=stack2_id).exists(),\n                \"stack2 should be deleted but still exists\"\n            )\n        else:\n            # stack2 was target, stack1 should be deleted\n            self.assertFalse(\n                PhotoStack.objects.filter(pk=stack1_id).exists(),\n                \"stack1 should be deleted but still exists\"\n            )\n\n    def test_merge_single_stack_no_merge_needed(self):\n        \"\"\"Test merging when only one stack found returns no_merge_needed.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo1.stacks.add(stack)\n        self.photo2.stacks.add(stack)\n        \n        response = self.client.post(\n            \"/api/stacks/merge\",\n            {\"photo_hashes\": [self.photo1.image_hash, self.photo2.image_hash]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"no_merge_needed\")\n\n    def test_merge_no_stacks_returns_error(self):\n        \"\"\"Test merging photos not in any stack returns error.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/merge\",\n            {\"photo_hashes\": [self.photo1.image_hash, self.photo2.image_hash]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_merge_missing_photo_hashes(self):\n        \"\"\"Test merge without photo_hashes returns error.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/merge\",\n            {},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n\nclass StackDetectionTestCase(TestCase):\n    \"\"\"Tests for stack detection trigger.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n\n    @patch(\"api.views.stacks.async_task\")\n    def test_detect_stacks_queues_background_job(self, mock_async_task):\n        \"\"\"Test detect stacks queues a background job.\"\"\"\n        response = self.client.post(\"/api/stacks/detect\")\n        \n        self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)\n        data = response.json()\n        self.assertEqual(data[\"status\"], \"queued\")\n        self.assertIn(\"options\", data)\n        \n        # Verify async_task was called\n        mock_async_task.assert_called_once()\n\n    @patch(\"api.views.stacks.async_task\")\n    def test_detect_stacks_with_options(self, mock_async_task):\n        \"\"\"Test detect stacks with custom options.\n        \n        NOTE: detect_raw_jpeg and detect_live_photos options were removed.\n        RAW+JPEG and Live Photos are now handled via file variants during scan.\n        Only detect_bursts is available.\n        \"\"\"\n        response = self.client.post(\n            \"/api/stacks/detect\",\n            {\n                \"detect_bursts\": False,\n            },\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)\n        data = response.json()\n        self.assertEqual(data[\"options\"][\"detect_bursts\"], False)\n\n\nclass StackStatsTestCase(TestCase):\n    \"\"\"Tests for stack statistics.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n        \n        self.photo1 = create_test_photo(owner=self.user)\n        self.photo2 = create_test_photo(owner=self.user)\n        self.photo3 = create_test_photo(owner=self.user)\n        self.photo4 = create_test_photo(owner=self.user)\n\n    def test_get_stack_stats(self):\n        \"\"\"Test getting stack statistics.\"\"\"\n        # Create some stacks\n        manual_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo1.stacks.add(manual_stack)\n        self.photo2.stacks.add(manual_stack)\n        \n        burst_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        self.photo3.stacks.add(burst_stack)\n        self.photo4.stacks.add(burst_stack)\n        \n        response = self.client.get(\"/api/stacks/stats\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"total_stacks\"], 2)\n        self.assertIn(\"by_type\", data)\n        self.assertEqual(data[\"by_type\"][\"manual\"], 1)\n        self.assertEqual(data[\"by_type\"][\"burst\"], 1)\n        self.assertEqual(data[\"photos_in_stacks\"], 4)\n\n\nclass StackEdgeCasesTestCase(TestCase):\n    \"\"\"Edge case tests for stacks.\"\"\"\n\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n        \n        self.photo1 = create_test_photo(owner=self.user)\n        self.photo2 = create_test_photo(owner=self.user)\n\n    def test_invalid_uuid_format_in_url(self):\n        \"\"\"Test invalid UUID format in URL is handled gracefully.\"\"\"\n        response = self.client.get(\"/api/stacks/not-a-valid-uuid\")\n        \n        # The URL pattern [0-9a-f-]+ is permissive and partial matches fall through\n        # to the list endpoint which returns 200. This is acceptable behavior -\n        # the important thing is we don't get a 500 error.\n        self.assertIn(response.status_code, [\n            status.HTTP_200_OK,  # Falls through to list endpoint\n            status.HTTP_400_BAD_REQUEST,\n            status.HTTP_404_NOT_FOUND\n        ])\n\n    def test_stack_with_deleted_photo_handles_gracefully(self):\n        \"\"\"Test stack behavior when a photo is deleted.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo1.stacks.add(stack)\n        self.photo2.stacks.add(stack)\n        stack.primary_photo = self.photo1\n        stack.save()\n        \n        # Delete photo1\n        self.photo1.delete()\n        \n        # Stack detail should still work\n        response = self.client.get(f\"/api/stacks/{stack.id}\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        # primary_photo should be None or the remaining photo\n        self.assertEqual(len(data[\"photos\"]), 1)\n\n    def test_empty_photo_hashes_array(self):\n        \"\"\"Test empty photo_hashes array returns error.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": []},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_duplicate_photo_hashes_in_request(self):\n        \"\"\"Test duplicate photo hashes in request are handled.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [self.photo1.image_hash, self.photo1.image_hash]},\n            format=\"json\",\n        )\n        \n        # Should fail since we need 2 unique photos\n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_remove_primary_photo_auto_selects_new_primary(self):\n        \"\"\"Test removing primary photo auto-selects a new primary.\"\"\"\n        photo3 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photo1.stacks.add(stack)\n        self.photo2.stacks.add(stack)\n        photo3.stacks.add(stack)\n        stack.primary_photo = self.photo1\n        stack.save()\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/remove\",\n            {\"photo_hashes\": [self.photo1.image_hash]},\n            format=\"json\",\n        )\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        \n        # Refresh and check primary was auto-selected\n        stack.refresh_from_db()\n        self.assertIsNotNone(stack.primary_photo)\n        self.assertNotEqual(stack.primary_photo.image_hash, self.photo1.image_hash)\n\n    def test_stack_list_page_size_max_100(self):\n        \"\"\"Test page_size is capped at 100.\"\"\"\n        response = self.client.get(\"/api/stacks?page_size=500\")\n        \n        self.assertEqual(response.status_code, status.HTTP_200_OK)\n        data = response.json()\n        self.assertEqual(data[\"page_size\"], 100)  # Capped at 100\n"
  },
  {
    "path": "api/tests/test_stack_detection_edge_cases.py",
    "content": "\"\"\"\nEdge case tests for Stack Detection to find bugs.\n\nNOTE: RAW+JPEG pairs and Live Photos are now handled as file variants\nduring scan (Photo.files ManyToMany field), not as stacks.\n\nThese tests specifically target:\n1. create_or_merge queryset ordering issues (potential Bug #11)\n2. Edge cases in hard criteria burst detection\n3. Edge cases in soft criteria burst detection\n4. File variant handling edge cases\n5. Memory/performance edge cases\n\"\"\"\n\nimport json\nimport uuid\nfrom datetime import datetime, timedelta\nfrom unittest.mock import patch\n\nfrom django.test import TestCase\n\nfrom api.models.file import File\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.duplicate import Duplicate\nfrom api.models.long_running_job import LongRunningJob\nfrom api.stack_detection import (\n    clear_stacks_of_type,\n    detect_burst_sequences,\n    batch_detect_stacks,\n    _create_burst_stack,\n)\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass CreateOrMergeQuerysetOrderingTestCase(TestCase):\n    \"\"\"\n    Test for Bug #11: create_or_merge uses two separate queries without ordering.\n\n    The code does:\n        existing_stacks = cls.objects.filter(...).distinct()\n        target_stack = existing_stacks.first()      # Query 1\n        for stack in existing_stacks[1:]:           # Query 2\n\n    Without explicit ordering, these two queries could return stacks in different\n    orders, causing the merge to not work correctly.\n    \"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_create_or_merge_with_multiple_existing_stacks(self):\n        \"\"\"Test that create_or_merge properly merges when photos are in multiple stacks.\"\"\"\n        # Create 4 photos\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        photo4 = create_test_photo(owner=self.user)\n\n        # Create two separate stacks\n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack1.photos.add(photo1, photo2)\n\n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack2.photos.add(photo3, photo4)\n\n        # Now try to create a stack with photos from both existing stacks\n        # This should trigger the merge logic\n        result_stack = PhotoStack.create_or_merge(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            photos=[photo1, photo3],  # One from each stack\n        )\n\n        # Should have merged into one stack\n        self.assertIsNotNone(result_stack)\n\n        # Count remaining stacks of this type\n        remaining_stacks = PhotoStack.objects.filter(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n\n        # Should only have 1 stack after merge\n        self.assertEqual(\n            remaining_stacks.count(),\n            1,\n            \"Multiple existing stacks should be merged into one\",\n        )\n\n        # The merged stack should contain all 4 photos\n        merged_stack = remaining_stacks.first()\n        self.assertEqual(\n            merged_stack.photos.count(),\n            4,\n            \"Merged stack should contain all photos from both original stacks\",\n        )\n\n    def test_create_or_merge_with_three_existing_stacks(self):\n        \"\"\"Test merging with 3 existing stacks - tests iterative merge.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(6)]\n\n        # Create three separate stacks with 2 photos each\n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack1.photos.add(photos[0], photos[1])\n\n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack2.photos.add(photos[2], photos[3])\n\n        stack3 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack3.photos.add(photos[4], photos[5])\n\n        # Create stack with one photo from each existing stack\n        _result_stack = PhotoStack.create_or_merge(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            photos=[photos[0], photos[2], photos[4]],\n        )\n\n        # Should merge all into one\n        remaining_stacks = PhotoStack.objects.filter(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n\n        self.assertEqual(\n            remaining_stacks.count(), 1, \"All 3 stacks should be merged into one\"\n        )\n        self.assertEqual(\n            remaining_stacks.first().photos.count(),\n            6,\n            \"Merged stack should have all 6 photos\",\n        )\n\n\nclass DuplicateCreateOrMergeOrderingTestCase(TestCase):\n    \"\"\"Test the same ordering issue in Duplicate.create_or_merge.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_duplicate_create_or_merge_with_multiple_groups(self):\n        \"\"\"Test that Duplicate.create_or_merge properly merges multiple groups.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(4)]\n\n        # Create two duplicate groups\n        dup1 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup1.photos.add(photos[0], photos[1])\n\n        dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup2.photos.add(photos[2], photos[3])\n\n        # Merge by adding photos from both groups\n        _result = Duplicate.create_or_merge(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            photos=[photos[0], photos[2]],\n        )\n\n        remaining = Duplicate.objects.filter(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n\n        self.assertEqual(\n            remaining.count(), 1, \"Multiple duplicate groups should be merged\"\n        )\n        self.assertEqual(\n            remaining.first().photos.count(), 4, \"Merged group should have all photos\"\n        )\n\n\nclass HardCriteriaBurstDetectionEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for hard criteria burst detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        # Set up hard rule that uses EXIF burst mode\n        self.user.burst_detection_rules = json.dumps(\n            [\n                {\n                    \"id\": 1,\n                    \"name\": \"EXIF Burst Mode\",\n                    \"rule_type\": \"exif_burst_mode\",\n                    \"category\": \"hard\",\n                    \"enabled\": True,\n                }\n            ]\n        )\n        self.user.save()\n\n    def _create_file(self, path, file_type=File.IMAGE):\n        \"\"\"Helper to create a File object.\"\"\"\n        return File.objects.create(\n            hash=str(uuid.uuid4())[:32],\n            path=path,\n            type=file_type,\n        )\n\n    def _create_photo_with_file(self, path, **kwargs):\n        \"\"\"Helper to create Photo with associated File.\"\"\"\n        file = self._create_file(path, File.IMAGE)\n        photo = create_test_photo(owner=self.user, **kwargs)\n        photo.main_file = file\n        photo.save()\n        return photo\n\n    def test_all_photos_without_main_file(self):\n        \"\"\"Test detection when all photos have no main_file.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo1.main_file = None\n        photo2.main_file = None\n        photo1.save()\n        photo2.save()\n\n        # Should handle gracefully\n        count = detect_burst_sequences(self.user)\n        self.assertEqual(count, 0)\n\n    @patch(\"api.metadata.reader.get_metadata\")\n    def test_get_metadata_raises_exception_for_all(self, mock_get_metadata):\n        \"\"\"Test when get_metadata raises exceptions for every photo.\"\"\"\n        _photo1 = self._create_photo_with_file(\"/photos/IMG_001.jpg\")\n        _photo2 = self._create_photo_with_file(\"/photos/IMG_002.jpg\")\n\n        mock_get_metadata.side_effect = Exception(\"EXIF read failed\")\n\n        # Should handle gracefully without crashing\n        count = detect_burst_sequences(self.user)\n        self.assertEqual(count, 0)\n\n    @patch(\"api.metadata.reader.get_metadata\")\n    def test_get_metadata_returns_empty_for_some(self, mock_get_metadata):\n        \"\"\"Test when get_metadata returns empty for some photos.\"\"\"\n        _photo1 = self._create_photo_with_file(\"/photos/IMG_001.jpg\")\n        _photo2 = self._create_photo_with_file(\"/photos/IMG_002.jpg\")\n\n        # Return empty values\n        mock_get_metadata.return_value = [None, None]\n\n        count = detect_burst_sequences(self.user)\n        # No bursts found because EXIF data is empty\n        self.assertEqual(count, 0)\n\n\nclass SoftCriteriaBurstDetectionEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for soft criteria burst detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def _create_file(self, path, file_type=File.IMAGE):\n        \"\"\"Helper to create a File object.\"\"\"\n        return File.objects.create(\n            hash=str(uuid.uuid4())[:32],\n            path=path,\n            type=file_type,\n        )\n\n    def _create_photo_with_timestamp(self, timestamp, perceptual_hash=None, **kwargs):\n        \"\"\"Helper to create Photo with specific timestamp and optional hash.\"\"\"\n        photo = create_test_photo(owner=self.user, **kwargs)\n        photo.exif_timestamp = timestamp\n        if perceptual_hash:\n            photo.perceptual_hash = perceptual_hash\n        file = self._create_file(f\"/photos/IMG_{photo.pk}.jpg\")\n        photo.main_file = file\n        photo.save()\n        return photo\n\n    def test_visual_similarity_with_null_perceptual_hash(self):\n        \"\"\"Test visual similarity detection when photos have no perceptual hash.\"\"\"\n        self.user.burst_detection_rules = json.dumps(\n            [\n                {\n                    \"id\": 1,\n                    \"name\": \"Visual Similarity\",\n                    \"rule_type\": \"visual_similarity\",\n                    \"category\": \"soft\",\n                    \"enabled\": True,\n                    \"similarity_threshold\": 15,\n                }\n            ]\n        )\n        self.user.save()\n\n        base_time = datetime(2024, 1, 1, 12, 0, 0)\n        # Create photos with no perceptual hash\n        _photo1 = self._create_photo_with_timestamp(base_time, perceptual_hash=None)\n        _photo2 = self._create_photo_with_timestamp(\n            base_time + timedelta(seconds=1), perceptual_hash=None\n        )\n\n        # Should handle gracefully\n        count = detect_burst_sequences(self.user)\n        self.assertEqual(count, 0)\n\n    def test_timestamp_proximity_with_same_timestamp(self):\n        \"\"\"Test timestamp proximity when multiple photos have exact same timestamp.\"\"\"\n        self.user.burst_detection_rules = json.dumps(\n            [\n                {\n                    \"id\": 1,\n                    \"name\": \"Timestamp Proximity\",\n                    \"rule_type\": \"timestamp_proximity\",\n                    \"category\": \"soft\",\n                    \"enabled\": True,\n                    \"interval_ms\": 2000,\n                }\n            ]\n        )\n        self.user.save()\n\n        exact_time = datetime(2024, 1, 1, 12, 0, 0)\n        # Create 5 photos with exact same timestamp\n        photos = []\n        for i in range(5):\n            photos.append(self._create_photo_with_timestamp(exact_time))\n\n        count = detect_burst_sequences(self.user)\n\n        # Should create one burst stack with all 5 photos\n        self.assertEqual(count, 1)\n\n        stacks = PhotoStack.objects.filter(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        self.assertEqual(stacks.count(), 1)\n        self.assertEqual(stacks.first().photos.count(), 5)\n\n    def test_timestamp_proximity_boundary_condition(self):\n        \"\"\"Test timestamp proximity at exact boundary (2000ms).\"\"\"\n        self.user.burst_detection_rules = json.dumps(\n            [\n                {\n                    \"id\": 1,\n                    \"name\": \"Timestamp Proximity\",\n                    \"rule_type\": \"timestamp_proximity\",\n                    \"category\": \"soft\",\n                    \"enabled\": True,\n                    \"interval_ms\": 2000,\n                    \"require_same_camera\": False,\n                }\n            ]\n        )\n        self.user.save()\n\n        base_time = datetime(2024, 1, 1, 12, 0, 0)\n        # Photo 2 is exactly 2000ms after photo 1\n        _photo1 = self._create_photo_with_timestamp(base_time)\n        _photo2 = self._create_photo_with_timestamp(\n            base_time + timedelta(milliseconds=2000)\n        )\n        # Photo 3 is 2001ms after photo 1 (just outside boundary)\n        _photo3 = self._create_photo_with_timestamp(\n            base_time + timedelta(milliseconds=2001)\n        )\n\n        count = detect_burst_sequences(self.user)\n\n        # Depending on implementation, photo1+photo2 might be grouped, photo3 separate\n        # This tests the boundary condition behavior\n        _stacks = PhotoStack.objects.filter(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        # At minimum, should not crash\n        self.assertGreaterEqual(count, 0)\n\n    def test_all_photos_already_in_burst_stacks(self):\n        \"\"\"Test soft criteria when all photos are already in burst stacks.\"\"\"\n        self.user.burst_detection_rules = json.dumps(\n            [\n                {\n                    \"id\": 1,\n                    \"name\": \"Timestamp Proximity\",\n                    \"rule_type\": \"timestamp_proximity\",\n                    \"category\": \"soft\",\n                    \"enabled\": True,\n                    \"interval_ms\": 5000,\n                }\n            ]\n        )\n        self.user.save()\n\n        base_time = datetime(2024, 1, 1, 12, 0, 0)\n        photo1 = self._create_photo_with_timestamp(base_time)\n        photo2 = self._create_photo_with_timestamp(base_time + timedelta(seconds=1))\n\n        # Pre-create a burst stack with both photos\n        existing_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        existing_stack.photos.add(photo1, photo2)\n\n        # Run detection - burst stacks are cleared at start, so this will re-detect\n        count = detect_burst_sequences(self.user)\n\n        # Should re-detect the burst\n        self.assertEqual(count, 1)\n\n\nclass FileVariantEdgeCasesTestCase(TestCase):\n    \"\"\"\n    Edge cases for file variant handling.\n\n    File variants (RAW+JPEG, Live Photos) are now stored via Photo.files\n    ManyToMany field, not as stacks.\n    \"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def _create_file(self, path, file_type=File.IMAGE):\n        \"\"\"Helper to create a File object.\"\"\"\n        return File.objects.create(\n            hash=str(uuid.uuid4())[:32],\n            path=path,\n            type=file_type,\n        )\n\n    def test_multiple_raw_files_same_photo(self):\n        \"\"\"Test when multiple RAW files are attached to same photo.\"\"\"\n        photo = create_test_photo(owner=self.user)\n\n        # Add JPEG as main file\n        jpeg_file = self._create_file(\"/photos/IMG_001.jpg\", File.IMAGE)\n        photo.main_file = jpeg_file\n        photo.files.add(jpeg_file)\n\n        # Add multiple RAW files (e.g., camera shot both CR2 and NEF)\n        raw_cr2 = self._create_file(\"/photos/IMG_001.CR2\", File.RAW_FILE)\n        raw_nef = self._create_file(\"/photos/IMG_001.NEF\", File.RAW_FILE)\n        photo.files.add(raw_cr2, raw_nef)\n        photo.save()\n\n        # Photo should have 3 file variants\n        self.assertEqual(photo.files.count(), 3)\n        self.assertEqual(photo.files.filter(type=File.RAW_FILE).count(), 2)\n\n    def test_video_and_raw_same_photo(self):\n        \"\"\"Test Live Photo with RAW (image + video + RAW).\"\"\"\n        photo = create_test_photo(owner=self.user)\n\n        # Add HEIC as main file\n        heic_file = self._create_file(\"/photos/IMG_001.heic\", File.IMAGE)\n        photo.main_file = heic_file\n        photo.files.add(heic_file)\n\n        # Add RAW\n        raw_file = self._create_file(\"/photos/IMG_001.DNG\", File.RAW_FILE)\n        photo.files.add(raw_file)\n\n        # Add video for Live Photo\n        video_file = self._create_file(\"/photos/IMG_001.mov\", File.VIDEO)\n        photo.files.add(video_file)\n        photo.save()\n\n        # Photo should have all three\n        self.assertEqual(photo.files.count(), 3)\n        self.assertTrue(photo.files.filter(type=File.IMAGE).exists())\n        self.assertTrue(photo.files.filter(type=File.RAW_FILE).exists())\n        self.assertTrue(photo.files.filter(type=File.VIDEO).exists())\n\n    def test_dot_in_basename(self):\n        \"\"\"Test files with dots in basename.\"\"\"\n        photo = create_test_photo(owner=self.user)\n\n        # File with dots in name\n        jpeg_file = self._create_file(\"/photos/IMG.2024.01.01.001.jpg\", File.IMAGE)\n        photo.main_file = jpeg_file\n        photo.files.add(jpeg_file)\n\n        raw_file = self._create_file(\"/photos/IMG.2024.01.01.001.CR2\", File.RAW_FILE)\n        photo.files.add(raw_file)\n        photo.save()\n\n        self.assertEqual(photo.files.count(), 2)\n\n    def test_very_long_filename(self):\n        \"\"\"Test files with very long filenames.\"\"\"\n        photo = create_test_photo(owner=self.user)\n\n        long_name = \"IMG_\" + \"a\" * 200\n        jpeg_file = self._create_file(f\"/photos/{long_name}.jpg\", File.IMAGE)\n        photo.main_file = jpeg_file\n        photo.files.add(jpeg_file)\n\n        raw_file = self._create_file(f\"/photos/{long_name}.CR2\", File.RAW_FILE)\n        photo.files.add(raw_file)\n        photo.save()\n\n        self.assertEqual(photo.files.count(), 2)\n\n    def test_whitespace_in_path(self):\n        \"\"\"Test files with whitespace in path.\"\"\"\n        photo = create_test_photo(owner=self.user)\n\n        jpeg_file = self._create_file(\"/photos/My Photos/IMG 001.jpg\", File.IMAGE)\n        photo.main_file = jpeg_file\n        photo.files.add(jpeg_file)\n\n        raw_file = self._create_file(\"/photos/My Photos/IMG 001.CR2\", File.RAW_FILE)\n        photo.files.add(raw_file)\n        photo.save()\n\n        self.assertEqual(photo.files.count(), 2)\n\n    def test_deeply_nested_path(self):\n        \"\"\"Test files in deeply nested directories.\"\"\"\n        photo = create_test_photo(owner=self.user)\n\n        deep_path = \"/photos\" + \"/subdir\" * 50\n        jpeg_file = self._create_file(f\"{deep_path}/IMG_001.jpg\", File.IMAGE)\n        photo.main_file = jpeg_file\n        photo.files.add(jpeg_file)\n\n        raw_file = self._create_file(f\"{deep_path}/IMG_001.CR2\", File.RAW_FILE)\n        photo.files.add(raw_file)\n        photo.save()\n\n        self.assertEqual(photo.files.count(), 2)\n\n\nclass MalformedRulesEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for malformed burst detection rules.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_invalid_json_string_rules(self):\n        \"\"\"Test detection with invalid JSON in burst_detection_rules.\"\"\"\n        self.user.burst_detection_rules = \"not valid json {\"\n        self.user.save()\n\n        # Should handle gracefully (json.loads will fail)\n        with self.assertRaises(json.JSONDecodeError):\n            detect_burst_sequences(self.user)\n\n    def test_rules_as_dict_instead_of_list(self):\n        \"\"\"Test detection when rules are stored as dict instead of list.\"\"\"\n        self.user.burst_detection_rules = json.dumps({\"rule1\": {\"enabled\": True}})\n        self.user.save()\n\n        # as_rules expects a list, dict should cause issues\n        # This tests if the code handles this gracefully\n        try:\n            count = detect_burst_sequences(self.user)\n            # If it doesn't crash, it should return 0 (no valid rules)\n            self.assertEqual(count, 0)\n        except (TypeError, AttributeError):\n            # This is expected if the code doesn't handle dict input\n            pass\n\n    def test_rules_with_missing_required_fields(self):\n        \"\"\"Test detection with rules missing required fields.\"\"\"\n        self.user.burst_detection_rules = json.dumps(\n            [\n                {\n                    \"id\": 1,\n                    # Missing: name, rule_type, category, enabled\n                }\n            ]\n        )\n        self.user.save()\n\n        # Should handle gracefully\n        try:\n            count = detect_burst_sequences(self.user)\n            self.assertEqual(count, 0)\n        except (KeyError, AttributeError):\n            # Expected if code doesn't validate input\n            pass\n\n    def test_rules_with_unknown_rule_type(self):\n        \"\"\"Test detection with unknown rule_type.\"\"\"\n        self.user.burst_detection_rules = json.dumps(\n            [\n                {\n                    \"id\": 1,\n                    \"name\": \"Unknown Rule\",\n                    \"rule_type\": \"unknown_nonexistent_type\",\n                    \"category\": \"hard\",\n                    \"enabled\": True,\n                }\n            ]\n        )\n        self.user.save()\n\n        # Should handle gracefully - unknown rule types should be skipped\n        count = detect_burst_sequences(self.user)\n        self.assertEqual(count, 0)\n\n\nclass BatchDetectionEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for batch_detect_stacks.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.user.burst_detection_rules = json.dumps([])\n        self.user.save()\n\n    def test_partial_options(self):\n        \"\"\"Test batch detection with partial options dict.\"\"\"\n        # Only specify some options\n        options = {\"detect_bursts\": False}\n\n        with patch(\"api.stack_detection.detect_burst_sequences\") as mock_burst:\n            mock_burst.return_value = 0\n\n            batch_detect_stacks(self.user, options=options)\n\n            # Burst should not be called (explicitly disabled)\n            mock_burst.assert_not_called()\n\n    def test_empty_options_dict(self):\n        \"\"\"Test batch detection with empty options dict.\"\"\"\n        with patch(\"api.stack_detection.detect_burst_sequences\") as mock_burst:\n            mock_burst.return_value = 0\n\n            batch_detect_stacks(self.user, options={})\n\n            # Burst should be called with defaults (True)\n            mock_burst.assert_called_once()\n\n    @patch(\"api.stack_detection.detect_burst_sequences\")\n    def test_exception_in_detector(self, mock_burst):\n        \"\"\"Test that exception in detector fails job properly.\"\"\"\n        mock_burst.side_effect = Exception(\"Burst detection crashed\")\n\n        with self.assertRaises(Exception):\n            batch_detect_stacks(self.user)\n\n        # Job should exist and be marked as failed\n        jobs = LongRunningJob.objects.filter(started_by=self.user)\n        self.assertEqual(jobs.count(), 1)\n\n\nclass ClearStacksEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for clear_stacks_of_type.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_clear_with_photo_in_multiple_stacks(self):\n        \"\"\"Test clearing when a photo is in multiple stacks of different types.\"\"\"\n        photo = create_test_photo(owner=self.user)\n\n        # Add photo to burst stack\n        burst_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        burst_stack.photos.add(photo)\n\n        # Add same photo to manual stack\n        manual_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        manual_stack.photos.add(photo)\n\n        # Clear only burst stacks\n        clear_stacks_of_type(self.user, PhotoStack.StackType.BURST_SEQUENCE)\n\n        # Photo should still be in manual stack\n        photo.refresh_from_db()\n        self.assertEqual(photo.stacks.count(), 1)\n        self.assertEqual(photo.stacks.first().stack_type, PhotoStack.StackType.MANUAL)\n\n    def test_clear_with_many_stacks(self):\n        \"\"\"Test clearing a large number of stacks.\"\"\"\n        # Create 100 stacks\n        for i in range(100):\n            stack = PhotoStack.objects.create(\n                owner=self.user,\n                stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            )\n            photo1 = create_test_photo(owner=self.user)\n            photo2 = create_test_photo(owner=self.user)\n            stack.photos.add(photo1, photo2)\n\n        count = clear_stacks_of_type(self.user, PhotoStack.StackType.BURST_SEQUENCE)\n\n        self.assertEqual(count, 100)\n        self.assertEqual(\n            PhotoStack.objects.filter(\n                owner=self.user,\n                stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            ).count(),\n            0,\n        )\n\n\nclass SequenceTimestampEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for sequence_start/sequence_end handling in burst stacks.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_burst_stack_with_none_timestamps(self):\n        \"\"\"Test burst stack creation when photos have None exif_timestamp.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo1.exif_timestamp = None\n        photo2.exif_timestamp = None\n        photo1.save()\n        photo2.save()\n\n        # Create burst stack with None timestamps\n        stack = _create_burst_stack(self.user, [photo1, photo2])\n\n        # Should still create stack, sequence timestamps will be None\n        self.assertIsNotNone(stack)\n        self.assertIsNone(stack.sequence_start)\n        self.assertIsNone(stack.sequence_end)\n\n    def test_create_or_merge_updates_sequence_timestamps(self):\n        \"\"\"Test that create_or_merge properly extends sequence timestamps.\"\"\"\n        from django.utils import timezone\n\n        base_time = timezone.now()\n\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo1.exif_timestamp = base_time\n        photo2.exif_timestamp = base_time + timedelta(seconds=1)\n        photo1.save()\n        photo2.save()\n\n        # Create initial stack\n        stack = PhotoStack.create_or_merge(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            photos=[photo1, photo2],\n            sequence_start=base_time,\n            sequence_end=base_time + timedelta(seconds=1),\n        )\n\n        # Create third photo with earlier timestamp\n        photo3 = create_test_photo(owner=self.user)\n        photo3.exif_timestamp = base_time - timedelta(seconds=1)\n        photo3.save()\n\n        # Merge with earlier timestamp\n        stack = PhotoStack.create_or_merge(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            photos=[photo1, photo3],  # photo1 already in stack\n            sequence_start=base_time - timedelta(seconds=1),\n            sequence_end=base_time,\n        )\n\n        stack.refresh_from_db()\n        # sequence_start should be updated to earlier time\n        self.assertEqual(stack.sequence_start, base_time - timedelta(seconds=1))\n\n\nclass FilenamePatternEdgeCasesTestCase(TestCase):\n    \"\"\"Edge cases for filename pattern matching in detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.user.burst_detection_rules = json.dumps(\n            [\n                {\n                    \"id\": 1,\n                    \"name\": \"Filename Pattern\",\n                    \"rule_type\": \"filename_pattern\",\n                    \"category\": \"hard\",\n                    \"enabled\": True,\n                    \"pattern\": r\"^IMG_(\\d+)_BURST(\\d+)\\.jpg$\",\n                    \"group_by\": \"burst_id\",\n                }\n            ]\n        )\n        self.user.save()\n\n    def _create_file(self, path, file_type=File.IMAGE):\n        \"\"\"Helper to create a File object.\"\"\"\n        return File.objects.create(\n            hash=str(uuid.uuid4())[:32],\n            path=path,\n            type=file_type,\n        )\n\n    def _create_photo_with_file(self, path, **kwargs):\n        \"\"\"Helper to create Photo with associated File.\"\"\"\n        file = self._create_file(path, File.IMAGE)\n        photo = create_test_photo(owner=self.user, **kwargs)\n        photo.main_file = file\n        photo.save()\n        return photo\n\n    def test_regex_pattern_special_chars(self):\n        \"\"\"Test filename pattern with special regex characters.\"\"\"\n        self.user.burst_detection_rules = json.dumps(\n            [\n                {\n                    \"id\": 1,\n                    \"name\": \"Pattern with brackets\",\n                    \"rule_type\": \"filename_pattern\",\n                    \"category\": \"hard\",\n                    \"enabled\": True,\n                    \"pattern\": r\"^IMG_\\[(\\d+)\\]\\.jpg$\",\n                    \"group_by\": \"photo_id\",\n                }\n            ]\n        )\n        self.user.save()\n\n        # Create two photos with different paths that both match the bracket pattern\n        _photo1 = self._create_photo_with_file(\"/photos/IMG_[001].jpg\")\n        _photo2 = self._create_photo_with_file(\n            \"/photos/IMG_[002].jpg\"\n        )  # Different path, same pattern\n\n        # Should not crash on regex special characters\n        count = detect_burst_sequences(self.user)\n        self.assertGreaterEqual(count, 0)\n\n    def test_empty_filename(self):\n        \"\"\"Test handling of empty filename (edge case).\"\"\"\n        # Create photo with empty filename component\n        file = File.objects.create(\n            hash=str(uuid.uuid4())[:32],\n            path=\"/photos/\",  # Just directory, no filename\n            type=File.IMAGE,\n        )\n        photo = create_test_photo(owner=self.user)\n        photo.main_file = file\n        photo.save()\n\n        # Should handle gracefully\n        count = detect_burst_sequences(self.user)\n        self.assertEqual(count, 0)\n"
  },
  {
    "path": "api/tests/test_stack_detection_logic.py",
    "content": "\"\"\"\nComprehensive tests for Stack Detection Logic.\n\nNOTE: RAW+JPEG pairs and Live Photos are now handled as file variants\nduring scan (Photo.files ManyToMany field), not as stacks.\nSee test_file_variants.py for file variant tests.\n\nTests cover:\n- clear_stacks_of_type: Clearing stacks before re-detection\n- detect_burst_sequences: Burst sequence detection with rules\n- batch_detect_stacks: Batch detection orchestration\n- Edge cases and error handling\n\"\"\"\n\nimport json\nimport uuid\nfrom datetime import datetime, timedelta\nfrom unittest.mock import patch\n\nfrom django.test import TestCase\n\nfrom api.models.file import File\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.long_running_job import LongRunningJob\nfrom api.stack_detection import (\n    clear_stacks_of_type,\n    detect_burst_sequences,\n    batch_detect_stacks,\n    _create_burst_stack,\n)\nfrom api.directory_watcher import JPEG_EXTENSIONS\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass ClearStacksTestCase(TestCase):\n    \"\"\"Tests for clear_stacks_of_type function.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.other_user = create_test_user()\n\n    def test_clear_stacks_removes_all_of_type(self):\n        \"\"\"Test clearing removes all stacks of specific type.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        photo4 = create_test_photo(owner=self.user)\n        \n        # Create stacks of different types\n        burst_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        burst_stack.photos.add(photo1, photo2)\n        \n        manual_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        manual_stack.photos.add(photo3, photo4)\n        \n        # Clear only burst stacks\n        count = clear_stacks_of_type(self.user, PhotoStack.StackType.BURST_SEQUENCE)\n        \n        self.assertEqual(count, 1)\n        self.assertFalse(PhotoStack.objects.filter(\n            owner=self.user, stack_type=PhotoStack.StackType.BURST_SEQUENCE\n        ).exists())\n        # Manual stack should still exist\n        self.assertTrue(PhotoStack.objects.filter(\n            owner=self.user, stack_type=PhotoStack.StackType.MANUAL\n        ).exists())\n\n    def test_clear_stacks_unlinks_photos(self):\n        \"\"\"Test clearing unlinks photos from stacks.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        self.assertEqual(photo1.stacks.count(), 1)\n        \n        clear_stacks_of_type(self.user, PhotoStack.StackType.BURST_SEQUENCE)\n        \n        photo1.refresh_from_db()\n        self.assertEqual(photo1.stacks.count(), 0)\n\n    def test_clear_stacks_only_affects_user(self):\n        \"\"\"Test clearing only affects specified user's stacks.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        other_photo1 = create_test_photo(owner=self.other_user)\n        other_photo2 = create_test_photo(owner=self.other_user)\n        \n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack1.photos.add(photo1, photo2)\n        \n        stack2 = PhotoStack.objects.create(\n            owner=self.other_user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack2.photos.add(other_photo1, other_photo2)\n        \n        clear_stacks_of_type(self.user, PhotoStack.StackType.BURST_SEQUENCE)\n        \n        # User's stack deleted\n        self.assertFalse(PhotoStack.objects.filter(pk=stack1.pk).exists())\n        # Other user's stack remains\n        self.assertTrue(PhotoStack.objects.filter(pk=stack2.pk).exists())\n\n    def test_clear_stacks_returns_zero_if_none(self):\n        \"\"\"Test clearing returns 0 if no stacks of type exist.\"\"\"\n        count = clear_stacks_of_type(self.user, PhotoStack.StackType.BURST_SEQUENCE)\n        self.assertEqual(count, 0)\n\n\nclass BurstDetectionTestCase(TestCase):\n    \"\"\"Tests for burst sequence detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        # Set up default burst rules using correct format\n        self.user.burst_detection_rules = json.dumps([\n            {\n                \"id\": 1,\n                \"name\": \"Test EXIF Burst\",\n                \"rule_type\": \"exif_burst_mode\",\n                \"category\": \"hard\",\n                \"enabled\": True,\n            },\n            {\n                \"id\": 2,\n                \"name\": \"Test Timestamp\",\n                \"rule_type\": \"timestamp_proximity\",\n                \"category\": \"soft\",\n                \"enabled\": True,\n                \"interval_ms\": 2000\n            }\n        ])\n        self.user.save()\n\n    def _create_file(self, path, file_type=File.IMAGE):\n        \"\"\"Helper to create a File object.\"\"\"\n        return File.objects.create(\n            hash=str(uuid.uuid4())[:32],\n            path=path,\n            type=file_type,\n        )\n\n    def _create_photo_with_timestamp(self, timestamp, **kwargs):\n        \"\"\"Helper to create Photo with specific timestamp.\"\"\"\n        photo = create_test_photo(owner=self.user, **kwargs)\n        photo.exif_timestamp = timestamp\n        file = self._create_file(f\"/photos/IMG_{photo.pk}.jpg\")\n        photo.main_file = file\n        photo.save()\n        return photo\n\n    def test_no_rules_returns_zero(self):\n        \"\"\"Test no burst detection when rules are empty.\"\"\"\n        self.user.burst_detection_rules = json.dumps([])\n        self.user.save()\n        \n        _photo1 = self._create_photo_with_timestamp(datetime(2024, 1, 1, 12, 0, 0))\n        _photo2 = self._create_photo_with_timestamp(datetime(2024, 1, 1, 12, 0, 1))\n        \n        count = detect_burst_sequences(self.user)\n        \n        self.assertEqual(count, 0)\n\n    def test_disabled_rules_ignored(self):\n        \"\"\"Test disabled rules are not used.\"\"\"\n        self.user.burst_detection_rules = json.dumps([\n            {\n                \"id\": 1,\n                \"name\": \"Disabled rule\",\n                \"rule_type\": \"timestamp_proximity\",\n                \"category\": \"soft\",\n                \"enabled\": False,\n                \"interval_ms\": 5000\n            }\n        ])\n        self.user.save()\n        \n        _photo1 = self._create_photo_with_timestamp(datetime(2024, 1, 1, 12, 0, 0))\n        _photo2 = self._create_photo_with_timestamp(datetime(2024, 1, 1, 12, 0, 1))\n        \n        count = detect_burst_sequences(self.user)\n        \n        self.assertEqual(count, 0)\n\n    def test_clears_existing_burst_stacks(self):\n        \"\"\"Test existing burst stacks are cleared before re-detection.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        old_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        old_stack.photos.add(photo1, photo2)\n        \n        detect_burst_sequences(self.user)\n        \n        self.assertFalse(PhotoStack.objects.filter(pk=old_stack.pk).exists())\n\n    def test_skip_hidden_photos(self):\n        \"\"\"Test hidden photos are excluded from burst detection.\"\"\"\n        base_time = datetime(2024, 1, 1, 12, 0, 0)\n        _photo1 = self._create_photo_with_timestamp(base_time, hidden=True)\n        _photo2 = self._create_photo_with_timestamp(base_time + timedelta(seconds=1))\n        \n        count = detect_burst_sequences(self.user)\n        \n        self.assertEqual(count, 0)\n\n    def test_skip_trashed_photos(self):\n        \"\"\"Test trashed photos are excluded from burst detection.\"\"\"\n        base_time = datetime(2024, 1, 1, 12, 0, 0)\n        _photo1 = self._create_photo_with_timestamp(base_time, in_trashcan=True)\n        _photo2 = self._create_photo_with_timestamp(base_time + timedelta(seconds=1))\n        \n        count = detect_burst_sequences(self.user)\n        \n        self.assertEqual(count, 0)\n\n\nclass CreateBurstStackTestCase(TestCase):\n    \"\"\"Tests for _create_burst_stack helper.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_requires_minimum_two_photos(self):\n        \"\"\"Test stack not created with less than 2 photos.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        stack = _create_burst_stack(self.user, [photo])\n        \n        self.assertIsNone(stack)\n\n    def test_creates_stack_with_two_photos(self):\n        \"\"\"Test stack created with exactly 2 photos.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo1.exif_timestamp = datetime(2024, 1, 1, 12, 0, 0)\n        photo2.exif_timestamp = datetime(2024, 1, 1, 12, 0, 1)\n        photo1.save()\n        photo2.save()\n        \n        stack = _create_burst_stack(self.user, [photo1, photo2])\n        \n        self.assertIsNotNone(stack)\n        self.assertEqual(stack.stack_type, PhotoStack.StackType.BURST_SEQUENCE)\n        self.assertEqual(stack.photos.count(), 2)\n\n    def test_skips_already_stacked_photos(self):\n        \"\"\"Test photos already in burst stack are skipped.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        # Add photo1 to existing burst stack\n        existing_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        existing_stack.photos.add(photo1, photo2)\n        \n        # Try to create new stack with photo1 and photo3\n        stack = _create_burst_stack(self.user, [photo1, photo3])\n        \n        # photo1 is filtered out, photo3 remains = 1 photo < 2, no stack created\n        self.assertIsNone(stack)\n\n\nclass BatchDetectStacksTestCase(TestCase):\n    \"\"\"Tests for batch_detect_stacks orchestration.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.user.burst_detection_rules = json.dumps([])\n        self.user.save()\n\n    @patch('api.stack_detection.detect_burst_sequences')\n    def test_calls_burst_detector_by_default(self, mock_burst):\n        \"\"\"Test burst detector called with default options.\"\"\"\n        mock_burst.return_value = 3\n        \n        batch_detect_stacks(self.user)\n        \n        mock_burst.assert_called_once()\n\n    @patch('api.stack_detection.detect_burst_sequences')\n    def test_respects_options(self, mock_burst):\n        \"\"\"Test options control which detectors run.\"\"\"\n        mock_burst.return_value = 0\n        \n        batch_detect_stacks(self.user, options={\n            'detect_bursts': False,\n        })\n        \n        mock_burst.assert_not_called()\n\n    @patch('api.stack_detection.detect_burst_sequences')\n    def test_creates_job(self, mock_burst):\n        \"\"\"Test LongRunningJob created for tracking.\"\"\"\n        mock_burst.return_value = 0\n        \n        batch_detect_stacks(self.user)\n        \n        job = LongRunningJob.objects.filter(\n            started_by=self.user,\n            job_type=LongRunningJob.JOB_SCAN_PHOTOS,\n        ).first()\n        self.assertIsNotNone(job)\n\n    @patch('api.stack_detection.detect_burst_sequences')\n    def test_handles_exception(self, mock_burst):\n        \"\"\"Test exception handling during detection.\"\"\"\n        mock_burst.side_effect = Exception(\"Detection failed\")\n        \n        with self.assertRaises(Exception):\n            batch_detect_stacks(self.user)\n        \n        # Job should be marked as failed\n        job = LongRunningJob.objects.filter(\n            started_by=self.user,\n        ).first()\n        self.assertIsNotNone(job)\n\n\nclass FileExtensionsTestCase(TestCase):\n    \"\"\"Tests for file extension handling in directory_watcher.\"\"\"\n\n    def test_jpeg_extensions_included(self):\n        \"\"\"Test JPEG and HEIC extensions are included.\"\"\"\n        expected = ['.jpg', '.jpeg', '.heic', '.heif']\n        for ext in expected:\n            self.assertIn(ext, JPEG_EXTENSIONS, f\"{ext} should be in JPEG_EXTENSIONS\")\n\n    def test_extensions_are_lowercase(self):\n        \"\"\"Test all JPEG extensions are lowercase.\"\"\"\n        for ext in JPEG_EXTENSIONS:\n            self.assertEqual(ext, ext.lower(), f\"{ext} should be lowercase\")\n\n\nclass EdgeCasesTestCase(TestCase):\n    \"\"\"Edge case tests for stack detection.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_photo_without_main_file(self):\n        \"\"\"Test photos without main_file are handled gracefully.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        photo.main_file = None\n        photo.save()\n        \n        # Set up rules to trigger detection\n        self.user.burst_detection_rules = json.dumps([\n            {\n                \"id\": 1,\n                \"name\": \"Test Timestamp\",\n                \"rule_type\": \"timestamp_proximity\",\n                \"category\": \"soft\",\n                \"enabled\": True,\n                \"interval_ms\": 2000\n            }\n        ])\n        self.user.save()\n        \n        # Should not raise\n        count = detect_burst_sequences(self.user)\n        self.assertEqual(count, 0)\n\n    def test_empty_photo_library(self):\n        \"\"\"Test detection on empty library.\"\"\"\n        self.user.burst_detection_rules = json.dumps([\n            {\n                \"id\": 1,\n                \"name\": \"Test Timestamp\",\n                \"rule_type\": \"timestamp_proximity\",\n                \"category\": \"soft\",\n                \"enabled\": True,\n                \"interval_ms\": 2000\n            }\n        ])\n        self.user.save()\n        \n        count = detect_burst_sequences(self.user)\n        self.assertEqual(count, 0)\n\n    def test_burst_rules_as_string(self):\n        \"\"\"Test burst_detection_rules stored as JSON string.\"\"\"\n        self.user.burst_detection_rules = '[]'  # Empty JSON string\n        self.user.save()\n        \n        count = detect_burst_sequences(self.user)\n        self.assertEqual(count, 0)\n\n\nclass FileVariantTestCase(TestCase):\n    \"\"\"\n    Tests for file variant handling (RAW+JPEG, Live Photos).\n    \n    File variants are now stored via Photo.files ManyToMany field,\n    not as stacks. These tests verify the data model works correctly.\n    \"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def _create_file(self, path, file_type=File.IMAGE):\n        \"\"\"Helper to create a File object.\"\"\"\n        return File.objects.create(\n            hash=str(uuid.uuid4())[:32],\n            path=path,\n            type=file_type,\n        )\n\n    def test_photo_with_raw_variant(self):\n        \"\"\"Test a photo can have a RAW file variant.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        # Add JPEG as main file\n        jpeg_file = self._create_file(\"/photos/IMG_001.jpg\", File.IMAGE)\n        photo.main_file = jpeg_file\n        photo.files.add(jpeg_file)\n        \n        # Add RAW variant\n        raw_file = self._create_file(\"/photos/IMG_001.CR2\", File.RAW_FILE)\n        photo.files.add(raw_file)\n        photo.save()\n        \n        # Verify\n        self.assertEqual(photo.files.count(), 2)\n        self.assertTrue(photo.files.filter(type=File.RAW_FILE).exists())\n        self.assertTrue(photo.files.filter(type=File.IMAGE).exists())\n\n    def test_photo_with_live_photo_video(self):\n        \"\"\"Test a photo can have a video variant (Live Photo).\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        # Add HEIC as main file\n        heic_file = self._create_file(\"/photos/IMG_001.heic\", File.IMAGE)\n        photo.main_file = heic_file\n        photo.files.add(heic_file)\n        \n        # Add video variant\n        video_file = self._create_file(\"/photos/IMG_001.mov\", File.VIDEO)\n        photo.files.add(video_file)\n        photo.save()\n        \n        # Verify\n        self.assertEqual(photo.files.count(), 2)\n        self.assertTrue(photo.files.filter(type=File.VIDEO).exists())\n        self.assertTrue(photo.files.filter(type=File.IMAGE).exists())\n\n    def test_photo_with_all_variant_types(self):\n        \"\"\"Test a photo can have image, RAW, and video variants.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        # Add all variant types\n        jpeg_file = self._create_file(\"/photos/IMG_001.jpg\", File.IMAGE)\n        raw_file = self._create_file(\"/photos/IMG_001.CR2\", File.RAW_FILE)\n        video_file = self._create_file(\"/photos/IMG_001.mov\", File.VIDEO)\n        \n        photo.main_file = jpeg_file\n        photo.files.add(jpeg_file, raw_file, video_file)\n        photo.save()\n        \n        # Verify\n        self.assertEqual(photo.files.count(), 3)\n        file_types = set(photo.files.values_list('type', flat=True))\n        self.assertEqual(file_types, {File.IMAGE, File.RAW_FILE, File.VIDEO})\n\n    def test_file_variant_vs_stack(self):\n        \"\"\"Test that file variants are separate from stacks.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        # Add file variant to photo1\n        jpeg_file = self._create_file(\"/photos/IMG_001.jpg\", File.IMAGE)\n        raw_file = self._create_file(\"/photos/IMG_001.CR2\", File.RAW_FILE)\n        photo1.main_file = jpeg_file\n        photo1.files.add(jpeg_file, raw_file)\n        photo1.save()\n        \n        # Add photo1 and photo2 to a burst stack\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        # Photo1 has 2 file variants AND is in a stack\n        self.assertEqual(photo1.files.count(), 2)\n        self.assertEqual(photo1.stacks.count(), 1)\n        \n        # These are independent concepts\n        self.assertNotEqual(photo1.files.count(), photo1.stacks.count())\n\n    def test_special_characters_in_filename(self):\n        \"\"\"Test files with special characters in names.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        jpeg_file = self._create_file(\"/photos/IMG (1) - Copy.jpg\", File.IMAGE)\n        raw_file = self._create_file(\"/photos/IMG (1) - Copy.CR2\", File.RAW_FILE)\n        \n        photo.main_file = jpeg_file\n        photo.files.add(jpeg_file, raw_file)\n        photo.save()\n        \n        self.assertEqual(photo.files.count(), 2)\n\n    def test_unicode_in_filename(self):\n        \"\"\"Test files with unicode characters in names.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        jpeg_file = self._create_file(\"/photos/фото_001.jpg\", File.IMAGE)\n        raw_file = self._create_file(\"/photos/фото_001.CR2\", File.RAW_FILE)\n        \n        photo.main_file = jpeg_file\n        photo.files.add(jpeg_file, raw_file)\n        photo.save()\n        \n        self.assertEqual(photo.files.count(), 2)\n\n    def test_progress_callback_called(self):\n        \"\"\"Test progress callback is called during burst detection.\"\"\"\n        self.user.burst_detection_rules = json.dumps([\n            {\n                \"id\": 1,\n                \"name\": \"Test Timestamp\",\n                \"rule_type\": \"timestamp_proximity\",\n                \"category\": \"soft\",\n                \"enabled\": True,\n                \"interval_ms\": 2000\n            }\n        ])\n        self.user.save()\n        \n        file = File.objects.create(\n            hash=str(uuid.uuid4())[:32],\n            path=\"/photos/IMG_001.jpg\",\n            type=File.IMAGE,\n        )\n        photo = create_test_photo(owner=self.user)\n        photo.main_file = file\n        photo.exif_timestamp = datetime(2024, 1, 1, 12, 0, 0)\n        photo.save()\n        \n        callback_calls = []\n        \n        def progress_callback(current, total, found):\n            callback_calls.append((current, total, found))\n        \n        detect_burst_sequences(self.user, progress_callback=progress_callback)\n        \n        # Callback should be called at least once (for index 0) or not at all if no hard rules\n        self.assertGreaterEqual(len(callback_calls), 0)\n"
  },
  {
    "path": "api/tests/test_stack_duplicate_integration.py",
    "content": "\"\"\"\nIntegration tests for interactions between Stacks and Duplicates.\n\nTests edge cases where photos belong to both stacks and duplicate groups:\n- Photo in stack is also part of duplicate group\n- Resolving duplicate trashes stack's primary photo\n- Deleting stack affects duplicate group\n- Concurrent operations on same photos\n- Multi-user isolation\n\"\"\"\n\n\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom api.models.duplicate import Duplicate\nfrom api.models.file import File\nfrom api.models.photo import Photo\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.user import User\n\n\nclass PhotoInBothStackAndDuplicateTestCase(TestCase):\n    \"\"\"Tests for photos that are in both a stack and a duplicate group.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and photos.\"\"\"\n        self.user = User.objects.create(username=\"integrationtest\")\n        self.photos = self._create_photos(4)\n\n    def _create_photos(self, count):\n        \"\"\"Helper to create multiple photos.\"\"\"\n        photos = []\n        for i in range(count):\n            file = File.objects.create(\n                hash=f\"integ{i}\" + \"a\" * 27,\n                path=f\"/photos/integration_{i}.jpg\",\n                type=File.IMAGE,\n            )\n            photo = Photo.objects.create(\n                owner=self.user,\n                main_file=file,\n                image_hash=f\"integ{i}\" + \"b\" * 27,\n                added_on=timezone.now(),\n                in_trashcan=False,\n            )\n            photos.append(photo)\n        return photos\n\n    def test_photo_can_be_in_stack_and_duplicate_simultaneously(self):\n        \"\"\"Photo should be able to belong to both a stack and duplicate group.\"\"\"\n        # Create stack with first two photos\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        self.photos[0].stacks.add(stack)\n        self.photos[1].stacks.add(stack)\n\n        # Create duplicate group with first and third photos\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        self.photos[0].duplicates.add(duplicate)\n        self.photos[2].duplicates.add(duplicate)\n\n        # Verify photo 0 is in both\n        self.assertEqual(self.photos[0].stacks.count(), 1)\n        self.assertEqual(self.photos[0].duplicates.count(), 1)\n\n        # Verify stack and duplicate are independent\n        self.assertEqual(stack.photos.count(), 2)\n        self.assertEqual(duplicate.photos.count(), 2)\n\n    def test_resolving_duplicate_trashes_stacked_photo(self):\n        \"\"\"Resolving duplicate should trash photo even if it's in a stack.\"\"\"\n        # Photo 0 and 1 are in a stack\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            primary_photo=self.photos[0],\n        )\n        self.photos[0].stacks.add(stack)\n        self.photos[1].stacks.add(stack)\n\n        # Photo 0 and 2 are duplicates\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        self.photos[0].duplicates.add(duplicate)\n        self.photos[2].duplicates.add(duplicate)\n\n        # Resolve duplicate, keeping photo 2 (trash photo 0)\n        duplicate.resolve(self.photos[2], trash_others=True)\n\n        # Photo 0 should be trashed\n        self.photos[0].refresh_from_db()\n        self.assertTrue(self.photos[0].in_trashcan)\n\n        # Photo 0 should still be in stack (not removed)\n        self.assertEqual(self.photos[0].stacks.count(), 1)\n\n        # Stack should still have both photos (ManyToMany not affected by trash)\n        self.assertEqual(stack.photos.count(), 2)\n\n    def test_resolving_duplicate_trashes_stack_primary(self):\n        \"\"\"What happens when duplicate resolution trashes the stack's primary photo.\"\"\"\n        # Photo 0 is stack primary\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            primary_photo=self.photos[0],\n        )\n        self.photos[0].stacks.add(stack)\n        self.photos[1].stacks.add(stack)\n\n        # Photo 0 and 2 are duplicates\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        self.photos[0].duplicates.add(duplicate)\n        self.photos[2].duplicates.add(duplicate)\n\n        # Resolve keeping photo 2, trashing photo 0 (the stack primary)\n        duplicate.resolve(self.photos[2], trash_others=True)\n\n        # Stack's primary is now trashed - this is a potential issue\n        stack.refresh_from_db()\n        self.assertTrue(stack.primary_photo.in_trashcan)\n\n        # Note: The system allows this - the UI should handle showing appropriate warning\n\n    def test_deleting_stack_does_not_affect_duplicate(self):\n        \"\"\"Deleting a stack should not affect duplicate group membership.\"\"\"\n        # Photo in both stack and duplicate\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photos[0].stacks.add(stack)\n\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        self.photos[0].duplicates.add(duplicate)\n        self.photos[1].duplicates.add(duplicate)\n\n        # Delete the stack\n        stack.delete()\n\n        # Photo should still be in duplicate group\n        self.photos[0].refresh_from_db()\n        self.assertEqual(self.photos[0].duplicates.count(), 1)\n        self.assertEqual(duplicate.photos.count(), 2)\n\n    def test_dismissing_duplicate_does_not_affect_stack(self):\n        \"\"\"Dismissing duplicate group should not affect stack membership.\"\"\"\n        # Photo in both\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        self.photos[0].stacks.add(stack)\n        self.photos[1].stacks.add(stack)\n\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        self.photos[0].duplicates.add(duplicate)\n        self.photos[2].duplicates.add(duplicate)\n\n        # Dismiss the duplicate\n        duplicate.dismiss()\n\n        # Photo should still be in stack\n        self.photos[0].refresh_from_db()\n        self.assertEqual(self.photos[0].stacks.count(), 1)\n        self.assertEqual(stack.photos.count(), 2)\n\n\nclass PhotoDeletionCascadeTestCase(TestCase):\n    \"\"\"Tests for photo deletion effects on stacks and duplicates.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and photos.\"\"\"\n        self.user = User.objects.create(username=\"deletiontest\")\n\n    def _create_photo(self, suffix):\n        \"\"\"Create a single photo.\"\"\"\n        file = File.objects.create(\n            hash=f\"del{suffix}\" + \"a\" * 28,\n            path=f\"/photos/delete_{suffix}.jpg\",\n            type=File.IMAGE,\n        )\n        return Photo.objects.create(\n            owner=self.user,\n            main_file=file,\n            image_hash=f\"del{suffix}\" + \"b\" * 28,\n            added_on=timezone.now(),\n        )\n\n    def test_deleting_photo_removes_from_stack(self):\n        \"\"\"Deleting photo should remove it from associated stacks.\"\"\"\n        photo1 = self._create_photo(\"1\")\n        photo2 = self._create_photo(\"2\")\n\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        photo1.stacks.add(stack)\n        photo2.stacks.add(stack)\n\n        self.assertEqual(stack.photos.count(), 2)\n\n        # Delete photo1\n        photo1.delete()\n\n        stack.refresh_from_db()\n        self.assertEqual(stack.photos.count(), 1)\n        self.assertEqual(stack.photos.first(), photo2)\n\n    def test_deleting_photo_removes_from_duplicate(self):\n        \"\"\"Deleting photo should remove it from associated duplicate groups.\"\"\"\n        photo1 = self._create_photo(\"3\")\n        photo2 = self._create_photo(\"4\")\n\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        photo1.duplicates.add(duplicate)\n        photo2.duplicates.add(duplicate)\n\n        self.assertEqual(duplicate.photos.count(), 2)\n\n        # Delete photo1\n        photo1.delete()\n\n        duplicate.refresh_from_db()\n        self.assertEqual(duplicate.photos.count(), 1)\n\n    def test_deleting_stack_primary_sets_to_null(self):\n        \"\"\"Deleting stack's primary photo should set primary_photo to NULL.\"\"\"\n        photo1 = self._create_photo(\"5\")\n        photo2 = self._create_photo(\"6\")\n\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            primary_photo=photo1,\n        )\n        photo1.stacks.add(stack)\n        photo2.stacks.add(stack)\n\n        # Delete the primary photo\n        photo1.delete()\n\n        stack.refresh_from_db()\n        self.assertIsNone(stack.primary_photo)\n        self.assertEqual(stack.photos.count(), 1)\n\n    def test_deleting_kept_photo_sets_to_null(self):\n        \"\"\"Deleting duplicate's kept_photo should set kept_photo to NULL.\"\"\"\n        photo1 = self._create_photo(\"7\")\n        photo2 = self._create_photo(\"8\")\n\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            kept_photo=photo1,\n        )\n        photo1.duplicates.add(duplicate)\n        photo2.duplicates.add(duplicate)\n\n        # Delete the kept photo\n        photo1.delete()\n\n        duplicate.refresh_from_db()\n        self.assertIsNone(duplicate.kept_photo)\n\n\nclass MultiUserIsolationTestCase(TestCase):\n    \"\"\"Tests for proper isolation between users.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create two test users.\"\"\"\n        self.user1 = User.objects.create(username=\"user1\")\n        self.user2 = User.objects.create(username=\"user2\")\n\n    def _create_photo_for_user(self, user, suffix):\n        \"\"\"Create a photo for a specific user.\"\"\"\n        file = File.objects.create(\n            hash=f\"usr{suffix}\" + \"a\" * 28,\n            path=f\"/photos/user_{suffix}.jpg\",\n            type=File.IMAGE,\n        )\n        return Photo.objects.create(\n            owner=user,\n            main_file=file,\n            image_hash=f\"usr{suffix}\" + \"b\" * 28,\n            added_on=timezone.now(),\n        )\n\n    def test_users_cannot_share_stacks(self):\n        \"\"\"Users should not be able to add photos to another user's stack.\"\"\"\n        photo1 = self._create_photo_for_user(self.user1, \"1\")\n        photo2 = self._create_photo_for_user(self.user2, \"2\")\n\n        stack = PhotoStack.objects.create(\n            owner=self.user1,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        photo1.stacks.add(stack)\n\n        # Attempt to add user2's photo to user1's stack\n        # The ManyToMany doesn't enforce this at DB level, but API should\n        photo2.stacks.add(stack)  # This succeeds at DB level\n\n        # Stack contains both photos (no DB-level enforcement)\n        self.assertEqual(stack.photos.count(), 2)\n\n        # Note: This is a potential issue - API layer should validate owner\n\n    def test_duplicate_detection_scoped_to_user(self):\n        \"\"\"Duplicate groups should be scoped to owner.\"\"\"\n        photo1 = self._create_photo_for_user(self.user1, \"3\")\n        \n        dup1 = Duplicate.objects.create(\n            owner=self.user1,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        photo1.duplicates.add(dup1)\n\n        # User2's duplicates should be separate\n        photo2 = self._create_photo_for_user(self.user2, \"4\")\n        dup2 = Duplicate.objects.create(\n            owner=self.user2,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        photo2.duplicates.add(dup2)\n\n        # Each user should have their own duplicate\n        self.assertEqual(Duplicate.objects.filter(owner=self.user1).count(), 1)\n        self.assertEqual(Duplicate.objects.filter(owner=self.user2).count(), 1)\n\n\nclass StackMergeWithDuplicatesTestCase(TestCase):\n    \"\"\"Tests for stack merging when photos are also in duplicates.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and photos.\"\"\"\n        self.user = User.objects.create(username=\"mergetest\")\n        self.photos = []\n        for i in range(4):\n            file = File.objects.create(\n                hash=f\"mrg{i}\" + \"a\" * 28,\n                path=f\"/photos/merge_{i}.jpg\",\n                type=File.IMAGE,\n            )\n            photo = Photo.objects.create(\n                owner=self.user,\n                main_file=file,\n                image_hash=f\"mrg{i}\" + \"b\" * 28,\n                added_on=timezone.now(),\n            )\n            self.photos.append(photo)\n\n    def test_merging_stacks_preserves_duplicate_membership(self):\n        \"\"\"Merging stacks should not affect duplicate group membership.\"\"\"\n        # Stack 1 with photo 0, 1\n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photos[0].stacks.add(stack1)\n        self.photos[1].stacks.add(stack1)\n\n        # Stack 2 with photo 2, 3\n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        self.photos[2].stacks.add(stack2)\n        self.photos[3].stacks.add(stack2)\n\n        # Photo 1 and 2 are duplicates\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        self.photos[1].duplicates.add(duplicate)\n        self.photos[2].duplicates.add(duplicate)\n\n        # Merge stack2 into stack1\n        stack1.merge_with(stack2)\n\n        # All photos should be in stack1\n        self.assertEqual(stack1.photos.count(), 4)\n\n        # Duplicate membership should be preserved\n        self.assertEqual(self.photos[1].duplicates.count(), 1)\n        self.assertEqual(self.photos[2].duplicates.count(), 1)\n        self.assertEqual(duplicate.photos.count(), 2)\n\n\nclass PhotoInMultipleStacksTestCase(TestCase):\n    \"\"\"Tests for photos that are in multiple stacks.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and photos.\"\"\"\n        self.user = User.objects.create(username=\"multistacktest\")\n\n    def _create_photo(self, suffix):\n        \"\"\"Create a single photo.\"\"\"\n        file = File.objects.create(\n            hash=f\"multi{suffix}\" + \"a\" * 26,\n            path=f\"/photos/multi_{suffix}.jpg\",\n            type=File.IMAGE,\n        )\n        return Photo.objects.create(\n            owner=self.user,\n            main_file=file,\n            image_hash=f\"multi{suffix}\" + \"b\" * 26,\n            added_on=timezone.now(),\n        )\n\n    def test_photo_can_be_in_multiple_stacks(self):\n        \"\"\"Photo should be able to belong to multiple stacks.\"\"\"\n        photo = self._create_photo(\"1\")\n\n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.RAW_JPEG_PAIR,\n        )\n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n\n        photo.stacks.add(stack1)\n        photo.stacks.add(stack2)\n\n        self.assertEqual(photo.stacks.count(), 2)\n\n    def test_photo_can_be_primary_in_multiple_stacks(self):\n        \"\"\"Photo can be primary photo for multiple stacks.\"\"\"\n        photo = self._create_photo(\"2\")\n\n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.RAW_JPEG_PAIR,\n            primary_photo=photo,\n        )\n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            primary_photo=photo,\n        )\n\n        photo.stacks.add(stack1)\n        photo.stacks.add(stack2)\n\n        self.assertEqual(stack1.primary_photo, photo)\n        self.assertEqual(stack2.primary_photo, photo)\n\n    def test_removing_photo_from_one_stack_preserves_others(self):\n        \"\"\"Removing from one stack should not affect other stacks.\"\"\"\n        photo = self._create_photo(\"3\")\n\n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n\n        photo.stacks.add(stack1)\n        photo.stacks.add(stack2)\n\n        # Remove from stack1\n        photo.stacks.remove(stack1)\n\n        # Should still be in stack2\n        self.assertEqual(photo.stacks.count(), 1)\n        self.assertIn(stack2, photo.stacks.all())\n\n\nclass DuplicateMergeWithStacksTestCase(TestCase):\n    \"\"\"Tests for duplicate merging when photos are also in stacks.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and photos.\"\"\"\n        self.user = User.objects.create(username=\"dupmergetest\")\n        self.photos = []\n        for i in range(4):\n            file = File.objects.create(\n                hash=f\"dpm{i}\" + \"a\" * 28,\n                path=f\"/photos/dupmerge_{i}.jpg\",\n                type=File.IMAGE,\n            )\n            photo = Photo.objects.create(\n                owner=self.user,\n                main_file=file,\n                image_hash=f\"dpm{i}\" + \"b\" * 28,\n                added_on=timezone.now(),\n            )\n            self.photos.append(photo)\n\n    def test_merging_duplicates_preserves_stack_membership(self):\n        \"\"\"Merging duplicate groups should not affect stack membership.\"\"\"\n        # Photo 0, 1 are in a stack\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        self.photos[0].stacks.add(stack)\n        self.photos[1].stacks.add(stack)\n\n        # Dup1: photo 0, 2\n        dup1 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        self.photos[0].duplicates.add(dup1)\n        self.photos[2].duplicates.add(dup1)\n\n        # Dup2: photo 1, 3\n        dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        self.photos[1].duplicates.add(dup2)\n        self.photos[3].duplicates.add(dup2)\n\n        # Merge dup2 into dup1\n        dup1.merge_with(dup2)\n\n        # Stack membership should be preserved\n        self.assertEqual(stack.photos.count(), 2)\n        self.assertEqual(self.photos[0].stacks.count(), 1)\n        self.assertEqual(self.photos[1].stacks.count(), 1)\n\n\nclass EdgeCasesTestCase(TestCase):\n    \"\"\"Edge case tests for stack/duplicate integration.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user.\"\"\"\n        self.user = User.objects.create(username=\"edgecasetest\")\n\n    def _create_photo(self, suffix):\n        \"\"\"Create a single photo.\"\"\"\n        file = File.objects.create(\n            hash=f\"edge{suffix}\" + \"a\" * 26,\n            path=f\"/photos/edge_{suffix}.jpg\",\n            type=File.IMAGE,\n        )\n        return Photo.objects.create(\n            owner=self.user,\n            main_file=file,\n            image_hash=f\"edge{suffix}\" + \"b\" * 26,\n            added_on=timezone.now(),\n        )\n\n    def test_empty_stack_and_duplicate(self):\n        \"\"\"Empty stack and duplicate should exist without photos.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n\n        self.assertEqual(stack.photos.count(), 0)\n        self.assertEqual(duplicate.photos.count(), 0)\n\n    def test_single_photo_in_both(self):\n        \"\"\"Single photo can be only member of both stack and duplicate.\"\"\"\n        photo = self._create_photo(\"1\")\n\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        photo.stacks.add(stack)\n\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        photo.duplicates.add(duplicate)\n\n        self.assertEqual(stack.photos.count(), 1)\n        self.assertEqual(duplicate.photos.count(), 1)\n\n    def test_trashing_all_photos_in_stack(self):\n        \"\"\"Trashing all photos in stack should not delete the stack.\"\"\"\n        photo1 = self._create_photo(\"2\")\n        photo2 = self._create_photo(\"3\")\n\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        photo1.stacks.add(stack)\n        photo2.stacks.add(stack)\n\n        # Trash all photos\n        photo1.in_trashcan = True\n        photo1.save()\n        photo2.in_trashcan = True\n        photo2.save()\n\n        # Stack should still exist\n        self.assertTrue(PhotoStack.objects.filter(id=stack.id).exists())\n        self.assertEqual(stack.photos.count(), 2)\n\n    def test_photo_in_multiple_duplicate_groups(self):\n        \"\"\"Photo can be in multiple duplicate groups.\"\"\"\n        photo = self._create_photo(\"4\")\n\n        dup1 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n\n        photo.duplicates.add(dup1)\n        photo.duplicates.add(dup2)\n\n        self.assertEqual(photo.duplicates.count(), 2)\n\n    def test_resolving_duplicate_does_not_affect_other_duplicates(self):\n        \"\"\"Resolving one duplicate should not affect photo's other duplicates.\"\"\"\n        photo1 = self._create_photo(\"5\")\n        photo2 = self._create_photo(\"6\")\n        photo3 = self._create_photo(\"7\")\n\n        # photo1 is in two duplicate groups\n        dup1 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        photo1.duplicates.add(dup1)\n        photo2.duplicates.add(dup1)\n\n        dup2 = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n        )\n        photo1.duplicates.add(dup2)\n        photo3.duplicates.add(dup2)\n\n        # Resolve dup1, keeping photo1\n        dup1.resolve(photo1, trash_others=True)\n\n        # photo1 should still be in dup2\n        photo1.refresh_from_db()\n        self.assertEqual(photo1.duplicates.count(), 2)\n        self.assertIn(dup2, photo1.duplicates.all())\n\n    def test_cascade_effects_are_contained(self):\n        \"\"\"Operations on one group should not cascade unexpectedly.\"\"\"\n        photo1 = self._create_photo(\"8\")\n        photo2 = self._create_photo(\"9\")\n\n        # Create complex relationship\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            primary_photo=photo1,\n        )\n        photo1.stacks.add(stack)\n        photo2.stacks.add(stack)\n\n        duplicate = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            kept_photo=photo1,\n        )\n        photo1.duplicates.add(duplicate)\n        photo2.duplicates.add(duplicate)\n\n        # Delete the stack\n        _stack_id = stack.id\n        stack.delete()\n\n        # Duplicate should be unaffected\n        self.assertTrue(Duplicate.objects.filter(id=duplicate.id).exists())\n        self.assertEqual(duplicate.photos.count(), 2)\n        \n        # Photos should still be in duplicate\n        photo1.refresh_from_db()\n        photo2.refresh_from_db()\n        self.assertEqual(photo1.duplicates.count(), 1)\n        self.assertEqual(photo2.duplicates.count(), 1)\n"
  },
  {
    "path": "api/tests/test_stack_review.py",
    "content": "\"\"\"\nComprehensive tests for api/models/stack_review.py\n\nTests the StackReview model for tracking user review decisions on stacks:\n- Model creation and field validation\n- Decision choices (PENDING, RESOLVED, DISMISSED)\n- Resolve workflow (keep photo, trash others)\n- Dismiss workflow (unlink photos)\n- Revert workflow (restore trashed photos)\n- Reviewable stack types logic\n\nNOTE: With the new file variants architecture, no stack types are\nreviewable because:\n- exact_copy and visual_duplicate are handled by Duplicate model\n- BURST_SEQUENCE, EXPOSURE_BRACKET, MANUAL are informational\n- RAW_JPEG_PAIR and LIVE_PHOTO are deprecated (use Photo.files)\n\"\"\"\n\nimport uuid\n\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom api.models.file import File\nfrom api.models.photo import Photo\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.stack_review import StackReview\nfrom api.models.user import User\n\n\nclass StackReviewModelTestCase(TestCase):\n    \"\"\"Tests for the StackReview model creation and fields.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user and stack.\"\"\"\n        self.user = User.objects.create(username=\"reviewtest\")\n        self.stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n\n    def test_create_stack_review(self):\n        \"\"\"Should create a StackReview with default values.\"\"\"\n        review = StackReview.objects.create(\n            stack=self.stack,\n            reviewer=self.user,\n        )\n        self.assertIsNotNone(review.id)\n        self.assertEqual(review.decision, StackReview.Decision.PENDING)\n        self.assertIsNone(review.kept_photo)\n        self.assertEqual(review.trashed_count, 0)\n        self.assertIsNone(review.reviewed_at)\n\n    def test_uuid_primary_key(self):\n        \"\"\"Review ID should be a valid UUID.\"\"\"\n        review = StackReview.objects.create(\n            stack=self.stack,\n            reviewer=self.user,\n        )\n        self.assertIsInstance(review.id, uuid.UUID)\n\n    def test_one_to_one_with_stack(self):\n        \"\"\"Stack should have at most one review.\"\"\"\n        StackReview.objects.create(\n            stack=self.stack,\n            reviewer=self.user,\n        )\n        # Creating second review for same stack should raise error\n        from django.db import IntegrityError\n        with self.assertRaises(IntegrityError):\n            StackReview.objects.create(\n                stack=self.stack,\n                reviewer=self.user,\n            )\n\n    def test_str_representation(self):\n        \"\"\"String representation should include stack ID and decision.\"\"\"\n        review = StackReview.objects.create(\n            stack=self.stack,\n            reviewer=self.user,\n            decision=StackReview.Decision.PENDING,\n        )\n        result = str(review)\n        self.assertIn(str(self.stack.id), result)\n        self.assertIn(\"pending\", result)\n\n    def test_decision_choices(self):\n        \"\"\"All decision choices should be valid.\"\"\"\n        for decision, label in StackReview.Decision.choices:\n            review = StackReview.objects.create(\n                stack=PhotoStack.objects.create(\n                    owner=self.user,\n                    stack_type=PhotoStack.StackType.MANUAL,\n                ),\n                reviewer=self.user,\n                decision=decision,\n            )\n            self.assertEqual(review.decision, decision)\n\n    def test_optional_note_field(self):\n        \"\"\"Note field should be optional.\"\"\"\n        review = StackReview.objects.create(\n            stack=self.stack,\n            reviewer=self.user,\n            note=\"User's reason for this decision\",\n        )\n        self.assertEqual(review.note, \"User's reason for this decision\")\n\n    def test_ordering_by_created_at_descending(self):\n        \"\"\"Reviews should be ordered by created_at descending.\"\"\"\n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        review1 = StackReview.objects.create(\n            stack=self.stack,\n            reviewer=self.user,\n        )\n        review2 = StackReview.objects.create(\n            stack=stack2,\n            reviewer=self.user,\n        )\n        reviews = list(StackReview.objects.all())\n        # review2 was created later, should come first\n        self.assertEqual(reviews[0], review2)\n        self.assertEqual(reviews[1], review1)\n\n\nclass DecisionChoicesTestCase(TestCase):\n    \"\"\"Tests for the Decision text choices.\"\"\"\n\n    def test_pending_choice(self):\n        \"\"\"PENDING decision should exist.\"\"\"\n        self.assertEqual(StackReview.Decision.PENDING, \"pending\")\n\n    def test_resolved_choice(self):\n        \"\"\"RESOLVED decision should exist.\"\"\"\n        self.assertEqual(StackReview.Decision.RESOLVED, \"resolved\")\n\n    def test_dismissed_choice(self):\n        \"\"\"DISMISSED decision should exist.\"\"\"\n        self.assertEqual(StackReview.Decision.DISMISSED, \"dismissed\")\n\n    def test_all_choices_have_labels(self):\n        \"\"\"All choices should have human-readable labels.\"\"\"\n        labels = dict(StackReview.Decision.choices)\n        self.assertEqual(labels[\"pending\"], \"Pending Review\")\n        self.assertEqual(labels[\"resolved\"], \"Resolved\")\n        self.assertEqual(labels[\"dismissed\"], \"Dismissed\")\n\n\nclass IsReviewableTypeTestCase(TestCase):\n    \"\"\"Tests for the is_reviewable_type classmethod.\"\"\"\n\n    def test_no_stack_types_are_reviewable(self):\n        \"\"\"No current stack types should be reviewable.\n        \n        This is because:\n        - Duplicates are now handled by the separate Duplicate model\n        - Other stack types are informational only\n        - RAW_JPEG_PAIR and LIVE_PHOTO are deprecated (use Photo.files)\n        \"\"\"\n        for stack_type in PhotoStack.StackType.values:\n            self.assertFalse(\n                StackReview.is_reviewable_type(stack_type),\n                f\"{stack_type} should not be reviewable\"\n            )\n\n    def test_manual_not_reviewable(self):\n        \"\"\"MANUAL stacks should not be reviewable.\"\"\"\n        self.assertFalse(StackReview.is_reviewable_type(PhotoStack.StackType.MANUAL))\n\n    def test_burst_not_reviewable(self):\n        \"\"\"BURST_SEQUENCE stacks should not be reviewable.\"\"\"\n        self.assertFalse(StackReview.is_reviewable_type(PhotoStack.StackType.BURST_SEQUENCE))\n\n\nclass CreateForStackTestCase(TestCase):\n    \"\"\"Tests for the create_for_stack classmethod.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user.\"\"\"\n        self.user = User.objects.create(username=\"createtest\")\n\n    def test_returns_none_for_all_stack_types(self):\n        \"\"\"Should return None for all stack types (none are reviewable).\"\"\"\n        for stack_type in PhotoStack.StackType.values:\n            stack = PhotoStack.objects.create(\n                owner=self.user,\n                stack_type=stack_type,\n            )\n            review = StackReview.create_for_stack(stack)\n            self.assertIsNone(\n                review,\n                f\"create_for_stack should return None for {stack_type}\"\n            )\n\n    def test_returns_none_for_manual_stack(self):\n        \"\"\"Should return None for manual stacks.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        review = StackReview.create_for_stack(stack)\n        self.assertIsNone(review)\n\n    def test_returns_none_for_burst_stack(self):\n        \"\"\"Should return None for burst stacks.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        review = StackReview.create_for_stack(stack)\n        self.assertIsNone(review)\n\n\nclass ResolveTestCase(TestCase):\n    \"\"\"Tests for the resolve method.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user, photos, and stack.\"\"\"\n        self.user = User.objects.create(username=\"resolvetest\")\n        \n        # Create photos with files\n        self.photos = []\n        for i in range(3):\n            file = File.objects.create(\n                hash=f\"resolve{i}\" + \"a\" * 25,\n                path=f\"/photos/img_{i}.jpg\",\n                type=File.IMAGE,\n            )\n            photo = Photo.objects.create(\n                owner=self.user,\n                main_file=file,\n                image_hash=f\"resolve{i}\" + \"b\" * 25,\n                added_on=timezone.now(),\n                in_trashcan=False,\n            )\n            self.photos.append(photo)\n        \n        # Create stack with photos\n        self.stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        for photo in self.photos:\n            photo.stacks.add(self.stack)\n        \n        # Create review directly (since create_for_stack returns None)\n        self.review = StackReview.objects.create(\n            stack=self.stack,\n            reviewer=self.user,\n        )\n\n    def test_resolve_sets_kept_photo(self):\n        \"\"\"Should set the kept_photo field.\"\"\"\n        kept = self.photos[0]\n        self.review.resolve(kept)\n        \n        self.review.refresh_from_db()\n        self.assertEqual(self.review.kept_photo, kept)\n\n    def test_resolve_sets_decision_to_resolved(self):\n        \"\"\"Should change decision to RESOLVED.\"\"\"\n        self.review.resolve(self.photos[0])\n        \n        self.review.refresh_from_db()\n        self.assertEqual(self.review.decision, StackReview.Decision.RESOLVED)\n\n    def test_resolve_sets_reviewed_at(self):\n        \"\"\"Should set reviewed_at timestamp.\"\"\"\n        self.assertIsNone(self.review.reviewed_at)\n        \n        self.review.resolve(self.photos[0])\n        \n        self.review.refresh_from_db()\n        self.assertIsNotNone(self.review.reviewed_at)\n\n    def test_resolve_sets_stack_primary_photo(self):\n        \"\"\"Should update stack's primary_photo.\"\"\"\n        kept = self.photos[0]\n        self.review.resolve(kept)\n        \n        self.stack.refresh_from_db()\n        self.assertEqual(self.stack.primary_photo, kept)\n\n    def test_resolve_trashes_other_photos(self):\n        \"\"\"Should move non-kept photos to trashcan.\"\"\"\n        kept = self.photos[0]\n        self.review.resolve(kept, trash_others=True)\n        \n        for photo in self.photos:\n            photo.refresh_from_db()\n        \n        self.assertFalse(self.photos[0].in_trashcan)\n        self.assertTrue(self.photos[1].in_trashcan)\n        self.assertTrue(self.photos[2].in_trashcan)\n\n    def test_resolve_sets_trashed_count(self):\n        \"\"\"Should count trashed photos.\"\"\"\n        self.review.resolve(self.photos[0], trash_others=True)\n        \n        self.review.refresh_from_db()\n        self.assertEqual(self.review.trashed_count, 2)\n\n    def test_resolve_without_trashing(self):\n        \"\"\"Should not trash photos when trash_others=False.\"\"\"\n        self.review.resolve(self.photos[0], trash_others=False)\n        \n        for photo in self.photos:\n            photo.refresh_from_db()\n            self.assertFalse(photo.in_trashcan)\n\n    def test_resolve_returns_self(self):\n        \"\"\"Should return the review instance.\"\"\"\n        result = self.review.resolve(self.photos[0])\n        self.assertEqual(result, self.review)\n\n\nclass DismissTestCase(TestCase):\n    \"\"\"Tests for the dismiss method.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user, photos, and stack.\"\"\"\n        self.user = User.objects.create(username=\"dismisstest\")\n        \n        # Create photos\n        self.photos = []\n        for i in range(2):\n            file = File.objects.create(\n                hash=f\"dismiss{i}\" + \"a\" * 26,\n                path=f\"/photos/dismiss_{i}.jpg\",\n                type=File.IMAGE,\n            )\n            photo = Photo.objects.create(\n                owner=self.user,\n                main_file=file,\n                image_hash=f\"dismiss{i}\" + \"b\" * 26,\n                added_on=timezone.now(),\n            )\n            self.photos.append(photo)\n        \n        # Create stack\n        self.stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        for photo in self.photos:\n            photo.stacks.add(self.stack)\n        \n        # Create review directly\n        self.review = StackReview.objects.create(\n            stack=self.stack,\n            reviewer=self.user,\n        )\n\n    def test_dismiss_sets_decision_to_dismissed(self):\n        \"\"\"Should change decision to DISMISSED.\"\"\"\n        self.review.dismiss()\n        \n        self.review.refresh_from_db()\n        self.assertEqual(self.review.decision, StackReview.Decision.DISMISSED)\n\n    def test_dismiss_sets_reviewed_at(self):\n        \"\"\"Should set reviewed_at timestamp.\"\"\"\n        self.review.dismiss()\n        \n        self.review.refresh_from_db()\n        self.assertIsNotNone(self.review.reviewed_at)\n\n    def test_dismiss_unlinks_photos_from_stack(self):\n        \"\"\"Should remove photos from the stack.\"\"\"\n        # Verify photos are in stack\n        self.assertEqual(self.stack.photos.count(), 2)\n        \n        self.review.dismiss()\n        \n        # Verify photos are unlinked\n        self.stack.refresh_from_db()\n        self.assertEqual(self.stack.photos.count(), 0)\n\n    def test_dismiss_returns_self(self):\n        \"\"\"Should return the review instance.\"\"\"\n        result = self.review.dismiss()\n        self.assertEqual(result, self.review)\n\n    def test_dismiss_photos_still_exist(self):\n        \"\"\"Photos should not be deleted, just unlinked.\"\"\"\n        photo_ids = [p.id for p in self.photos]\n        \n        self.review.dismiss()\n        \n        for pid in photo_ids:\n            self.assertTrue(Photo.objects.filter(id=pid).exists())\n\n\nclass RevertTestCase(TestCase):\n    \"\"\"Tests for the revert method.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user, photos, and resolved review.\"\"\"\n        self.user = User.objects.create(username=\"reverttest\")\n        \n        # Create photos\n        self.photos = []\n        for i in range(3):\n            file = File.objects.create(\n                hash=f\"revert{i}\" + \"a\" * 26,\n                path=f\"/photos/revert_{i}.jpg\",\n                type=File.IMAGE,\n            )\n            photo = Photo.objects.create(\n                owner=self.user,\n                main_file=file,\n                image_hash=f\"revert{i}\" + \"b\" * 26,\n                added_on=timezone.now(),\n                in_trashcan=False,\n            )\n            self.photos.append(photo)\n        \n        # Create stack\n        self.stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        for photo in self.photos:\n            photo.stacks.add(self.stack)\n        \n        # Create and resolve review\n        self.review = StackReview.objects.create(\n            stack=self.stack,\n            reviewer=self.user,\n        )\n        self.review.resolve(self.photos[0], trash_others=True)\n\n    def test_revert_restores_trashed_photos(self):\n        \"\"\"Should restore photos from trashcan.\"\"\"\n        # Verify photos are trashed\n        self.photos[1].refresh_from_db()\n        self.photos[2].refresh_from_db()\n        self.assertTrue(self.photos[1].in_trashcan)\n        self.assertTrue(self.photos[2].in_trashcan)\n        \n        self.review.revert()\n        \n        # Verify photos are restored\n        self.photos[1].refresh_from_db()\n        self.photos[2].refresh_from_db()\n        self.assertFalse(self.photos[1].in_trashcan)\n        self.assertFalse(self.photos[2].in_trashcan)\n\n    def test_revert_resets_decision_to_pending(self):\n        \"\"\"Should change decision back to PENDING.\"\"\"\n        self.review.revert()\n        \n        self.review.refresh_from_db()\n        self.assertEqual(self.review.decision, StackReview.Decision.PENDING)\n\n    def test_revert_clears_kept_photo(self):\n        \"\"\"Should clear the kept_photo field.\"\"\"\n        self.review.revert()\n        \n        self.review.refresh_from_db()\n        self.assertIsNone(self.review.kept_photo)\n\n    def test_revert_clears_trashed_count(self):\n        \"\"\"Should reset trashed_count to 0.\"\"\"\n        self.review.revert()\n        \n        self.review.refresh_from_db()\n        self.assertEqual(self.review.trashed_count, 0)\n\n    def test_revert_clears_reviewed_at(self):\n        \"\"\"Should clear reviewed_at timestamp.\"\"\"\n        self.review.revert()\n        \n        self.review.refresh_from_db()\n        self.assertIsNone(self.review.reviewed_at)\n\n    def test_revert_clears_stack_primary_photo(self):\n        \"\"\"Should clear stack's primary_photo.\"\"\"\n        self.review.revert()\n        \n        self.stack.refresh_from_db()\n        self.assertIsNone(self.stack.primary_photo)\n\n    def test_revert_returns_restored_count(self):\n        \"\"\"Should return number of restored photos.\"\"\"\n        count = self.review.revert()\n        self.assertEqual(count, 2)\n\n    def test_revert_does_nothing_if_not_resolved(self):\n        \"\"\"Should do nothing if decision is not RESOLVED.\"\"\"\n        # Create pending review\n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        review2 = StackReview.objects.create(\n            stack=stack2,\n            reviewer=self.user,\n            decision=StackReview.Decision.PENDING,\n        )\n        \n        result = review2.revert()\n        \n        review2.refresh_from_db()\n        self.assertEqual(result, review2)  # Returns self, not count\n        self.assertEqual(review2.decision, StackReview.Decision.PENDING)\n\n    def test_revert_does_nothing_if_dismissed(self):\n        \"\"\"Should do nothing if decision is DISMISSED.\"\"\"\n        self.review.decision = StackReview.Decision.DISMISSED\n        self.review.save()\n        \n        result = self.review.revert()\n        \n        self.review.refresh_from_db()\n        self.assertEqual(result, self.review)  # Returns self\n        self.assertEqual(self.review.decision, StackReview.Decision.DISMISSED)\n\n\nclass EdgeCasesTestCase(TestCase):\n    \"\"\"Edge case tests for StackReview.\"\"\"\n\n    def setUp(self):\n        \"\"\"Create test user.\"\"\"\n        self.user = User.objects.create(username=\"edgetest\")\n\n    def test_resolve_with_single_photo_stack(self):\n        \"\"\"Should handle stack with only one photo.\"\"\"\n        file = File.objects.create(\n            hash=\"single\" + \"a\" * 26,\n            path=\"/photos/single.jpg\",\n            type=File.IMAGE,\n        )\n        photo = Photo.objects.create(\n            owner=self.user,\n            main_file=file,\n            image_hash=\"single\" + \"b\" * 26,\n            added_on=timezone.now(),\n        )\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        photo.stacks.add(stack)\n        \n        review = StackReview.objects.create(\n            stack=stack,\n            reviewer=self.user,\n        )\n        review.resolve(photo)\n        \n        review.refresh_from_db()\n        self.assertEqual(review.trashed_count, 0)\n        self.assertEqual(review.kept_photo, photo)\n\n    def test_dismiss_empty_stack(self):\n        \"\"\"Should handle stack with no photos.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        review = StackReview.objects.create(\n            stack=stack,\n            reviewer=self.user,\n        )\n        \n        # Should not raise error\n        review.dismiss()\n        \n        review.refresh_from_db()\n        self.assertEqual(review.decision, StackReview.Decision.DISMISSED)\n\n    def test_revert_when_photos_already_restored(self):\n        \"\"\"Should handle reverting when photos are already not in trash.\"\"\"\n        file = File.objects.create(\n            hash=\"restored\" + \"a\" * 24,\n            path=\"/photos/restored.jpg\",\n            type=File.IMAGE,\n        )\n        photo = Photo.objects.create(\n            owner=self.user,\n            main_file=file,\n            image_hash=\"restored\" + \"b\" * 24,\n            added_on=timezone.now(),\n            in_trashcan=False,\n        )\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        photo.stacks.add(stack)\n        \n        review = StackReview.objects.create(\n            stack=stack,\n            reviewer=self.user,\n            decision=StackReview.Decision.RESOLVED,\n            kept_photo=photo,\n            trashed_count=0,\n        )\n        \n        # Should not raise error, returns 0 restored\n        count = review.revert()\n        self.assertEqual(count, 0)\n\n    def test_stack_cascade_delete(self):\n        \"\"\"Review should be deleted when stack is deleted.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        review = StackReview.objects.create(\n            stack=stack,\n            reviewer=self.user,\n        )\n        review_id = review.id\n        \n        stack.delete()\n        \n        self.assertFalse(StackReview.objects.filter(id=review_id).exists())\n\n    def test_user_set_to_deleted_on_reviewer_delete(self):\n        \"\"\"Reviewer should be set to deleted user when user is deleted.\"\"\"\n        temp_user = User.objects.create(username=\"temp_reviewer\")\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        review = StackReview.objects.create(\n            stack=stack,\n            reviewer=temp_user,\n        )\n        \n        temp_user.delete()\n        \n        review.refresh_from_db()\n        self.assertEqual(review.reviewer.username, \"deleted\")\n\n    def test_review_with_note(self):\n        \"\"\"Should store user's note.\"\"\"\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        review = StackReview.objects.create(\n            stack=stack,\n            reviewer=self.user,\n            note=\"These are from different events, not duplicates\",\n        )\n        \n        review.refresh_from_db()\n        self.assertEqual(review.note, \"These are from different events, not duplicates\")\n\n    def test_review_index_on_reviewer_decision(self):\n        \"\"\"Index on reviewer+decision should exist for efficient queries.\"\"\"\n        # Create multiple reviews with different decisions\n        for decision in StackReview.Decision.values:\n            stack = PhotoStack.objects.create(\n                owner=self.user,\n                stack_type=PhotoStack.StackType.MANUAL,\n            )\n            StackReview.objects.create(\n                stack=stack,\n                reviewer=self.user,\n                decision=decision,\n            )\n        \n        # Query should be efficient (index used)\n        pending = StackReview.objects.filter(\n            reviewer=self.user,\n            decision=StackReview.Decision.PENDING,\n        )\n        self.assertEqual(pending.count(), 1)\n"
  },
  {
    "path": "api/tests/test_stack_validation_edge_cases.py",
    "content": "\"\"\"\nEdge case tests for Stack API input validation.\n\nTests cover:\n- Duplicate photo hashes handling\n- Input validation edge cases\n- Error message accuracy\n\"\"\"\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.models.photo_stack import PhotoStack\nfrom api.tests.utils import create_test_photo, create_test_user\n\n\nclass DuplicatePhotoHashesTestCase(TestCase):\n    \"\"\"Tests for handling duplicate photo hashes in input.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_create_manual_stack_with_duplicate_valid_hashes(self):\n        \"\"\"\n        Test creating manual stack when two valid photos are provided\n        but one hash is duplicated.\n        \n        Fixed Bug #15: If photo_hashes = [hash1, hash2, hash1], the input\n        is de-duplicated before validation. Since there are 2 unique valid\n        photos, the stack creation should succeed.\n        \"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        # Send duplicate hash1\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [photo1.image_hash, photo2.image_hash, photo1.image_hash]},\n            format='json',\n        )\n        \n        # After fix: Duplicates are de-duplicated, so 2 unique photos are found\n        # Stack creation should succeed\n        self.assertEqual(response.status_code, 201)\n        self.assertIn(\"stack_id\", response.data)\n        \n        # Verify stack was created with 2 photos\n        stack = PhotoStack.objects.get(id=response.data[\"stack_id\"])\n        self.assertEqual(stack.photos.count(), 2)\n\n    def test_create_manual_stack_triple_duplicate_same_hash(self):\n        \"\"\"Test with all three hashes being the same.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        \n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {\"photo_hashes\": [photo.image_hash, photo.image_hash, photo.image_hash]},\n            format='json',\n        )\n        \n        # Expected: Only 1 unique photo, should fail (need >= 2)\n        self.assertEqual(response.status_code, 400)\n\n    def test_add_photos_with_duplicate_hashes(self):\n        \"\"\"Test adding photos with duplicate hashes to existing stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        # Add photo3 with duplicate hash\n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/add\",\n            {\"photo_hashes\": [photo3.image_hash, photo3.image_hash]},\n            format='json',\n        )\n        \n        # Should work - duplicates should be ignored\n        self.assertEqual(response.status_code, 200)\n        stack.refresh_from_db()\n        self.assertEqual(stack.photos.count(), 3)\n\n    def test_remove_photos_with_duplicate_hashes(self):\n        \"\"\"Test removing photos with duplicate hashes.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(4)]\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*photos)\n        \n        # Remove with duplicate hash\n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/remove\",\n            {\"photo_hashes\": [photos[0].image_hash, photos[0].image_hash]},\n            format='json',\n        )\n        \n        # Should work - removes only once\n        self.assertEqual(response.status_code, 200)\n        stack.refresh_from_db()\n        self.assertEqual(stack.photos.count(), 3)\n\n\nclass MergeStacksDuplicateHashesTestCase(TestCase):\n    \"\"\"Tests for merge stacks with duplicate hashes.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_merge_with_duplicate_hashes(self):\n        \"\"\"Test merge endpoint with duplicate photo hashes.\n        \n        Fixed Bug #15: Duplicate hashes are now de-duplicated before validation.\n        \"\"\"\n        photos1 = [create_test_photo(owner=self.user) for _ in range(2)]\n        photos2 = [create_test_photo(owner=self.user) for _ in range(2)]\n        \n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack1.photos.add(*photos1)\n        \n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack2.photos.add(*photos2)\n        \n        # Include duplicate hashes\n        hashes = [photos1[0].image_hash, photos2[0].image_hash, photos1[0].image_hash]\n        \n        response = self.client.post(\n            \"/api/stacks/merge\",\n            {\"photo_hashes\": hashes},\n            format='json',\n        )\n        \n        # After fix: Duplicates are de-duplicated, merge should succeed\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"stack_id\", response.data)\n        \n        # Should have merged into one stack with all 4 photos\n        stacks = PhotoStack.objects.filter(owner=self.user, stack_type=PhotoStack.StackType.MANUAL)\n        self.assertEqual(stacks.count(), 1)\n        self.assertEqual(stacks.first().photos.count(), 4)\n\n\nclass ListStacksWithNullThumbnailTestCase(TestCase):\n    \"\"\"Tests for stack list with photos that have no thumbnails.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_list_stacks_without_thumbnails(self):\n        \"\"\"Test listing stacks when photos have thumbnails (created by helper).\"\"\"\n        # Note: create_test_photo creates thumbnails, so we test that listing works\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            primary_photo=photo1,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        # List should work\n        response = self.client.get(\"/api/stacks\")\n        \n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"count\"], 1)\n        \n        # thumbnail_url should be present (create_test_photo creates thumbnails)\n        result = response.data[\"results\"][0]\n        # Primary photo thumbnail should exist since create_test_photo creates them\n        if result.get(\"primary_photo\"):\n            # Just verify the structure is valid - may or may not have thumbnail\n            self.assertIn(\"thumbnail_url\", result[\"primary_photo\"])\n\n    def test_detail_stack_without_thumbnails(self):\n        \"\"\"Test getting stack detail when photos don't have thumbnails.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            primary_photo=photo1,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.get(f\"/api/stacks/{stack.id}\")\n        \n        self.assertEqual(response.status_code, 200)\n        # Should have photos even without thumbnails\n        self.assertEqual(len(response.data[\"photos\"]), 2)\n\n\nclass RemoveFromStackPrimaryPhotoTestCase(TestCase):\n    \"\"\"Tests for removing the primary photo from a stack.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_remove_primary_updates_primary(self):\n        \"\"\"Test that removing the primary photo selects a new primary.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            primary_photo=photo1,\n        )\n        stack.photos.add(photo1, photo2, photo3)\n        \n        # Remove the primary photo\n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/remove\",\n            {\"photo_hashes\": [photo1.image_hash]},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        stack.refresh_from_db()\n        \n        # Primary should have changed\n        self.assertIsNotNone(stack.primary_photo)\n        self.assertNotEqual(stack.primary_photo.image_hash, photo1.image_hash)\n\n    def test_remove_non_primary_keeps_primary(self):\n        \"\"\"Test that removing a non-primary photo keeps the current primary.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        photo3 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            primary_photo=photo1,\n        )\n        stack.photos.add(photo1, photo2, photo3)\n        \n        # Remove a non-primary photo\n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/remove\",\n            {\"photo_hashes\": [photo2.image_hash]},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 200)\n        stack.refresh_from_db()\n        \n        # Primary should stay the same\n        self.assertEqual(stack.primary_photo.image_hash, photo1.image_hash)\n\n\nclass EmptyInputTestCase(TestCase):\n    \"\"\"Tests for empty or null input handling.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_set_primary_no_hash(self):\n        \"\"\"Test setting primary with no photo_hash provided.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/primary\",\n            {},  # No photo_hash\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 400)\n        self.assertEqual(response.data[\"error\"], \"photo_hash is required\")\n\n    def test_set_primary_empty_hash(self):\n        \"\"\"Test setting primary with empty photo_hash.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/primary\",\n            {\"photo_hash\": \"\"},  # Empty hash\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 400)\n\n    def test_add_empty_photo_list(self):\n        \"\"\"Test adding empty photo list to stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/add\",\n            {\"photo_hashes\": []},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 400)\n\n    def test_remove_empty_photo_list(self):\n        \"\"\"Test removing empty photo list from stack.\"\"\"\n        photo1 = create_test_photo(owner=self.user)\n        photo2 = create_test_photo(owner=self.user)\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo1, photo2)\n        \n        response = self.client.post(\n            f\"/api/stacks/{stack.id}/remove\",\n            {\"photo_hashes\": []},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 400)\n\n    def test_merge_empty_photo_list(self):\n        \"\"\"Test merge with empty photo list.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/merge\",\n            {\"photo_hashes\": []},\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 400)\n\n    def test_create_manual_missing_photo_hashes_key(self):\n        \"\"\"Test creating manual stack without photo_hashes key.\"\"\"\n        response = self.client.post(\n            \"/api/stacks/manual\",\n            {},  # No photo_hashes key\n            format='json',\n        )\n        \n        self.assertEqual(response.status_code, 400)\n\n\nclass StackListPaginationEdgeCasesTestCase(TestCase):\n    \"\"\"Tests for pagination edge cases in stack list.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n        \n        # Create 5 stacks\n        for i in range(5):\n            photos = [create_test_photo(owner=self.user) for _ in range(2)]\n            stack = PhotoStack.objects.create(\n                owner=self.user,\n                stack_type=PhotoStack.StackType.MANUAL,\n            )\n            stack.photos.add(*photos)\n\n    def test_page_beyond_results(self):\n        \"\"\"Test requesting a page beyond available results.\"\"\"\n        response = self.client.get(\"/api/stacks?page=100\")\n        \n        self.assertEqual(response.status_code, 200)\n        # Django's Paginator.get_page() returns last page for out-of-range pages\n        # So we may get results (last page) rather than empty\n        self.assertGreaterEqual(len(response.data[\"results\"]), 0)\n        # has_next should be False since we're at/past the last page\n        self.assertFalse(response.data[\"has_next\"])\n\n    def test_page_zero(self):\n        \"\"\"Test requesting page 0 (should default to 1).\"\"\"\n        response = self.client.get(\"/api/stacks?page=0\")\n        \n        self.assertEqual(response.status_code, 200)\n        # Should get results (treated as page 1)\n        self.assertGreater(len(response.data[\"results\"]), 0)\n\n    def test_negative_page(self):\n        \"\"\"Test requesting negative page number.\"\"\"\n        response = self.client.get(\"/api/stacks?page=-1\")\n        \n        self.assertEqual(response.status_code, 200)\n        # Should get results (negative treated as 1)\n        self.assertGreater(len(response.data[\"results\"]), 0)\n\n    def test_non_numeric_page(self):\n        \"\"\"Test requesting non-numeric page.\"\"\"\n        response = self.client.get(\"/api/stacks?page=abc\")\n        \n        # After Bug #16 fix: Non-numeric page defaults to 1\n        self.assertEqual(response.status_code, 200)\n        self.assertGreater(len(response.data[\"results\"]), 0)\n\n    def test_page_size_zero(self):\n        \"\"\"Test requesting page_size of 0.\"\"\"\n        response = self.client.get(\"/api/stacks?page_size=0\")\n        \n        self.assertEqual(response.status_code, 200)\n        # page_size=0 should be treated as page_size=1 (min)\n        self.assertGreater(len(response.data[\"results\"]), 0)\n\n    def test_page_size_negative(self):\n        \"\"\"Test requesting negative page_size.\"\"\"\n        response = self.client.get(\"/api/stacks?page_size=-5\")\n        \n        # Should handle gracefully (Bug #10 fix)\n        self.assertEqual(response.status_code, 200)\n\n    def test_page_size_exceeds_max(self):\n        \"\"\"Test requesting page_size exceeding maximum.\"\"\"\n        response = self.client.get(\"/api/stacks?page_size=1000\")\n        \n        self.assertEqual(response.status_code, 200)\n        # page_size should be capped at 100\n        self.assertLessEqual(response.data[\"page_size\"], 100)\n\n    def test_non_numeric_page_size(self):\n        \"\"\"Test requesting non-numeric page_size.\"\"\"\n        response = self.client.get(\"/api/stacks?page_size=abc\")\n        \n        # After Bug #16 fix: Non-numeric page_size defaults to 20\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"page_size\"], 20)\n"
  },
  {
    "path": "api/tests/test_stats_accuracy.py",
    "content": "\"\"\"\nTests for stats accuracy and edge cases.\n\nEnsures that:\n- Duplicate stats are calculated correctly\n- Stack stats are calculated correctly\n- Edge cases (empty, single items, etc.) are handled\n- Stats are properly scoped to user\n- Potential savings calculations are accurate\n\"\"\"\n\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient, APITestCase\n\nfrom api.models import Photo\nfrom api.models.duplicate import Duplicate\nfrom api.models.photo_stack import PhotoStack\nfrom api.tests.utils import create_test_photo, create_test_user\nfrom api.stats import get_count_stats, calc_megabytes, median_value\n\n\nclass DuplicateStatsAccuracyTestCase(APITestCase):\n    \"\"\"Test duplicate stats calculations.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_empty_stats(self):\n        \"\"\"Test stats when no duplicates exist.\"\"\"\n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"total_duplicates\"], 0)\n        self.assertEqual(response.data[\"pending_duplicates\"], 0)\n        self.assertEqual(response.data[\"resolved_duplicates\"], 0)\n        self.assertEqual(response.data[\"dismissed_duplicates\"], 0)\n\n    def test_stats_by_type(self):\n        \"\"\"Test stats count by duplicate type.\"\"\"\n        # Create exact copies\n        for _ in range(3):\n            photos = [create_test_photo(owner=self.user) for _ in range(2)]\n            dup = Duplicate.objects.create(\n                owner=self.user,\n                duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            )\n            dup.photos.add(*photos)\n        \n        # Create visual duplicates\n        for _ in range(2):\n            photos = [create_test_photo(owner=self.user) for _ in range(2)]\n            dup = Duplicate.objects.create(\n                owner=self.user,\n                duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,\n            )\n            dup.photos.add(*photos)\n        \n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"by_type\"][\"exact_copy\"], 3)\n        self.assertEqual(response.data[\"by_type\"][\"visual_duplicate\"], 2)\n\n    def test_stats_by_status(self):\n        \"\"\"Test stats count by review status.\"\"\"\n        # Create pending\n        for _ in range(4):\n            photos = [create_test_photo(owner=self.user) for _ in range(2)]\n            dup = Duplicate.objects.create(\n                owner=self.user,\n                duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n                review_status=Duplicate.ReviewStatus.PENDING,\n            )\n            dup.photos.add(*photos)\n        \n        # Create resolved\n        for _ in range(2):\n            photos = [create_test_photo(owner=self.user) for _ in range(2)]\n            dup = Duplicate.objects.create(\n                owner=self.user,\n                duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n                review_status=Duplicate.ReviewStatus.RESOLVED,\n            )\n            dup.photos.add(*photos)\n        \n        # Create dismissed\n        photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.DISMISSED,\n        )\n        dup.photos.add(*photos)\n        \n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"pending_duplicates\"], 4)\n        self.assertEqual(response.data[\"resolved_duplicates\"], 2)\n        self.assertEqual(response.data[\"dismissed_duplicates\"], 1)\n\n    def test_photos_in_duplicates_count(self):\n        \"\"\"Test count of photos involved in duplicates.\"\"\"\n        # Create 3 duplicate groups, each with 2 photos = 6 photos total\n        for _ in range(3):\n            photos = [create_test_photo(owner=self.user) for _ in range(2)]\n            dup = Duplicate.objects.create(\n                owner=self.user,\n                duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            )\n            dup.photos.add(*photos)\n        \n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"photos_in_duplicates\"], 6)\n\n    def test_potential_savings_calculation(self):\n        \"\"\"Test that potential savings is calculated from pending duplicates only.\"\"\"\n        # Create pending duplicate with known savings\n        photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        pending_dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.PENDING,\n            potential_savings=1000000,  # 1MB\n        )\n        pending_dup.photos.add(*photos)\n        \n        # Create resolved duplicate - should not count\n        photos2 = [create_test_photo(owner=self.user) for _ in range(2)]\n        resolved_dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            review_status=Duplicate.ReviewStatus.RESOLVED,\n            potential_savings=5000000,  # 5MB - should be ignored\n        )\n        resolved_dup.photos.add(*photos2)\n        \n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, 200)\n        # Only pending savings should be counted\n        self.assertEqual(response.data[\"potential_savings_bytes\"], 1000000)\n\n    def test_stats_user_scoped(self):\n        \"\"\"Test that stats only include current user's duplicates.\"\"\"\n        other_user = create_test_user()\n        \n        # Create duplicate for other user\n        other_photos = [create_test_photo(owner=other_user) for _ in range(2)]\n        other_dup = Duplicate.objects.create(\n            owner=other_user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        other_dup.photos.add(*other_photos)\n        \n        # Create duplicate for current user\n        my_photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        my_dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        my_dup.photos.add(*my_photos)\n        \n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, 200)\n        # Should only see our duplicate\n        self.assertEqual(response.data[\"total_duplicates\"], 1)\n\n\nclass StackStatsAccuracyTestCase(APITestCase):\n    \"\"\"Test stack stats calculations.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_empty_stats(self):\n        \"\"\"Test stats when no stacks exist.\"\"\"\n        response = self.client.get(\"/api/stacks/stats\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"total_stacks\"], 0)\n        self.assertEqual(response.data[\"photos_in_stacks\"], 0)\n\n    def test_stats_by_type(self):\n        \"\"\"Test stats count by stack type.\"\"\"\n        # Create RAW+JPEG pairs\n        for _ in range(2):\n            photos = [create_test_photo(owner=self.user) for _ in range(2)]\n            stack = PhotoStack.objects.create(\n                owner=self.user,\n                stack_type=PhotoStack.StackType.RAW_JPEG_PAIR,\n            )\n            stack.photos.add(*photos)\n        \n        # Create burst sequences\n        for _ in range(3):\n            photos = [create_test_photo(owner=self.user) for _ in range(3)]\n            stack = PhotoStack.objects.create(\n                owner=self.user,\n                stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n            )\n            stack.photos.add(*photos)\n        \n        # Create manual stack\n        photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*photos)\n        \n        response = self.client.get(\"/api/stacks/stats\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"by_type\"][\"raw_jpeg\"], 2)\n        self.assertEqual(response.data[\"by_type\"][\"burst\"], 3)\n        self.assertEqual(response.data[\"by_type\"][\"manual\"], 1)\n\n    def test_photos_in_stacks_count(self):\n        \"\"\"Test count of photos involved in stacks.\"\"\"\n        # 2 RAW+JPEG pairs (2 photos each) = 4 photos\n        for _ in range(2):\n            photos = [create_test_photo(owner=self.user) for _ in range(2)]\n            stack = PhotoStack.objects.create(\n                owner=self.user,\n                stack_type=PhotoStack.StackType.RAW_JPEG_PAIR,\n            )\n            stack.photos.add(*photos)\n        \n        # 1 burst (3 photos)\n        photos = [create_test_photo(owner=self.user) for _ in range(3)]\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.BURST_SEQUENCE,\n        )\n        stack.photos.add(*photos)\n        \n        response = self.client.get(\"/api/stacks/stats\")\n        self.assertEqual(response.status_code, 200)\n        # Total: 4 + 3 = 7 photos\n        self.assertEqual(response.data[\"photos_in_stacks\"], 7)\n\n    def test_photo_in_multiple_stacks_counted_once(self):\n        \"\"\"Test that a photo in multiple stacks is counted only once.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(3)]\n        \n        # Add first two photos to one stack\n        stack1 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack1.photos.add(photos[0], photos[1])\n        \n        # Add last two photos to another stack (photos[1] is in both)\n        stack2 = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack2.photos.add(photos[1], photos[2])\n        \n        response = self.client.get(\"/api/stacks/stats\")\n        self.assertEqual(response.status_code, 200)\n        # Should be 3 distinct photos, not 4\n        self.assertEqual(response.data[\"photos_in_stacks\"], 3)\n\n    def test_stats_user_scoped(self):\n        \"\"\"Test that stats only include current user's stacks.\"\"\"\n        other_user = create_test_user()\n        \n        # Create stack for other user\n        other_photos = [create_test_photo(owner=other_user) for _ in range(2)]\n        other_stack = PhotoStack.objects.create(\n            owner=other_user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        other_stack.photos.add(*other_photos)\n        \n        # Create stack for current user\n        my_photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        my_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        my_stack.photos.add(*my_photos)\n        \n        response = self.client.get(\"/api/stacks/stats\")\n        self.assertEqual(response.status_code, 200)\n        # Should only see our stack\n        self.assertEqual(response.data[\"total_stacks\"], 1)\n\n    def test_excludes_duplicate_type_stacks(self):\n        \"\"\"Test that old duplicate-type stacks are excluded from stats.\"\"\"\n        # Create a valid stack\n        photos = [create_test_photo(owner=self.user) for _ in range(2)]\n        valid_stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        valid_stack.photos.add(*photos)\n        \n        # Create an old duplicate-type stack (should be excluded)\n        # Note: These types may not exist anymore, but test the filtering\n        \n        response = self.client.get(\"/api/stacks/stats\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"total_stacks\"], 1)\n\n\nclass UtilityFunctionsTestCase(TestCase):\n    \"\"\"Test utility functions in stats module.\"\"\"\n\n    def test_calc_megabytes_zero(self):\n        \"\"\"Test megabyte calculation with zero bytes.\"\"\"\n        self.assertEqual(calc_megabytes(0), 0)\n\n    def test_calc_megabytes_none(self):\n        \"\"\"Test megabyte calculation with None.\"\"\"\n        self.assertEqual(calc_megabytes(None), 0)\n\n    def test_calc_megabytes_small(self):\n        \"\"\"Test megabyte calculation with small values.\"\"\"\n        # 1 MB = 1048576 bytes\n        self.assertEqual(calc_megabytes(1048576), 1)\n\n    def test_calc_megabytes_large(self):\n        \"\"\"Test megabyte calculation with large values.\"\"\"\n        # 100 MB\n        self.assertEqual(calc_megabytes(104857600), 100)\n\n    def test_median_value_empty_queryset(self):\n        \"\"\"Test median with empty queryset.\"\"\"\n        qs = Photo.objects.none()\n        result = median_value(qs, \"size\")\n        self.assertIsNone(result)\n\n\nclass CountStatsTestCase(TestCase):\n    \"\"\"Test get_count_stats function.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_count_stats_no_photos(self):\n        \"\"\"Test count stats when user has no photos.\"\"\"\n        stats = get_count_stats(self.user)\n        self.assertEqual(stats[\"num_photos\"], 0)\n\n    def test_count_stats_with_photos(self):\n        \"\"\"Test count stats with photos.\"\"\"\n        # Create some photos\n        for _ in range(5):\n            create_test_photo(owner=self.user)\n        \n        stats = get_count_stats(self.user)\n        self.assertEqual(stats[\"num_photos\"], 5)\n\n    def test_count_stats_excludes_hidden(self):\n        \"\"\"Test that hidden photos are excluded from count.\"\"\"\n        # Create visible photos\n        for _ in range(3):\n            create_test_photo(owner=self.user)\n        \n        # Create hidden photo\n        hidden = create_test_photo(owner=self.user)\n        hidden.hidden = True\n        hidden.save()\n        \n        _stats = get_count_stats(self.user)\n        # Depending on implementation, hidden may or may not be counted\n        # The important thing is no crash\n\n    def test_count_stats_user_scoped(self):\n        \"\"\"Test that count stats are user-scoped.\"\"\"\n        other_user = create_test_user()\n        \n        # Create photos for other user\n        for _ in range(10):\n            create_test_photo(owner=other_user)\n        \n        # Create photos for current user\n        for _ in range(3):\n            create_test_photo(owner=self.user)\n        \n        stats = get_count_stats(self.user)\n        self.assertEqual(stats[\"num_photos\"], 3)\n\n\nclass StatsEdgeCasesTestCase(APITestCase):\n    \"\"\"Test edge cases for stats calculations.\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        self.client = APIClient()\n        self.client.force_authenticate(user=self.user)\n\n    def test_duplicate_with_zero_photos(self):\n        \"\"\"Test stats with duplicate group that has no photos.\"\"\"\n        # Create empty duplicate group\n        _dup = Duplicate.objects.create(\n            owner=self.user,\n            duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n        )\n        # Don't add any photos\n        \n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, 200)\n        # Should handle gracefully\n\n    def test_stack_with_single_photo(self):\n        \"\"\"Test stats with stack that has only one photo.\"\"\"\n        photo = create_test_photo(owner=self.user)\n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(photo)\n        \n        response = self.client.get(\"/api/stacks/stats\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"total_stacks\"], 1)\n        self.assertEqual(response.data[\"photos_in_stacks\"], 1)\n\n    def test_deleted_photo_in_group(self):\n        \"\"\"Test stats when photo has been deleted from group.\"\"\"\n        photos = [create_test_photo(owner=self.user) for _ in range(3)]\n        \n        stack = PhotoStack.objects.create(\n            owner=self.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n        )\n        stack.photos.add(*photos)\n        \n        # Delete one photo from stack\n        stack.photos.remove(photos[0])\n        \n        response = self.client.get(\"/api/stacks/stats\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"photos_in_stacks\"], 2)\n\n    def test_trashed_photos_excluded(self):\n        \"\"\"Test that trashed photos are excluded from total count.\"\"\"\n        # Create normal photos\n        for _ in range(3):\n            create_test_photo(owner=self.user)\n        \n        # Create trashed photo\n        trashed = create_test_photo(owner=self.user)\n        trashed.in_trashcan = True\n        trashed.save()\n        \n        response = self.client.get(\"/api/stacks/stats\")\n        self.assertEqual(response.status_code, 200)\n        # total_photos should not include trashed\n        self.assertEqual(response.data[\"total_photos\"], 3)\n\n    def test_large_number_of_groups(self):\n        \"\"\"Test stats with many groups.\"\"\"\n        # Create 50 duplicate groups\n        for _ in range(50):\n            photos = [create_test_photo(owner=self.user) for _ in range(2)]\n            dup = Duplicate.objects.create(\n                owner=self.user,\n                duplicate_type=Duplicate.DuplicateType.EXACT_COPY,\n            )\n            dup.photos.add(*photos)\n        \n        response = self.client.get(\"/api/duplicates/stats\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response.data[\"total_duplicates\"], 50)\n        self.assertEqual(response.data[\"photos_in_duplicates\"], 100)\n"
  },
  {
    "path": "api/tests/test_thumbnail_migration.py",
    "content": "\"\"\"\nTests for the thumbnail migration (0120_rename_thumbnails_uuid_to_hash)\n\"\"\"\n\nimport os\nimport tempfile\nfrom unittest.mock import patch\nfrom django.test import TestCase\nfrom django.conf import settings\n\nfrom api.models import Photo, Thumbnail\nfrom api.tests.utils import create_test_user, create_test_photo\n\n\nclass ThumbnailMigrationTest(TestCase):\n    \"\"\"Test the thumbnail UUID to hash migration\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n        # Create a temporary directory for test thumbnails\n        self.test_media_root = tempfile.mkdtemp()\n\n    def tearDown(self):\n        # Clean up temporary files\n        import shutil\n\n        if os.path.exists(self.test_media_root):\n            shutil.rmtree(self.test_media_root)\n\n    def test_batch_processing_logic(self):\n        \"\"\"Test that batch processing correctly handles multiple photos\"\"\"\n        # Create test photos with thumbnails\n        photos = []\n        for i in range(5):\n            photo = create_test_photo(owner=self.user)\n            photos.append(photo)\n\n        # Verify all photos have thumbnails\n        self.assertEqual(Thumbnail.objects.count(), 5)\n\n        # Verify thumbnails use image_hash in their paths\n        for photo in photos:\n            thumbnail = photo.thumbnail\n            self.assertIn(photo.image_hash, thumbnail.thumbnail_big.name)\n            self.assertIn(photo.image_hash, thumbnail.square_thumbnail.name)\n            self.assertIn(photo.image_hash, thumbnail.square_thumbnail_small.name)\n\n    @patch(\"django.conf.settings.MEDIA_ROOT\")\n    def test_file_renaming_with_mocked_filesystem(self, mock_media_root):\n        \"\"\"Test that the migration logic handles file renaming correctly\"\"\"\n        # Set the mock to return our test directory\n        test_dir = self.test_media_root\n        mock_media_root.return_value = test_dir\n        mock_media_root.__str__ = lambda _: test_dir\n\n        # Create test photo\n        photo = create_test_photo(owner=self.user)\n        photo_uuid = str(photo.id)\n        photo_hash = photo.image_hash\n\n        # Create dummy thumbnail directories\n        for thumb_dir in [\n            \"thumbnails_big\",\n            \"square_thumbnails\",\n            \"square_thumbnails_small\",\n        ]:\n            os.makedirs(os.path.join(self.test_media_root, thumb_dir), exist_ok=True)\n\n        # Create dummy \"old\" thumbnail files with UUID names\n        old_files = []\n        for thumb_dir in [\n            \"thumbnails_big\",\n            \"square_thumbnails\",\n            \"square_thumbnails_small\",\n        ]:\n            old_file = os.path.join(\n                self.test_media_root, thumb_dir, f\"{photo_uuid}.webp\"\n            )\n            with open(old_file, \"w\") as f:\n                f.write(\"dummy thumbnail\")\n            old_files.append(old_file)\n            self.assertTrue(os.path.exists(old_file))\n\n        # Simulate the migration logic (simplified version)\n        for thumb_dir in [\n            \"thumbnails_big\",\n            \"square_thumbnails\",\n            \"square_thumbnails_small\",\n        ]:\n            old_path = os.path.join(\n                self.test_media_root, thumb_dir, f\"{photo_uuid}.webp\"\n            )\n            new_path = os.path.join(\n                self.test_media_root, thumb_dir, f\"{photo_hash}.webp\"\n            )\n\n            if os.path.exists(old_path) and not os.path.exists(new_path):\n                os.rename(old_path, new_path)\n\n        # Verify files were renamed\n        for thumb_dir in [\n            \"thumbnails_big\",\n            \"square_thumbnails\",\n            \"square_thumbnails_small\",\n        ]:\n            old_path = os.path.join(\n                self.test_media_root, thumb_dir, f\"{photo_uuid}.webp\"\n            )\n            new_path = os.path.join(\n                self.test_media_root, thumb_dir, f\"{photo_hash}.webp\"\n            )\n\n            self.assertFalse(\n                os.path.exists(old_path), f\"Old file should not exist: {old_path}\"\n            )\n            self.assertTrue(\n                os.path.exists(new_path), f\"New file should exist: {new_path}\"\n            )\n\n    def test_bulk_update_performance(self):\n        \"\"\"Test that bulk_update is more efficient than individual saves\"\"\"\n        # This test verifies the concept - actual migration uses bulk_update\n        photos = []\n        thumbnails = []\n\n        for i in range(10):\n            photo = create_test_photo(owner=self.user)\n            photos.append(photo)\n            thumbnails.append(photo.thumbnail)\n\n        # Update thumbnails in bulk\n        for thumbnail in thumbnails:\n            thumbnail.thumbnail_big = f\"updated_{thumbnail.thumbnail_big}\"\n\n        # Use bulk_update (this is what the migration does)\n        Thumbnail.objects.bulk_update(thumbnails, [\"thumbnail_big\"], batch_size=1000)\n\n        # Verify updates were applied\n        for i, photo in enumerate(photos):\n            photo.thumbnail.refresh_from_db()\n            self.assertTrue(photo.thumbnail.thumbnail_big.name.startswith(\"updated_\"))\n"
  },
  {
    "path": "api/tests/test_thumbnail_naming.py",
    "content": "\"\"\"\nTests for thumbnail naming using image_hash instead of UUID.\n\"\"\"\nimport os\nfrom unittest import mock\nfrom django.test import TestCase\nfrom django.conf import settings\n\nfrom api.models import Photo, Thumbnail\nfrom api.tests.utils import create_test_user, create_test_photo\n\n\nclass ThumbnailNamingTest(TestCase):\n    \"\"\"Test that thumbnails are named using image_hash instead of UUID\"\"\"\n\n    def setUp(self):\n        self.user = create_test_user()\n\n    def test_thumbnail_uses_image_hash_not_uuid(self):\n        \"\"\"Verify that thumbnails use image_hash for file naming\"\"\"\n        # Create a photo with thumbnail\n        photo = create_test_photo(owner=self.user)\n        \n        # Verify photo has both UUID and image_hash\n        self.assertIsNotNone(photo.id)  # UUID\n        self.assertIsNotNone(photo.image_hash)  # Content hash\n        self.assertNotEqual(str(photo.id), photo.image_hash)  # They should be different\n        \n        # Check that thumbnail exists\n        self.assertTrue(hasattr(photo, 'thumbnail'))\n        thumbnail = photo.thumbnail\n        \n        # Verify thumbnail paths use image_hash, not UUID\n        self.assertIn(photo.image_hash, thumbnail.thumbnail_big.name)\n        self.assertIn(photo.image_hash, thumbnail.square_thumbnail.name)\n        self.assertIn(photo.image_hash, thumbnail.square_thumbnail_small.name)\n        \n        # Verify UUID is NOT in the thumbnail paths\n        self.assertNotIn(str(photo.id), thumbnail.thumbnail_big.name)\n        self.assertNotIn(str(photo.id), thumbnail.square_thumbnail.name)\n        self.assertNotIn(str(photo.id), thumbnail.square_thumbnail_small.name)\n\n    @mock.patch('api.models.thumbnail.create_thumbnail')\n    @mock.patch('api.models.thumbnail.does_static_thumbnail_exist')\n    def test_generate_thumbnail_uses_image_hash(self, mock_exists, mock_create):\n        \"\"\"Verify that _generate_thumbnail method uses image_hash\"\"\"\n        # Mock to indicate thumbnails don't exist yet\n        mock_exists.return_value = False\n        mock_create.return_value = '/tmp/test.webp'\n        \n        # Create a photo\n        photo = create_test_photo(owner=self.user)\n        thumbnail = photo.thumbnail\n        \n        # Call _generate_thumbnail\n        thumbnail._generate_thumbnail()\n        \n        # Verify create_thumbnail was called with image_hash, not UUID\n        calls = mock_create.call_args_list\n        for call in calls:\n            kwargs = call[1]\n            if 'hash' in kwargs:\n                # The hash parameter should be the image_hash\n                self.assertEqual(kwargs['hash'], photo.image_hash)\n                # Should NOT be the UUID\n                self.assertNotEqual(kwargs['hash'], str(photo.id))\n\n    def test_thumbnail_file_naming_convention(self):\n        \"\"\"Test that thumbnail file names follow the correct pattern\"\"\"\n        photo = create_test_photo(owner=self.user)\n        thumbnail = photo.thumbnail\n        \n        # Check naming patterns\n        expected_big = f\"thumbnails_big/{photo.image_hash}.webp\"\n        expected_square = f\"square_thumbnails/{photo.image_hash}.webp\"\n        expected_square_small = f\"square_thumbnails_small/{photo.image_hash}.webp\"\n        \n        self.assertEqual(thumbnail.thumbnail_big.name.strip(), expected_big)\n        self.assertEqual(thumbnail.square_thumbnail.name.strip(), expected_square)\n        self.assertEqual(thumbnail.square_thumbnail_small.name.strip(), expected_square_small)\n\n    def test_video_thumbnail_naming(self):\n        \"\"\"Test that video thumbnails use .mp4 extension with image_hash\"\"\"\n        photo = create_test_photo(owner=self.user, video=True)\n        thumbnail = photo.thumbnail\n        \n        # Video thumbnails should use .mp4 for square thumbnails\n        expected_big = f\"thumbnails_big/{photo.image_hash}.webp\"\n        expected_square = f\"square_thumbnails/{photo.image_hash}.mp4\"\n        expected_square_small = f\"square_thumbnails_small/{photo.image_hash}.mp4\"\n        \n        self.assertEqual(thumbnail.thumbnail_big.name.strip(), expected_big)\n        self.assertEqual(thumbnail.square_thumbnail.name.strip(), expected_square)\n        self.assertEqual(thumbnail.square_thumbnail_small.name.strip(), expected_square_small)\n"
  },
  {
    "path": "api/tests/test_trash_api.py",
    "content": "from django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.tests.utils import create_test_photos, create_test_user\n\n\nclass TrashAPITest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n\n    def test_trash_api_returns_deleted_images(self):\n        \"\"\"Test that the trash API returns albums containing deleted images\"\"\"\n\n        # Create some test photos\n        photos = create_test_photos(number_of_photos=3, owner=self.user)\n\n        # Move one photo to trash\n        photo_to_delete = photos[0]\n        photo_to_delete.in_trashcan = True\n        photo_to_delete.removed = False\n        photo_to_delete.save()\n\n        print(f\"Created test photo with hash: {photo_to_delete.image_hash}\")\n        print(f\"Photo in trashcan: {photo_to_delete.in_trashcan}\")\n\n        # Test the trash API endpoint\n        response = self.client.get(\"/api/albums/date/list/?in_trashcan=true\")\n\n        print(f\"API Response Status: {response.status_code}\")\n\n        # Check that the API responds successfully\n        self.assertEqual(response.status_code, 200)\n\n        data = response.json()\n\n        # Check that we get the expected response structure\n        self.assertIn(\"results\", data)\n\n        print(f\"Number of results: {len(data['results'])}\")\n\n        # Verify that we can call the API successfully\n        # (We might not have trashed albums with photos, but the API should work)\n        if data[\"results\"]:\n            print(\"✅ Got album results - trash API is working\")\n            # If we have results, check the structure\n            album = data[\"results\"][0]\n            self.assertIn(\"id\", album)\n            self.assertIn(\"date\", album)\n            self.assertIn(\"photo_count\", album)\n        else:\n            print(\n                \"ℹ️ Got empty results (no trashed albums with photos) - but API structure is correct\"\n            )\n\n        print(\"✅ Trash API test completed successfully\")\n\n    def test_trash_api_without_folder_parameter(self):\n        \"\"\"Test that the trash API works correctly when no folder parameter is provided\"\"\"\n\n        # Test the trash API endpoint without folder parameter\n        response = self.client.get(\"/api/albums/date/list/?in_trashcan=true\")\n\n        # Check that the API responds successfully\n        self.assertEqual(response.status_code, 200)\n\n        data = response.json()\n\n        # Check that we get the expected response structure\n        self.assertIn(\"results\", data)\n\n        print(\"✅ Trash API without folder parameter works correctly\")\n"
  },
  {
    "path": "api/tests/test_user.py",
    "content": "import logging\nfrom unittest.mock import patch\n\nfrom constance.test import override_config\nfrom django.core.management import call_command\nfrom django.test import TestCase\nfrom rest_framework.test import APIClient\n\nfrom api.models import User\nfrom api.tests.utils import create_test_user, create_user_details\n\nlogger = logging.getLogger(__name__)\n\n\ndef delete_all_users():\n    User.objects.all().delete()\n    # That's a weird one. When deleting all users, the user with username \"deleted\" is not deleted.\n    User.objects.filter(username=\"deleted\").delete()\n\n\nclass UserTest(TestCase):\n    public_user_properties = [\n        \"id\",\n        \"avatar_url\",\n        \"username\",\n        \"first_name\",\n        \"last_name\",\n        \"public_photo_count\",\n        \"public_photo_samples\",\n    ]\n\n    private_user_properties = [\n        \"id\",\n        \"username\",\n        \"email\",\n        \"scan_directory\",\n        \"confidence\",\n        \"confidence_person\",\n        \"transcode_videos\",\n        \"semantic_search_topk\",\n        \"first_name\",\n        \"public_photo_samples\",\n        \"last_name\",\n        \"public_photo_count\",\n        \"date_joined\",\n        \"avatar\",\n        \"is_superuser\",\n        \"photo_count\",\n        \"nextcloud_server_address\",\n        \"nextcloud_username\",\n        \"nextcloud_scan_directory\",\n        \"avatar_url\",\n        \"favorite_min_rating\",\n        \"image_scale\",\n        \"save_metadata_to_disk\",\n        \"datetime_rules\",\n        \"default_timezone\",\n        \"public_sharing\",\n        \"face_recognition_model\",\n        \"min_cluster_size\",\n        \"confidence_unknown_face\",\n        \"min_samples\",\n        \"burst_detection_rules\",\n        \"skip_raw_files\",\n        \"stack_raw_jpeg\",\n        \"slideshow_interval\",\n        \"duplicate_sensitivity\",\n        \"duplicate_clear_existing\",\n        \"cluster_selection_epsilon\",\n        \"llm_settings\",\n        \"text_alignment\",\n        \"header_size\",\n        \"save_face_tags_to_disk\",\n        \"public_sharing_defaults\",\n    ]\n\n    def setUp(self):\n        self.client = APIClient()\n        self.client.force_authenticate(user=None)\n        self.admin = create_test_user(is_admin=True)\n        self.user1 = create_test_user(public_sharing=True)\n        self.user2 = create_test_user()\n\n    def test_public_user_list_count(self):\n        response = self.client.get(\"/api/user/\")\n        data = response.json()\n        self.assertEqual(\n            len(User.objects.filter(public_sharing=True)), len(data[\"results\"])\n        )\n\n    def test_public_user_list_properties(self):\n        response = self.client.get(\"/api/user/\")\n        data = response.json()\n        for user in data[\"results\"]:\n            self.assertEqual(len(self.public_user_properties), len(user.keys()))\n            for key in self.public_user_properties:\n                self.assertTrue(key in user, f\"user does not have key: {key}\")\n\n    def test_authenticated_user_list_count(self):\n        self.client.force_authenticate(user=self.user1)\n        response = self.client.get(\"/api/user/\")\n        data = response.json()\n        self.assertEqual(len(User.objects.all()), len(data[\"results\"]))\n\n    def test_authenticated_user_list_properties(self):\n        self.client.force_authenticate(user=self.user1)\n        response = self.client.get(\"/api/user/\")\n        data = response.json()\n        logger.debug(data)\n\n        for user in data[\"results\"]:\n            for key in self.private_user_properties:\n                self.assertTrue(key in user, f\"user does not have key: {key}\")\n            for key in user:\n                self.assertTrue(\n                    key in self.private_user_properties,\n                    f\"user has superfluous key: {key}\",\n                )\n\n            self.assertEqual(len(self.private_user_properties), len(user.keys()))\n\n    def test_user_update_self(self):\n        self.client.force_authenticate(user=self.user1)\n        response = self.client.patch(\n            f\"/api/user/{self.user1.id}/\", data={\"first_name\": \"Updated\"}\n        )\n        self.assertEqual(200, response.status_code)\n\n    def test_public_update_user(self):\n        response = self.client.patch(\n            f\"/api/user/{self.user1.id}/\", data={\"first_name\": \"Updated\"}\n        )\n        self.assertEqual(401, response.status_code)\n\n    def test_public_delete_user(self):\n        response = self.client.delete(f\"/api/user/{self.user1.id}/\")\n        self.assertEqual(401, response.status_code)\n\n    @override_config(ALLOW_REGISTRATION=False)\n    def test_super_user_create_with_command(self):\n        with patch.dict(\"os.environ\", {\"ADMIN_PASSWORD\": \"demo1234\"}):\n            delete_all_users()\n            call_command(\"createadmin\", \"demo\", \"demo@test.com\")\n            self.assertEqual(1, len(User.objects.all()))\n            user = User.objects.get(username=\"demo\")\n            self.assertTrue(user.is_superuser)\n\n    @override_config(ALLOW_REGISTRATION=False)\n    def test_public_user_create_successful_on_first_setup(self):\n        delete_all_users()\n        self.client.force_authenticate(user=None)\n        data = create_user_details()\n        response = self.client.post(\"/api/user/\", data=data)\n        self.assertEqual(201, response.status_code)\n        self.assertEqual(1, len(User.objects.all()))\n        user = User.objects.get(username=data[\"username\"])\n        self.assertTrue(user.is_superuser)\n\n    @override_config(ALLOW_REGISTRATION=True)\n    def test_public_user_create_successful_when_registration_enabled(self):\n        data = create_user_details()\n        response = self.client.post(\"/api/user/\", data=data)\n        self.assertEqual(201, response.status_code)\n        user = User.objects.get(username=data[\"username\"])\n        self.assertEqual(data[\"username\"], user.username)\n        self.assertEqual(data[\"email\"], user.email)\n        self.assertEqual(data[\"first_name\"], user.first_name)\n        self.assertEqual(data[\"last_name\"], user.last_name)\n\n    @override_config(ALLOW_REGISTRATION=True)\n    def test_after_registration_user_can_authenticate(self):\n        user = create_user_details()\n        signup_response = self.client.post(\"/api/user/\", data=user)\n        self.assertEqual(201, signup_response.status_code)\n        login_payload = {\n            \"username\": user[\"username\"],\n            \"password\": user[\"password\"],\n        }\n        response = self.client.post(\"/api/auth/token/obtain/\", data=login_payload)\n        self.assertEqual(200, response.status_code)\n        data = response.json()\n        self.assertTrue(\"access\" in data.keys())\n        self.assertTrue(\"refresh\" in data.keys())\n\n    @override_config(ALLOW_REGISTRATION=False)\n    def test_public_user_create_fails_when_registration_disabled(self):\n        response = self.client.post(\"/api/user/\", data=create_user_details())\n        # because IsAdminOrFirstTimeSetupOrRegistrationAllowed is **global** permission\n        # on UserViewSet, we are returning 401 and not 403\n        self.assertEqual(401, response.status_code)\n\n    @override_config(ALLOW_REGISTRATION=True)\n    def test_not_first_setup_create_admin_should_create_regular_user(self):\n        data = create_user_details(is_admin=True)\n        response = self.client.post(\"/api/user/\", data=data)\n        self.assertEqual(201, response.status_code)\n        user = User.objects.get(username=data[\"username\"])\n        self.assertEqual(False, user.is_superuser)\n\n    @override_config(ALLOW_REGISTRATION=True)\n    def test_authenticated_user_cannot_create_superuser(self):\n        \"\"\"Authenticated non-admin users must not be able to create superuser accounts.\"\"\"\n        self.client.force_authenticate(user=self.user1)\n        data = create_user_details(is_admin=True)\n        response = self.client.post(\"/api/user/\", data=data)\n        self.assertEqual(201, response.status_code)\n        user = User.objects.get(username=data[\"username\"])\n        self.assertFalse(user.is_superuser)\n        self.assertFalse(user.is_staff)\n\n    def test_user_update_another_user(self):\n        self.client.force_authenticate(user=self.user1)\n        response = self.client.patch(\n            f\"/api/user/{self.user2.id}/\", data={\"first_name\": \"Updated\"}\n        )\n        self.assertEqual(403, response.status_code)\n\n    def test_user_delete_another_user(self):\n        self.client.force_authenticate(user=self.user1)\n        response = self.client.delete(f\"/api/user/{self.user2.id}/\")\n        self.assertEqual(403, response.status_code)\n\n    def test_admin_create_user(self):\n        self.client.force_authenticate(user=self.admin)\n        response = self.client.post(\"/api/user/\", data=create_user_details())\n        self.assertEqual(201, response.status_code)\n\n    def test_admin_partial_update_user(self):\n        self.client.force_authenticate(user=self.admin)\n        response = self.client.patch(\n            f\"/api/user/{self.user1.id}/\", data={\"first_name\": \"Updated\"}\n        )\n        self.assertEqual(200, response.status_code)\n\n    def test_admin_delete_user(self):\n        self.client.force_authenticate(user=self.admin)\n        response = self.client.delete(f\"/api/user/{self.user1.id}/\")\n        self.assertEqual(204, response.status_code)\n\n    @override_config(ALLOW_REGISTRATION=False)\n    def test_first_time_setup_creates_user_when_registration_is_disabled(self):\n        delete_all_users()\n        response = self.client.post(\"/api/user/\", data=create_user_details())\n        self.assertEqual(201, response.status_code)\n\n    def test_first_time_setup(self):\n        delete_all_users()\n        response = self.client.get(\"/api/firsttimesetup/\")\n        data = response.json()\n        self.assertEqual(True, data[\"isFirstTimeSetup\"])\n\n    @override_config(ALLOW_REGISTRATION=True)\n    def test_not_first_time_setup(self):\n        data = create_user_details()\n        signup_response = self.client.post(\"/api/user/\", data=data)\n        self.assertEqual(201, signup_response.status_code)\n        user = User.objects.get(username=data[\"username\"])\n        self.client.force_authenticate(user=user)\n        response = self.client.get(\"/api/firsttimesetup/\")\n        data = response.json()\n        self.assertEqual(False, data[\"isFirstTimeSetup\"])\n\n    def test_regular_user_not_allowed_to_set_scan_directory(self):\n        self.client.force_authenticate(user=self.user1)\n        response = self.client.patch(\n            f\"/api/user/{self.user1.id}/\", {\"scan_directory\": \"/data\"}\n        )\n        data = response.json()\n        self.assertNotEqual(\"/data\", data[\"scan_directory\"])\n"
  },
  {
    "path": "api/tests/test_xmp_association.py",
    "content": "\"\"\"\nTest for XMP sidecar association with photos.\n\nThis test validates that XMP sidecar files are correctly associated with their\ncorresponding photos when processed in the correct order (images first, then metadata).\n\"\"\"\nimport os\nimport random\nimport tempfile\nfrom unittest.mock import patch\n\nfrom django.test import TestCase\n\nfrom api.directory_watcher import create_new_image\nfrom api.models import Photo\nfrom api.tests.utils import create_test_user\n\n\ndef create_unique_png(seed=0):\n    \"\"\"\n    Create a minimal PNG with unique content based on seed value.\n    Different seeds produce different hashes.\n    \"\"\"\n    # Minimal PNG with variable pixel data to ensure unique hashes\n    color = seed % 256\n    return (\n        b\"\\x89PNG\\r\\n\\x1a\\n\\x00\\x00\\x00\\rIHDR\\x00\\x00\\x00\\x01\\x00\\x00\\x00\\x01\"\n        b\"\\x08\\x06\\x00\\x00\\x00\\x1f\\x15\\xc4\\x89\\x00\\x00\\x00\\nIDATx\\x9cc\" + bytes([color]) + b\"\\x01\"\n        b\"\\x00\\x00\\x05\\x00\\x01\\r\\n-\\xb4\\x00\\x00\\x00\\x00IEND\\xaeB`\\x82\"\n    )\n\n\nclass XMPAssociationTest(TestCase):\n    \"\"\"\n    Test that metadata files (XMP sidecars) are correctly associated with photos.\n    \n    This test validates the core logic without async complexity by directly calling\n    create_new_image for both images and metadata files.\n    \"\"\"\n\n    def test_xmp_association_after_image_creation(self):\n        \"\"\"\n        Test that XMP files are correctly associated when processed after their images.\n        \n        This test simulates the real scenario that the sentinel handles:\n        - Files arrive in random mixed order from directory scanning\n        - The sentinel logic separates them into images and metadata\n        - Images are processed first, then metadata\n        \n        We verify that even when files are discovered in random order (e.g., XMP before image),\n        the separation and ordering logic ensures correct association.\n        \"\"\"\n        user = create_test_user()\n        \n        with tempfile.TemporaryDirectory() as tmpdir:\n            N = 4\n            all_files = []  # Mixed list simulating random directory scan order\n            \n            # Create test files with unique images\n            for i in range(N):\n                base = f\"img_{i}\"\n                img_path = os.path.join(tmpdir, f\"{base}.jpg\")\n                xmp_path = os.path.join(tmpdir, f\"{base}.xmp\")\n                \n                with open(img_path, \"wb\") as f:\n                    f.write(create_unique_png(i))  # Each image has unique hash\n                with open(xmp_path, \"wb\") as f:\n                    f.write(b\"<x:xmpmeta>test</x:xmpmeta>\")\n                \n                # Add to mixed list (will be shuffled to simulate random discovery)\n                all_files.append(('image', img_path))\n                all_files.append(('xmp', xmp_path))\n            \n            # Shuffle to simulate random file system ordering\n            # This is the key: files can be discovered in ANY order\n            random.shuffle(all_files)\n            \n            # Example of what the shuffled order might look like:\n            # [('xmp', '.../img_2.xmp'), ('image', '.../img_0.jpg'), ('xmp', '.../img_1.xmp'), ...]\n            # This simulates the real problem: XMP files may be discovered before their images!\n            \n            # Separate into images and metadata (simulating what scan_photos does)\n            from api.models.file import is_metadata\n            image_paths = [path for ftype, path in all_files if not is_metadata(path)]\n            xmp_paths = [path for ftype, path in all_files if is_metadata(path)]\n            \n            # Verify separation happened correctly\n            self.assertEqual(len(image_paths), N, \"Should have N images\")\n            self.assertEqual(len(xmp_paths), N, \"Should have N XMP files\")\n            \n            # Mock pyvips to accept our test images\n            with patch(\"pyvips.Image.thumbnail\"):\n                # Process images first (simulating what the sentinel ensures)\n                # This is the critical ordering that the sentinel guarantees\n                for img_path in image_paths:\n                    photo = create_new_image(user, img_path)\n                    self.assertIsNotNone(photo, f\"Photo should be created for {img_path}\")\n                \n                # Then process XMP files (after sentinel waits for image group completion)\n                for xmp_path in xmp_paths:\n                    create_new_image(user, xmp_path)\n            \n            # Validate: all photos should have their XMP sidecars\n            photos = list(Photo.objects.filter(owner=user))\n            self.assertEqual(len(photos), N, \"All images should produce Photo objects\")\n            \n            for photo in photos:\n                xmp_files = list(photo.files.filter(path__endswith=\".xmp\"))\n                base = os.path.splitext(os.path.basename(photo.main_file.path))[0]\n                self.assertEqual(\n                    len(xmp_files), 1,\n                    f\"Photo {base} should have exactly 1 XMP sidecar, got {len(xmp_files)}\"\n                )\n\n    def test_xmp_processed_before_image_fails_gracefully(self):\n        \"\"\"\n        Test that XMP files processed before their images are handled gracefully.\n        \n        Without the sentinel ordering, this would be the problematic scenario.\n        The XMP should not be associated (logged as warning) and later when the\n        image is processed, it won't automatically pick up the orphaned XMP.\n        \"\"\"\n        user = create_test_user()\n        \n        with tempfile.TemporaryDirectory() as tmpdir:\n            img_path = os.path.join(tmpdir, \"test_img.jpg\")\n            xmp_path = os.path.join(tmpdir, \"test_img.xmp\")\n            \n            with open(img_path, \"wb\") as f:\n                f.write(create_unique_png(100))  # Use seed 100 for this test\n            with open(xmp_path, \"wb\") as f:\n                f.write(b\"<x:xmpmeta>test</x:xmpmeta>\")\n            \n            with patch(\"pyvips.Image.thumbnail\"):\n                # Process XMP first (the problematic order that sentinel prevents)\n                result_xmp = create_new_image(user, xmp_path)\n                self.assertIsNone(result_xmp, \"XMP without photo should return None\")\n                \n                # Now process the image\n                photo = create_new_image(user, img_path)\n                self.assertIsNotNone(photo, \"Photo should be created\")\n                \n                # The XMP won't be auto-associated (this is expected without rescan)\n                xmp_files = list(photo.files.filter(path__endswith=\".xmp\"))\n                self.assertEqual(\n                    len(xmp_files), 0,\n                    \"XMP processed before image won't be auto-associated\"\n                )\n    \n    def test_metadata_function_finds_matching_photo(self):\n        \"\"\"\n        Test that the metadata association logic in create_new_image correctly\n        finds and associates with existing photos.\n        \"\"\"\n        user = create_test_user()\n        \n        with tempfile.TemporaryDirectory() as tmpdir:\n            img_path = os.path.join(tmpdir, \"matching_test.jpg\")\n            xmp_path = os.path.join(tmpdir, \"matching_test.xmp\")\n            \n            with open(img_path, \"wb\") as f:\n                f.write(create_unique_png(200))  # Use seed 200 for this test\n            with open(xmp_path, \"wb\") as f:\n                f.write(b\"<x:xmpmeta>test matching</x:xmpmeta>\")\n            \n            with patch(\"pyvips.Image.thumbnail\"):\n                # Create photo first\n                photo = create_new_image(user, img_path)\n                self.assertIsNotNone(photo, \"Photo should be created\")\n                initial_file_count = photo.files.count()\n                \n                # Process XMP - should find and associate with photo\n                create_new_image(user, xmp_path)\n                \n                # Refresh and verify\n                photo.refresh_from_db()\n                self.assertEqual(\n                    photo.files.count(), initial_file_count + 1,\n                    \"XMP file should be added to photo\"\n                )\n                \n                xmp_file = photo.files.filter(path=xmp_path).first()\n                self.assertIsNotNone(xmp_file, \"XMP file should be associated with photo\")\n"
  },
  {
    "path": "api/tests/test_zip_list_photos_view_v2.py",
    "content": "from unittest.mock import patch\n\nfrom django.test import TestCase\nfrom django.utils import timezone\nfrom rest_framework.test import APIClient\n\nfrom api.models.long_running_job import LongRunningJob\nfrom api.tests.utils import create_test_photos, create_test_user\n\n\nclass PhotoListWithoutTimestampTest(TestCase):\n    def setUp(self):\n        self.client = APIClient()\n        self.user = create_test_user()\n        self.client.force_authenticate(user=self.user)\n\n    @patch(\"shutil.disk_usage\")\n    def test_download(self, patched_shutil):\n        # test download function when we have enough storage\n        patched_shutil.return_value.free = 500000000\n        now = timezone.now()\n        create_test_photos(number_of_photos=1, owner=self.user, added_on=now, size=100)\n\n        response = self.client.get(\"/api/photos/notimestamp/\")\n        img_hash = response.json()[\"results\"][0][\"url\"]\n        datadict = {\"owner\": self.user, \"image_hashes\": [img_hash]}\n\n        response_2 = self.client.post(\"/api/photos/download\", data=datadict)\n        lrr_job = LongRunningJob.objects.all()[0]\n        self.assertEqual(lrr_job.job_id, response_2.json()[\"job_id\"])\n        self.assertEqual(response_2.status_code, 200)\n\n        # test download function when we dont have enough storage\n        patched_shutil.return_value.free = 0\n        response_3 = self.client.post(\"/api/photos/download\", data=datadict)\n        self.assertEqual(response_3.status_code, 507)\n"
  },
  {
    "path": "api/tests/utils.py",
    "content": "import secrets\nimport uuid\nfrom typing import Any\n\nimport numpy as np\nfrom django.utils import timezone\nfrom faker import Faker\n\nfrom api.models import Cluster, Face, File, Person, Photo, User\n\nfake = Faker()\n\nONE_PIXEL_PNG = (\n    b\"\\x89PNG\\r\\n\\x1a\\n\\x00\\x00\\x00\\rIHDR\\x00\\x00\\x00\\x01\\x00\\x00\\x00\\x01\\x08\\x02\\x00\\x00\\x00\\xb1\\x1e\\x28\"\n    b\"\\x00\\x00\\x00\\x03PLTE\\xff\\xff\\xff\\xff\\xff\\xff\\x00\\x00\\x00\\x00IEND\\xaeB`\\x82\"\n)\n\n\ndef create_password():\n    return secrets.token_urlsafe(10)\n\n\ndef create_user_details(is_admin=False):\n    return {\n        \"username\": fake.user_name(),\n        \"first_name\": fake.first_name(),\n        \"last_name\": fake.last_name(),\n        \"email\": fake.email(),\n        \"password\": create_password(),\n        \"is_superuser\": is_admin,\n    }\n\n\ndef create_test_person(\n    name: str | None = None,\n    kind: str | None = Person.KIND_USER,\n    cover_photo: Photo | None = None,\n    cover_face: Face | None = None,\n    face_count: int = 0,\n    cluster_owner: User | None = None,\n    **kwargs: Any,\n) -> Person:\n    \"\"\"Create a test Person object with random data using Faker.\"\"\"\n    return Person.objects.create(\n        name=name or fake.name(),\n        kind=kind,\n        cover_photo=cover_photo,\n        cover_face=cover_face,\n        face_count=face_count,\n        cluster_owner=cluster_owner,\n        **kwargs,\n    )\n\n\ndef create_test_face(\n    photo: Photo | None = None,\n    image: str | None = \"test.jpg\",\n    person: Person | None = None,\n    classification_person: Person | None = None,\n    classification_probability: float = 0.0,\n    cluster_person: Person | None = None,\n    cluster_probability: float = 0.0,\n    deleted: bool = False,\n    cluster: Cluster | None = None,\n    location_top: int = 0,\n    location_bottom: int = 0,\n    location_left: int = 0,\n    location_right: int = 0,\n    encoding: str | None = None,\n    **kwargs: Any,\n) -> Face:\n    \"\"\"Create a test Face object with random data using Faker.\"\"\"\n    return Face.objects.create(\n        photo=photo,\n        image=image,\n        person=person,\n        classification_person=classification_person,\n        classification_probability=classification_probability\n        or fake.pyfloat(min_value=0, max_value=1),\n        cluster_person=cluster_person,\n        cluster_probability=cluster_probability\n        or fake.pyfloat(min_value=0, max_value=1),\n        deleted=deleted,\n        cluster=cluster,\n        location_top=location_top or fake.random_int(min=0, max=500),\n        location_bottom=location_bottom or fake.random_int(min=501, max=1000),\n        location_left=location_left or fake.random_int(min=0, max=500),\n        location_right=location_right or fake.random_int(min=501, max=1000),\n        encoding=encoding or np.random.rand(128).tobytes().hex(),\n        **kwargs,\n    )\n\n\ndef create_test_user(is_admin=False, public_sharing=False, **kwargs):\n    import uuid\n\n    # Ensure unique username by appending UUID\n    username = fake.user_name() + str(uuid.uuid4())[:8]\n    return User.objects.create(\n        username=username,\n        first_name=fake.first_name(),\n        last_name=fake.last_name(),\n        email=fake.email(),\n        password=create_password(),\n        public_sharing=public_sharing,\n        is_superuser=is_admin,\n        is_staff=is_admin,\n        **kwargs,\n    )\n\n\ndef create_test_photo(**kwargs):\n    from api.models.thumbnail import Thumbnail\n    from api.models.photo_caption import PhotoCaption\n    from api.models.photo_search import PhotoSearch\n    from api.models.photo_metadata import PhotoMetadata\n\n    # Use proper UUID for primary key (Photo model now uses UUIDField)\n    pk = uuid.uuid4()\n    # Use MD5 for image_hash (content hash for deduplication)\n    image_hash = fake.md5()\n\n    # Extract fields that are no longer part of Photo model\n    aspect_ratio = kwargs.pop(\"aspect_ratio\", 1)\n    is_video = kwargs.get(\"video\", False)\n    square_ext = \".mp4\" if is_video else \".webp\"\n    thumbnail_big = kwargs.pop(\"thumbnail_big\", f\"thumbnails_big/{image_hash}.webp\")\n    square_thumbnail = kwargs.pop(\n        \"square_thumbnail\", f\"square_thumbnails/{image_hash}{square_ext}\"\n    )\n    square_thumbnail_small = kwargs.pop(\n        \"square_thumbnail_small\", f\"square_thumbnails_small/{image_hash}{square_ext}\"\n    )\n    dominant_color = kwargs.pop(\"dominant_color\", None)\n\n    # Extract caption and search fields\n    captions_json = kwargs.pop(\"captions_json\", None)\n    search_captions = kwargs.pop(\"search_captions\", None)\n    search_location = kwargs.pop(\"search_location\", None)\n\n    # Extract metadata fields that are now in PhotoMetadata model\n    # Map old field names to new PhotoMetadata field names\n    metadata_field_mapping = {\n        \"camera\": \"camera_model\",  # Old 'camera' -> new 'camera_model'\n        \"lens\": \"lens_model\",  # Old 'lens' -> new 'lens_model'\n        \"iso\": \"iso\",\n        \"fstop\": \"aperture\",  # Old 'fstop' -> new 'aperture'\n        \"focal_length\": \"focal_length\",\n        \"shutter_speed\": \"shutter_speed\",\n        \"focalLength35Equivalent\": \"focal_length_35mm\",\n        \"digitalZoomRatio\": None,  # Not in new model, discard\n        \"subjectDistance\": None,  # Not in new model, discard\n        \"width\": \"width\",\n        \"height\": \"height\",\n        \"orientation\": \"orientation\",\n        \"gps_lat\": \"gps_latitude\",\n        \"gps_lon\": \"gps_longitude\",\n        \"gps_altitude\": \"gps_altitude\",\n        # Also support new field names directly\n        \"camera_make\": \"camera_make\",\n        \"camera_model\": \"camera_model\",\n        \"lens_make\": \"lens_make\",\n        \"lens_model\": \"lens_model\",\n        \"aperture\": \"aperture\",\n        \"focal_length_35mm\": \"focal_length_35mm\",\n        \"gps_latitude\": \"gps_latitude\",\n        \"gps_longitude\": \"gps_longitude\",\n    }\n    metadata_fields = {}\n    for old_name, new_name in metadata_field_mapping.items():\n        if old_name in kwargs:\n            value = kwargs.pop(old_name)\n            if new_name is not None:  # Skip fields that don't exist in new model\n                metadata_fields[new_name] = value\n\n    # Create the photo with remaining kwargs\n    photo = Photo(pk=pk, image_hash=image_hash, **kwargs)\n    file = create_test_file(f\"/tmp/{image_hash}.png\", photo.owner, ONE_PIXEL_PNG)\n    photo.main_file = file\n    if \"added_on\" not in kwargs.keys():\n        photo.added_on = timezone.now()\n    photo.save()\n\n    # Create thumbnail for the photo\n    Thumbnail.objects.create(\n        photo=photo,\n        aspect_ratio=aspect_ratio,\n        thumbnail_big=thumbnail_big,\n        square_thumbnail=square_thumbnail,\n        square_thumbnail_small=square_thumbnail_small,\n        dominant_color=dominant_color,\n    )\n\n    # Create PhotoCaption if captions_json is provided\n    if captions_json is not None:\n        PhotoCaption.objects.create(photo=photo, captions_json=captions_json)\n\n    # Create PhotoSearch if search fields are provided\n    if search_captions is not None or search_location is not None:\n        PhotoSearch.objects.create(\n            photo=photo,\n            search_captions=search_captions,\n            search_location=search_location,\n        )\n\n    # Create PhotoMetadata if metadata fields are provided\n    if metadata_fields:\n        PhotoMetadata.objects.create(photo=photo, **metadata_fields)\n\n    return photo\n\n\ndef create_test_photos(number_of_photos=1, **kwargs):\n    return [create_test_photo(**kwargs) for _ in range(0, number_of_photos)]\n\n\ndef create_test_photos_with_faces(number_of_photos=1, **kwargs):\n    photos = create_test_photos(number_of_photos, **kwargs)\n    [create_test_face(photo=photo) for photo in photos]\n    return photos\n\n\ndef create_test_file(path: str, user: User, content: bytes):\n    with open(path, \"wb+\") as f:\n        f.write(content)\n    return File.create(path, user)\n\n\ndef share_test_photos(photo_ids, user):\n    \"\"\"Share photos with a user.\n\n    Args:\n        photo_ids: Can be either photo UUIDs (pk) or image_hashes (for backward compatibility)\n        user: The user to share photos with\n    \"\"\"\n    # Handle both UUID (pk) and image_hash inputs for backward compatibility\n    resolved_ids = []\n    for photo_id in photo_ids:\n        if isinstance(photo_id, uuid.UUID):\n            resolved_ids.append(photo_id)\n        elif isinstance(photo_id, str):\n            # Try to find photo by image_hash\n            try:\n                photo = Photo.objects.get(image_hash=photo_id)\n                resolved_ids.append(photo.pk)\n            except Photo.DoesNotExist:\n                # Maybe it's a UUID string\n                try:\n                    resolved_ids.append(uuid.UUID(photo_id))\n                except ValueError:\n                    raise ValueError(f\"Could not resolve photo_id: {photo_id}\")\n        else:\n            resolved_ids.append(photo_id)\n\n    Photo.shared_to.through.objects.bulk_create(\n        [\n            Photo.shared_to.through(user_id=user.id, photo_id=photo_id)\n            for photo_id in resolved_ids\n        ]\n    )\n"
  },
  {
    "path": "api/thumbnails.py",
    "content": "import os\nimport subprocess\n\nimport pyvips\nimport requests\nfrom django.conf import settings\n\nfrom api import util\nfrom api.models.file import is_raw\n\n\ndef create_thumbnail(input_path, output_height, output_path, hash, file_type):\n    try:\n        if is_raw(input_path):\n            if \"thumbnails_big\" in output_path:\n                complete_path = os.path.join(\n                    settings.MEDIA_ROOT, output_path, hash + file_type\n                )\n                json = {\n                    \"source\": input_path,\n                    \"destination\": complete_path,\n                    \"height\": output_height,\n                }\n                response = requests.post(\"http://localhost:8003/\", json=json).json()\n                return response[\"thumbnail\"]\n            else:\n                # only encode raw image in worse case, smaller thumbnails can get created from the big thumbnail instead\n                big_thumbnail_path = os.path.join(\n                    settings.MEDIA_ROOT, \"thumbnails_big\", hash + file_type\n                )\n                x = pyvips.Image.thumbnail(\n                    big_thumbnail_path,\n                    10000,\n                    height=output_height,\n                    size=pyvips.enums.Size.DOWN,\n                )\n                complete_path = os.path.join(\n                    settings.MEDIA_ROOT, output_path, hash + file_type\n                )\n                x.write_to_file(complete_path, Q=95)\n            return complete_path\n        else:\n            x = pyvips.Image.thumbnail(\n                input_path, 10000, height=output_height, size=pyvips.enums.Size.DOWN\n            )\n            complete_path = os.path.join(\n                settings.MEDIA_ROOT, output_path, hash + file_type\n            )\n            x.write_to_file(complete_path, Q=95)\n            return complete_path\n    except Exception as e:\n        util.logger.error(f\"Could not create thumbnail for file {input_path}\")\n        raise e\n\n\ndef create_animated_thumbnail(input_path, output_height, output_path, hash, file_type):\n    try:\n        output = os.path.join(\n            settings.MEDIA_ROOT, output_path, hash + file_type\n        )\n        command = [\n            \"ffmpeg\",\n            \"-i\",\n            input_path,\n            \"-to\",\n            \"00:00:05\",\n            \"-vcodec\",\n            \"libx264\",\n            \"-crf\",\n            \"20\",\n            \"-an\",\n            \"-filter:v\",\n            f\"scale=-2:{output_height}\",\n            output,\n        ]\n\n        with subprocess.Popen(command) as proc:\n            proc.wait()\n    except Exception as e:\n        util.logger.error(f\"Could not create animated thumbnail for file {input_path}\")\n        raise e\n\n\ndef create_thumbnail_for_video(input_path, output_path, hash, file_type):\n    try:\n        output = os.path.join(\n            settings.MEDIA_ROOT, output_path, hash + file_type\n        )\n        command = [\n            \"ffmpeg\",\n            \"-i\",\n            input_path,\n            \"-ss\",\n            \"00:00:00.000\",\n            \"-vframes\",\n            \"1\",\n            output,\n        ]\n\n        with subprocess.Popen(command) as proc:\n            proc.wait()\n    except Exception as e:\n        util.logger.error(f\"Could not create thumbnail for video file {input_path}\")\n        raise e\n\n\ndef does_static_thumbnail_exist(output_path, hash):\n    return os.path.exists(\n        os.path.join(settings.MEDIA_ROOT, output_path, hash + \".webp\")\n    )\n\n\ndef does_video_thumbnail_exist(output_path, hash):\n    return os.path.exists(\n        os.path.join(settings.MEDIA_ROOT, output_path, hash + \".mp4\")\n    )\n"
  },
  {
    "path": "api/util.py",
    "content": "import logging.handlers\nimport os\nimport os.path\n\nfrom django.conf import settings\n\nlogger = logging.getLogger(\"ownphotos\")\nformatter = logging.Formatter(\n    \"%(asctime)s : %(filename)s : %(funcName)s : %(lineno)s : %(levelname)s : %(message)s\"\n)\nFILE_MAX_BYTE = 256 * 1024 * 200  # 100MB\nFILE_HANDLER = logging.handlers.RotatingFileHandler(\n    os.path.join(settings.LOGS_ROOT, \"ownphotos.log\"),\n    maxBytes=FILE_MAX_BYTE,\n    backupCount=10,\n)\nFILE_HANDLER.setFormatter(formatter)\nlogger.addHandler(FILE_HANDLER)\nlogger.setLevel(logging.INFO)\n\n\ndef is_valid_path(path, root_path):\n    # Resolve absolute paths to prevent directory traversal attacks\n    abs_path = os.path.abspath(path)\n    abs_root = os.path.abspath(root_path)\n\n    try:\n        common = os.path.commonpath([abs_path, abs_root])\n    except ValueError:\n        # Raised when paths are on different drives\n        return False\n\n    if common != abs_root:\n        return False\n\n    # Guard against paths that merely share a prefix with the root path\n    # (e.g. /root and /root_dir). By normalising with os.path.commonpath\n    # and checking for path separators we ensure the path really resides\n    # within the root directory or is the directory itself.\n    return abs_path == abs_root or abs_path.startswith(abs_root + os.sep)\n\n\ndef is_number(s):\n    try:\n        float(s)\n        return True\n    except Exception:\n        return False\n\n\ndef convert_to_degrees(values):\n    \"\"\"Helper function to convert the GPS coordinates stored in the EXIF to degrees in float format\n    :param value:\n    :type value: exifread.utils.Ratio\n    :rtype: float\n    \"\"\"\n    d = float(values[0].num) / float(values[0].den)\n    m = float(values[1].num) / float(values[1].den)\n    s = float(values[2].num) / float(values[2].den)\n\n    return d + (m / 60.0) + (s / 3600.0)\n\n\nweekdays = {\n    1: \"Monday\",\n    2: \"Tuesday\",\n    3: \"Wednesday\",\n    4: \"Thursday\",\n    5: \"Friday\",\n    6: \"Saturday\",\n    7: \"Sunday\",\n}\n"
  },
  {
    "path": "api/views/__init__.py",
    "content": ""
  },
  {
    "path": "api/views/album_auto.py",
    "content": "import uuid\n\nfrom django.db.models import Count, OuterRef, Prefetch, Q, Subquery\nfrom django_q.tasks import AsyncTask\nfrom drf_spectacular.utils import extend_schema\nfrom rest_framework import filters, viewsets\nfrom rest_framework.decorators import action\nfrom rest_framework.response import Response\nfrom rest_framework.views import APIView\n\nfrom api.autoalbum import generate_event_albums, regenerate_event_titles\nfrom api.models import AlbumAuto, Face, Person, Photo\nfrom api.serializers.album_auto import AlbumAutoListSerializer, AlbumAutoSerializer\nfrom api.util import logger\nfrom api.views.custom_api_view import ListViewSet\nfrom api.views.pagination import StandardResultsSetPagination\n\n\n# TODO: This is a fetches with too many queries. We need to optimize this.\nclass AlbumAutoViewSet(viewsets.ModelViewSet):\n    serializer_class = AlbumAutoSerializer\n    pagination_class = StandardResultsSetPagination\n\n    def get_queryset(self):\n        if self.request.user.is_anonymous:\n            return AlbumAuto.objects.none()\n\n        return (\n            AlbumAuto.objects.prefetch_related(\n                Prefetch(\"owner\"),\n                Prefetch(\"photos\", queryset=Photo.visible.all()),\n                Prefetch(\"photos__faces\"),\n                Prefetch(\n                    \"photos__faces__person\",\n                    queryset=Person.objects.all().annotate(\n                        viewable_face_count=Count(\"faces\"),\n                        face_url=Subquery(\n                            Face.objects.filter(\n                                person=OuterRef(\"pk\"),\n                                photo__hidden=False,\n                                photo__in_trashcan=False,\n                                photo__owner=self.request.user,\n                            )\n                            .order_by(\"id\")\n                            .values(\"image\")[:1]\n                        ),\n                        face_photo_url=Subquery(\n                            Photo.objects.filter(\n                                faces__person=OuterRef(\"pk\"),\n                                hidden=False,\n                                in_trashcan=False,\n                                owner=self.request.user,\n                            )\n                            .order_by(\"added_on\")\n                            .values(\"image_hash\")[:1]\n                        ),\n                        video=Subquery(\n                            Photo.objects.filter(\n                                faces__person=OuterRef(\"pk\"),\n                                hidden=False,\n                                in_trashcan=False,\n                                owner=self.request.user,\n                            )\n                            .order_by(\"added_on\")\n                            .values(\"video\")[:1]\n                        ),\n                    ),\n                ),\n            )\n            .annotate(photo_count=Count((\"photos\"), distinct=True))\n            .filter(Q(photo_count__gt=0) & Q(owner=self.request.user))\n            .order_by(\"-timestamp\")\n        )\n\n    @action(detail=False, methods=[\"post\"])\n    def delete_all(self, request):\n        AlbumAuto.objects.filter(owner=request.user).all().delete()\n        return Response(\"success\")\n\n\n# TODO: Add custom covers for auto album\nclass AlbumAutoListViewSet(ListViewSet):\n    serializer_class = AlbumAutoListSerializer\n    pagination_class = StandardResultsSetPagination\n    filter_backends = (filters.SearchFilter,)\n    search_fields = [\n        \"photos__search_instance__search_captions\",\n        \"photos__search_instance__search_location\",\n        \"photos__faces__person__name\",\n    ]\n\n    def get_queryset(self):\n        cover_photo_query = Photo.objects.filter(hidden=False)\n        return (\n            AlbumAuto.objects.annotate(\n                photo_count=Count(\n                    \"photos\", filter=Q(photos__hidden=False), distinct=True\n                )\n            )\n            .filter(Q(photo_count__gt=0) & Q(owner=self.request.user))\n            .prefetch_related(\n                Prefetch(\n                    \"photos\", queryset=cover_photo_query[:1], to_attr=\"cover_photo\"\n                )\n            )\n            .order_by(\"-timestamp\")\n        )\n\n\nclass RegenerateAutoAlbumTitles(APIView):\n    @extend_schema(\n        deprecated=True,\n        description=\"Use POST method to re-generate auto album titles.\",\n    )\n    def get(self, request, format=None):\n        return self._schedule_auto_album_title_regeneration(request)\n\n    def post(self, request, format=None):\n        return self._schedule_auto_album_title_regeneration(request)\n\n    def _schedule_auto_album_title_regeneration(self, request, format=None):\n        try:\n            job_id = uuid.uuid4()\n            AsyncTask(regenerate_event_titles, request.user, job_id).run()\n            return Response({\"status\": True, \"job_id\": job_id})\n        except BaseException as e:\n            logger.error(str(e))\n            return Response({\"status\": False})\n\n\nclass AutoAlbumGenerateView(APIView):\n    @extend_schema(\n        deprecated=True,\n        description=\"Use POST method to re-generate auto albums.\",\n    )\n    def get(self, request, format=None):\n        return self._schedule_auto_album_regeneration(request)\n\n    def post(self, request, format=None):\n        return self._schedule_auto_album_regeneration(request)\n\n    def _schedule_auto_album_regeneration(self, request):\n        try:\n            job_id = uuid.uuid4()\n            AsyncTask(generate_event_albums, request.user, job_id).run()\n            return Response({\"status\": True, \"job_id\": job_id})\n        except BaseException as e:\n            logger.error(str(e))\n            return Response({\"status\": False})\n"
  },
  {
    "path": "api/views/album_folder.py",
    "content": "import os\nfrom rest_framework import viewsets\nfrom rest_framework.decorators import action\nfrom rest_framework.permissions import IsAuthenticated\nfrom rest_framework.response import Response\nfrom django.conf import settings\nfrom django.db.models import Count, Q\n\nfrom api.util import logger\nfrom api.models.photo import Photo\n\n\nclass FolderNavigationViewSet(viewsets.ViewSet):\n    \"\"\"\n    ViewSet for folder navigation functionality.\n    Returns paginated subfolders for a given path (max 100 per page).\n    Only queries photo counts for folders in the current page for optimal performance.\n\n    Query Parameters:\n    - path: The directory path to list subfolders for\n    - page: Page number for pagination (default: 1)\n\n    Security:\n    - Admins (is_staff=True) can access all folders within DATA_ROOT\n    - Regular users can only access folders within their scan_directory\n    - All paths are validated to prevent directory traversal attacks\n    \"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    @action(detail=False, methods=[\"get\"])\n    def subfolders(self, request):\n        \"\"\"Get subfolders for a given path with pagination.\"\"\"\n        # Get pagination parameters\n        try:\n            page = int(request.query_params.get(\"page\", 1))\n            if page < 1:\n                page = 1\n        except (ValueError, TypeError):\n            page = 1\n\n        page_size = 100  # Fixed page size of 100 folders\n\n        # Determine default path based on user permissions\n        is_admin = request.user.is_staff if request.user else False\n\n        if is_admin:\n            default_path = settings.DATA_ROOT\n        else:\n            # For regular users, default to their scan directory\n            if hasattr(request.user, \"scan_directory\") and request.user.scan_directory:\n                default_path = request.user.scan_directory\n            else:\n                return Response(\n                    {\"error\": \"User scan directory not configured\"}, status=403\n                )\n\n        base_path = request.query_params.get(\"path\", default_path)\n\n        # Validate path is within allowed directories\n        if not os.path.exists(base_path):\n            return Response({\"error\": \"Path does not exist\"}, status=400)\n\n        if not os.path.isdir(base_path):\n            return Response({\"error\": \"Path is not a directory\"}, status=400)\n\n        # Security check - determine allowed paths based on user permissions\n        is_admin = request.user.is_staff if request.user else False\n\n        if is_admin:\n            # Admins can access all folders within DATA_ROOT\n            if not base_path.startswith(settings.DATA_ROOT):\n                return Response({\"error\": \"Access denied\"}, status=403)\n        else:\n            # Regular users can only access folders within their scan directory\n            if (\n                not hasattr(request.user, \"scan_directory\")\n                or not request.user.scan_directory\n            ):\n                return Response(\n                    {\"error\": \"User scan directory not configured\"}, status=403\n                )\n\n            # Ensure scan directory exists\n            scan_directory = request.user.scan_directory\n            if not os.path.exists(scan_directory):\n                return Response({\"error\": \"Scan directory does not exist\"}, status=403)\n\n            # Ensure requested path is within user's scan directory\n            if not base_path.startswith(scan_directory):\n                return Response(\n                    {\n                        \"error\": \"Access denied - can only access folders within your scan directory\"\n                    },\n                    status=403,\n                )\n\n        try:\n            # Gather immediate subfolders and their mtimes\n            folder_entries = []\n            for item in os.scandir(base_path):\n                if item.is_dir() and not item.name.startswith(\".\"):\n                    folder_entries.append(\n                        (item.name, item.path, os.path.getmtime(item.path))\n                    )\n\n            # Early return if there are no subfolders\n            if not folder_entries:\n                if is_admin:\n                    parent_path = (\n                        os.path.dirname(base_path)\n                        if base_path != settings.DATA_ROOT\n                        else None\n                    )\n                else:\n                    parent_path = (\n                        os.path.dirname(base_path)\n                        if base_path != request.user.scan_directory\n                        else None\n                    )\n                return Response(\n                    {\n                        \"current_path\": base_path,\n                        \"parent_path\": parent_path,\n                        \"subfolders\": [],\n                        \"pagination\": {\n                            \"page\": page,\n                            \"page_size\": page_size,\n                            \"total_folders\": 0,\n                            \"total_pages\": 0,\n                            \"has_next\": False,\n                            \"has_previous\": page > 1,\n                        },\n                    }\n                )\n\n            # Sort folder entries by name first\n            folder_entries.sort(key=lambda x: x[0].lower())\n\n            # Apply pagination to folder entries before querying database\n            total_folders_all = len(folder_entries)\n            total_pages_all = (\n                total_folders_all + page_size - 1\n            ) // page_size  # Ceiling division\n\n            start_idx = (page - 1) * page_size\n            end_idx = start_idx + page_size\n            paginated_entries = folder_entries[start_idx:end_idx]\n\n            # Early return if no folders in this page\n            if not paginated_entries:\n                if is_admin:\n                    parent_path = (\n                        os.path.dirname(base_path)\n                        if base_path != settings.DATA_ROOT\n                        else None\n                    )\n                else:\n                    parent_path = (\n                        os.path.dirname(base_path)\n                        if base_path != request.user.scan_directory\n                        else None\n                    )\n                return Response(\n                    {\n                        \"current_path\": base_path,\n                        \"parent_path\": parent_path,\n                        \"subfolders\": [],\n                        \"pagination\": {\n                            \"page\": page,\n                            \"page_size\": page_size,\n                            \"total_folders\": total_folders_all,\n                            \"total_pages\": total_pages_all,\n                            \"has_next\": page < total_pages_all,\n                            \"has_previous\": page > 1,\n                        },\n                    }\n                )\n\n            # Query database only for the folders we need (paginated ones)\n            aggregates = {}\n            for idx, (_, folder_path, _) in enumerate(paginated_entries):\n                aggregates[f\"count_{idx}\"] = Count(\n                    \"pk\", filter=Q(files__path__startswith=folder_path), distinct=True\n                )\n\n            counts = Photo.objects.filter(owner=request.user).aggregate(**aggregates)\n\n            # Build response for paginated folders\n            paginated_subfolders = []\n            for idx, (name, folder_path, mtime) in enumerate(paginated_entries):\n                photo_count = counts.get(f\"count_{idx}\", 0) or 0\n                if photo_count > 0:\n                    paginated_subfolders.append(\n                        {\n                            \"name\": name,\n                            \"path\": folder_path,\n                            \"photo_count\": photo_count,\n                            \"modified\": mtime,\n                        }\n                    )\n\n            # Calculate parent path respecting user permissions\n            if is_admin:\n                parent_path = (\n                    os.path.dirname(base_path)\n                    if base_path != settings.DATA_ROOT\n                    else None\n                )\n            else:\n                parent_path = (\n                    os.path.dirname(base_path)\n                    if base_path != request.user.scan_directory\n                    else None\n                )\n\n            return Response(\n                {\n                    \"current_path\": base_path,\n                    \"parent_path\": parent_path,\n                    \"subfolders\": paginated_subfolders,\n                    \"pagination\": {\n                        \"page\": page,\n                        \"page_size\": page_size,\n                        \"total_folders\": total_folders_all,\n                        \"total_pages\": total_pages_all,\n                        \"has_next\": page < total_pages_all,\n                        \"has_previous\": page > 1,\n                    },\n                }\n            )\n\n        except Exception as e:\n            logger.error(f\"Error scanning directory {base_path}: {e}\")\n            return Response({\"error\": \"Error scanning directory\"}, status=500)\n"
  },
  {
    "path": "api/views/albums.py",
    "content": "import re\n\nfrom django.core.paginator import EmptyPage, PageNotAnInteger, Paginator\nfrom django.db.models import Count, F, Prefetch, Q\nfrom drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema\nfrom rest_framework import filters, viewsets\nfrom rest_framework.permissions import AllowAny, IsAuthenticated\nfrom rest_framework.response import Response\n\nfrom api.models import (\n    AlbumDate,\n    AlbumPlace,\n    AlbumThing,\n    AlbumUser,\n    Face,\n    File,\n    Person,\n    Photo,\n    User,\n)\nfrom api.models.photo_stack import PhotoStack\nfrom api.serializers.album_date import (\n    AlbumDateSerializer,\n    IncompleteAlbumDateSerializer,\n)\nfrom api.serializers.album_place import (\n    AlbumPlaceListSerializer,\n    AlbumPlaceSerializer,\n    GroupedPlacePhotosSerializer,\n)\nfrom api.serializers.album_thing import (\n    AlbumThingListSerializer,\n    AlbumThingSerializer,\n    GroupedThingPhotosSerializer,\n)\nfrom api.serializers.album_user import (\n    AlbumUserListSerializer,\n    AlbumUserPublicSerializer,\n    AlbumUserSerializer,\n)\nfrom api.serializers.person import GroupedPersonPhotosSerializer, PersonSerializer\nfrom api.serializers.photos import PhotoSummarySerializer\nfrom api.util import logger\nfrom api.views.custom_api_view import ListViewSet\nfrom api.views.pagination import (\n    RegularResultsSetPagination,\n    StandardResultsSetPagination,\n)\n\n\n# To-Do: Not used as far as I can tell, only in mobile app\n@extend_schema(\n    deprecated=True,\n    description=\"This endpoint is deprecated. Use /api/persons instead.\",\n)\nclass AlbumPersonViewSet(viewsets.ModelViewSet):\n    serializer_class = GroupedPersonPhotosSerializer\n\n    def get_queryset(self):\n        if self.request.user.is_anonymous:\n            return Person.objects.none()\n\n        return (\n            Person.objects.annotate(\n                photo_count=Count(\n                    \"faces\", filter=Q(faces__photo__hidden=False), distinct=True\n                )\n            )\n            .filter(Q(photo_count__gt=0))\n            .prefetch_related(\n                Prefetch(\n                    \"faces\",\n                    queryset=Face.objects.filter(Q(person__isnull=False)),\n                )\n            )\n            .prefetch_related(\n                Prefetch(\n                    \"faces__photo\",\n                    queryset=Photo.objects.filter(\n                        Q(faces__photo__hidden=False) & Q(owner=self.request.user)\n                    )\n                    .distinct()\n                    .order_by(\"-exif_timestamp\")\n                    .only(\"image_hash\", \"exif_timestamp\", \"rating\", \"public\", \"hidden\"),\n                )\n            )\n        )\n\n    def retrieve(self, *args, **kwargs):\n        queryset = self.get_queryset()\n        logger.warning(args[0].__str__())\n        albumid = re.findall(r\"\\'(.+?)\\'\", args[0].__str__())[0].split(\"/\")[-2]\n        serializer = GroupedPersonPhotosSerializer(\n            queryset.filter(id=albumid).first(), context={\"request\": self.request}\n        )\n        return Response({\"results\": serializer.data})\n\n    def list(self, *args, **kwargs):\n        queryset = self.get_queryset()\n        serializer = GroupedPersonPhotosSerializer(\n            queryset, many=True, context={\"request\": self.request}\n        )\n        return Response({\"results\": serializer.data})\n\n\nclass PersonViewSet(viewsets.ModelViewSet):\n    serializer_class = PersonSerializer\n    pagination_class = StandardResultsSetPagination\n    filter_backends = (filters.SearchFilter,)\n    search_fields = [\"name\"]\n    ordering_fields = [\"name\"]\n\n    def get_queryset(self):\n        if self.request.user.is_anonymous:\n            return Person.objects.none()\n        qs = (\n            Person.objects.filter(\n                Q(kind=Person.KIND_USER) & Q(cluster_owner=self.request.user)\n            )\n            .select_related(\"cover_photo\", \"cover_face\")\n            .only(\n                \"name\",\n                \"face_count\",\n                \"id\",\n                \"cover_face\",\n                \"cover_photo\",\n            )\n            .order_by(\"name\")\n        )\n\n        return qs\n\n\ndef _get_active_tag_thing_types():\n    \"\"\"Return the AlbumThing thing_type values for the active tagging model.\"\"\"\n    from constance import config as site_config\n\n    tagging_model = site_config.TAGGING_MODEL\n    if tagging_model == \"siglip2\":\n        return [\"siglip2_tag\"]\n    return [\"places365_attribute\", \"places365_category\"]\n\n\nclass AlbumThingViewSet(viewsets.ModelViewSet):\n    serializer_class = AlbumThingSerializer\n    pagination_class = StandardResultsSetPagination\n\n    def get_queryset(self):\n        if self.request.user.is_anonymous:\n            return AlbumThing.objects.none()\n        active_types = _get_active_tag_thing_types()\n        return (\n            AlbumThing.objects.filter(Q(owner=self.request.user))\n            .filter(Q(photo_count__gt=0))\n            .filter(Q(thing_type__in=active_types) | Q(thing_type=\"hashtag_attribute\"))\n            .prefetch_related(\n                Prefetch(\n                    \"photos\",\n                    queryset=Photo.visible.order_by(\"-exif_timestamp\"),\n                ),\n                Prefetch(\n                    \"photos__owner\",\n                    queryset=User.objects.only(\n                        \"id\", \"username\", \"first_name\", \"last_name\"\n                    ),\n                ),\n            )\n            .order_by(\"title\")\n        )\n\n    def retrieve(self, *args, **kwargs):\n        queryset = self.get_queryset()\n        logger.warning(args[0].__str__())\n        albumid = re.findall(r\"\\'(.+?)\\'\", args[0].__str__())[0].split(\"/\")[-2]\n        serializer = GroupedThingPhotosSerializer(\n            queryset.filter(id=albumid).first(), context={\"request\": self.request}\n        )\n        return Response({\"results\": serializer.data})\n\n    def list(self, *args, **kwargs):\n        queryset = self.get_queryset()\n        serializer = GroupedThingPhotosSerializer(\n            queryset, many=True, context={\"request\": self.request}\n        )\n        return Response({\"results\": serializer.data})\n\n\n# To-Do: Could be literally the list command in AlbumThingViewSet\nclass AlbumThingListViewSet(ListViewSet):\n    serializer_class = AlbumThingListSerializer\n    pagination_class = StandardResultsSetPagination\n    filter_backends = (filters.SearchFilter,)\n    search_fields = [\"title\"]\n\n    def get_queryset(self):\n        if self.request.user.is_anonymous:\n            return AlbumThing.objects.none()\n\n        active_types = _get_active_tag_thing_types()\n        queryset = (\n            AlbumThing.objects.filter(owner=self.request.user)\n            .prefetch_related(\"cover_photos\")\n            .filter(photo_count__gt=0)\n            .filter(Q(thing_type__in=active_types) | Q(thing_type=\"hashtag_attribute\"))\n            .order_by(\"-title\")\n        )\n\n        return queryset\n\n\nclass AlbumPlaceViewSet(viewsets.ModelViewSet):\n    serializer_class = AlbumPlaceSerializer\n    pagination_class = StandardResultsSetPagination\n\n    def get_queryset(self):\n        if self.request.user.is_anonymous:\n            return AlbumPlace.objects.none()\n        return (\n            AlbumPlace.objects.annotate(\n                photo_count=Count(\n                    \"photos\", filter=Q(photos__hidden=False), distinct=True\n                )\n            )\n            .filter(Q(photo_count__gt=0) & Q(owner=self.request.user))\n            .prefetch_related(\n                Prefetch(\n                    \"photos\",\n                    queryset=Photo.objects.filter(hidden=False)\n                    .only(\"image_hash\", \"public\", \"rating\", \"hidden\", \"exif_timestamp\")\n                    .order_by(\"-exif_timestamp\"),\n                )\n            )\n            .order_by(\"title\")\n        )\n\n    def retrieve(self, *args, **kwargs):\n        queryset = self.get_queryset()\n        logger.warning(args[0].__str__())\n        albumid = re.findall(r\"\\'(.+?)\\'\", args[0].__str__())[0].split(\"/\")[-2]\n        serializer = GroupedPlacePhotosSerializer(\n            queryset.filter(id=albumid).first(), context={\"request\": self.request}\n        )\n        return Response({\"results\": serializer.data})\n\n\n# To-Do: Could be literally the list command in AlbumPlaceViewSet\nclass AlbumPlaceListViewSet(ListViewSet):\n    serializer_class = AlbumPlaceListSerializer\n    pagination_class = StandardResultsSetPagination\n    filter_backends = (filters.SearchFilter,)\n    search_fields = [\"title\"]\n\n    def get_queryset(self):\n        if self.request.user.is_anonymous:\n            return AlbumPlace.objects.none()\n        cover_photos_query = Photo.objects.filter(hidden=False).only(\n            \"image_hash\", \"video\"\n        )\n\n        return (\n            AlbumPlace.objects.filter(owner=self.request.user)\n            .annotate(\n                photo_count=Count(\n                    \"photos\", filter=Q(photos__hidden=False), distinct=True\n                )\n            )\n            .prefetch_related(\n                Prefetch(\n                    \"photos\", queryset=cover_photos_query[:4], to_attr=\"cover_photos\"\n                )\n            )\n            .filter(Q(photo_count__gt=0) & Q(owner=self.request.user))\n            .order_by(\"title\")\n        )\n\n\nclass AlbumUserViewSet(viewsets.ModelViewSet):\n    serializer_class = AlbumUserSerializer\n    pagination_class = StandardResultsSetPagination\n\n    def perform_create(self, serializer):\n        serializer.save(owner=self.request.user)\n\n    def get_queryset(self):\n        # Support public access when explicitly requested\n        if self.request.query_params.get(\"public\"):\n            # Anyone can view a public album if the album itself is marked public and active (not expired)\n            username = self.request.query_params.get(\"username\")\n            from django.utils import timezone\n\n            # Use share model exclusively\n            base_qs = AlbumUser.objects.filter(\n                Q(share__enabled=True)\n                & (\n                    Q(share__expires_at__isnull=True)\n                    | Q(share__expires_at__gte=timezone.now())\n                )\n            )\n            if username:\n                base_qs = base_qs.filter(owner__username=username)\n            return base_qs.order_by(\"-id\")\n\n        if self.request.user.is_anonymous:\n            return AlbumUser.objects.none()\n        return (\n            AlbumUser.objects.filter(\n                Q(owner=self.request.user) | Q(shared_to__exact=self.request.user.id)\n            )\n            .distinct()\n            .order_by(\"-id\")\n        )\n\n    def get_permissions(self):\n        if self.request.query_params.get(\"public\"):\n            permission_classes = [AllowAny]\n        else:\n            permission_classes = [IsAuthenticated]\n        return [permission() for permission in permission_classes]\n\n    def get_serializer_class(self):\n        if self.request.query_params.get(\"public\"):\n            return AlbumUserPublicSerializer\n        return super().get_serializer_class()\n\n\n# To-Do: Could be the list command in AlbumUserViewSet\nclass AlbumUserListViewSet(ListViewSet):\n    serializer_class = AlbumUserListSerializer\n    pagination_class = StandardResultsSetPagination\n    filter_backends = (filters.SearchFilter,)\n    search_fields = [\"title\"]\n\n    def get_queryset(self):\n        if self.request.user.is_anonymous:\n            return AlbumUser.objects.none()\n        return (\n            AlbumUser.objects.filter(owner=self.request.user)\n            .annotate(\n                photo_count=Count(\n                    \"photos\", filter=Q(photos__hidden=False), distinct=True\n                )\n            )\n            .filter(Q(photo_count__gt=0) & Q(owner=self.request.user))\n            .order_by(\"title\")\n        )\n\n\nclass AlbumDateViewSet(viewsets.ModelViewSet):\n    serializer_class = AlbumDateSerializer\n    pagination_class = RegularResultsSetPagination\n\n    def get_queryset(self):\n        photo_filter = []\n        photo_filter.append(Q(thumbnail__aspect_ratio__isnull=False))\n\n        if not self.request.user.is_anonymous and not self.request.query_params.get(\n            \"public\"\n        ):\n            photo_filter.append(Q(owner=self.request.user))\n        if self.request.query_params.get(\"favorite\"):\n            min_rating = self.request.user.favorite_min_rating\n            photo_filter.append(Q(rating__gte=min_rating))\n\n        if self.request.query_params.get(\"public\"):\n            if self.request.query_params.get(\"username\"):\n                username = self.request.query_params.get(\"username\")\n                photo_filter.append(Q(owner__username=username))\n            photo_filter.append(Q(public=True))\n\n        # Filter by folder path if provided\n        if self.request.query_params.get(\"folder\"):\n            folder_path = self.request.query_params.get(\"folder\")\n            photo_filter.append(Q(files__path__startswith=folder_path))\n\n        if self.request.query_params.get(\"hidden\"):\n            photo_filter.append(Q(hidden=True))\n        else:\n            photo_filter.append(Q(hidden=False))\n\n        if self.request.query_params.get(\"video\"):\n            photo_filter.append(Q(video=True))\n\n        if self.request.query_params.get(\"photo\"):\n            photo_filter.append(Q(video=False))\n\n        if self.request.query_params.get(\"in_trashcan\"):\n            photo_filter.append(Q(in_trashcan=True) & Q(removed=False))\n        else:\n            photo_filter.append(Q(in_trashcan=False))\n\n        # Stack filtering: Show photos that are either:\n        # 1. Not in any stack, OR\n        # 2. The primary photo of their stack\n        # Stacks are for organizational purposes (RAW+JPEG pairs, bursts, brackets, live photos, manual)\n        # Non-primary photos are hidden in the timeline but accessible via stack expansion\n        # NOTE: Duplicates are handled separately via the Duplicate model and are not filtered here\n        if not self.request.query_params.get(\"show_all_stack_photos\"):\n            photo_filter.append(\n                Q(stacks__isnull=True) |\n                Q(primary_in_stack__isnull=False)\n            )\n\n        if self.request.query_params.get(\"person\"):\n            photo_filter.append(\n                Q(faces__person__id=self.request.query_params.get(\"person\"))\n            )\n        if self.request.query_params.get(\"last_modified\"):\n            photo_filter = []\n            photo_filter.append(Q(owner=self.request.user))\n            photo_filter.append(\n                Q(exif_timestamp__gte=self.request.query_params.get(\"last_modified\"))\n            )\n\n        album_date = AlbumDate.objects.filter(id=self.kwargs[\"pk\"]).first()\n\n        # Build a prefetch for stacks that filters to valid types and annotates\n        # photo_count, avoiding N+1 queries in the serializer\n        valid_stack_types = PhotoStack.VALID_STACK_TYPES + [\n            PhotoStack.StackType.RAW_JPEG_PAIR,\n            PhotoStack.StackType.LIVE_PHOTO,\n        ]\n        stacks_prefetch = Prefetch(\n            \"stacks\",\n            queryset=PhotoStack.objects.filter(\n                stack_type__in=valid_stack_types\n            ).annotate(photo_count_annotation=Count(\"photos\")),\n        )\n\n        photo_qs = (\n            album_date.photos.filter(*photo_filter)\n            .prefetch_related(\n                Prefetch(\n                    \"owner\",\n                    queryset=User.objects.only(\n                        \"id\", \"username\", \"first_name\", \"last_name\"\n                    ),\n                ),\n                Prefetch(\n                    \"main_file__embedded_media\",\n                    queryset=File.objects.only(\"hash\"),\n                ),\n                stacks_prefetch,\n                \"files\",  # Prefetch files for get_has_raw_variant()\n            )\n            .select_related(\"thumbnail\", \"search_instance\", \"main_file\")\n            .distinct()  # Remove duplicates that can occur when filtering through reverse ForeignKey relationships (e.g., faces__person__id)\n            .order_by(\"-exif_timestamp\")\n            .only(\n                \"image_hash\",\n                \"thumbnail__aspect_ratio\",\n                \"thumbnail__dominant_color\",\n                \"video\",\n                \"main_file\",\n                \"search_instance__search_location\",\n                \"public\",\n                \"rating\",\n                \"hidden\",\n                \"exif_timestamp\",\n                \"owner\",\n                \"video_length\",\n                \"exif_gps_lat\",\n                \"exif_gps_lon\",\n                \"removed\",\n                \"in_trashcan\",\n            )\n        )\n\n        # Paginate photo queryset\n        page_size = self.request.query_params.get(\"size\") or 100\n        paginator = Paginator(photo_qs, page_size)\n        page = self.request.query_params.get(\"page\")\n\n        try:\n            photos = paginator.page(page)\n        except PageNotAnInteger:\n            photos = paginator.page(1)\n        except EmptyPage:\n            photos = paginator.page(paginator.num_pages)\n\n        return album_date, photos, paginator.count\n\n    def get_permissions(self):\n        if self.request.query_params.get(\"public\"):\n            permission_classes = [AllowAny]\n        else:\n            permission_classes = [IsAuthenticated]\n        return [permission() for permission in permission_classes]\n\n    @extend_schema(\n        parameters=[\n            OpenApiParameter(\"favorite\", OpenApiTypes.BOOL),\n            OpenApiParameter(\"public\", OpenApiTypes.BOOL),\n            OpenApiParameter(\"in_trashcan\", OpenApiTypes.BOOL),\n            OpenApiParameter(\"hidden\", OpenApiTypes.BOOL),\n            OpenApiParameter(\"video\", OpenApiTypes.BOOL),\n            OpenApiParameter(\"username\", OpenApiTypes.STR),\n            OpenApiParameter(\"person\", OpenApiTypes.INT),\n            OpenApiParameter(\"last_modified\", OpenApiTypes.DATE),\n        ],\n        description=\"Returns the actual images, for a given day in chunks of 100 images.\",\n    )\n    def retrieve(self, *args, **kwargs):\n        album_date, photos, count = self.get_queryset()\n        serializer = AlbumDateSerializer(album_date, context={\"request\": self.request})\n        serializer_data = serializer.data\n        serializer_data[\"items\"] = PhotoSummarySerializer(\n            photos, many=True\n        ).data  # Assuming you have a PhotoSerializer\n        serializer_data[\"numberOfItems\"] = count\n        return Response({\"results\": serializer_data})\n\n\n# To-Do: Could be the summary command in AlbumDateViewSet\nclass AlbumDateListViewSet(ListViewSet):\n    serializer_class = IncompleteAlbumDateSerializer\n    pagination_class = None\n    filter_backends = (filters.SearchFilter,)\n    search_fields = [\n        \"photos__search_instance__search_captions\",\n        \"photos__search_instance__search_location\",\n        \"photos__faces__person__name\",\n    ]\n\n    def get_queryset(self):\n        filter = []\n        filter.append(Q(photos__thumbnail__aspect_ratio__isnull=False))\n\n        if self.request.query_params.get(\"hidden\"):\n            filter.append(Q(photos__hidden=True))\n        else:\n            filter.append(Q(photos__hidden=False))\n\n        if self.request.query_params.get(\"in_trashcan\"):\n            filter.append(Q(photos__in_trashcan=True) & Q(photos__removed=False))\n        else:\n            filter.append(Q(photos__in_trashcan=False))\n\n        # Stack filtering: Only count photos that are either:\n        # 1. Not in any stack, OR\n        # 2. The primary photo of their stack\n        # Stacks are for organizational purposes (RAW+JPEG pairs, bursts, brackets, live photos, manual)\n        # Non-primary photos are hidden in the timeline but accessible via stack expansion\n        # NOTE: Duplicates are handled separately via the Duplicate model and are not filtered here\n        if not self.request.query_params.get(\"show_all_stack_photos\"):\n            filter.append(\n                Q(photos__stacks__isnull=True) |\n                Q(photos__primary_in_stack__isnull=False)\n            )\n\n        # Filter by folder path if provided\n        if self.request.query_params.get(\"folder\"):\n            folder_path = self.request.query_params.get(\"folder\")\n            filter.append(Q(photos__files__path__startswith=folder_path))\n\n        if not self.request.user.is_anonymous and not self.request.query_params.get(\n            \"public\"\n        ):\n            filter.append(Q(owner=self.request.user))\n            filter.append(Q(photos__owner=self.request.user))\n\n        if self.request.query_params.get(\"favorite\"):\n            min_rating = self.request.user.favorite_min_rating\n            filter.append(Q(photos__rating__gte=min_rating))\n\n        if self.request.query_params.get(\"public\"):\n            username = self.request.query_params.get(\"username\")\n            filter.append(Q(owner__username=username))\n            filter.append(Q(photos__public=True))\n\n        if self.request.query_params.get(\"video\"):\n            filter.append(Q(photos__video=True))\n\n        if self.request.query_params.get(\"photo\"):\n            filter.append(Q(photos__video=False))\n\n        if self.request.query_params.get(\"person\"):\n            filter.append(\n                Q(photos__faces__person__id=self.request.query_params.get(\"person\"))\n            )\n        if self.request.query_params.get(\"last_modified\"):\n            filter = []\n            filter.append(Q(owner=self.request.user))\n            filter.append(Q(photos__owner=self.request.user))\n            filter.append(\n                Q(\n                    photos__last_modified__gte=self.request.query_params.get(\n                        \"last_modified\"\n                    )\n                )\n            )\n\n        qs = (\n            AlbumDate.objects.filter(*filter)\n            .annotate(photo_count=Count(\"photos\", distinct=True))\n            .filter(Q(photo_count__gt=0))\n            .order_by(F(\"date\").desc(nulls_last=True))\n        )\n\n        return qs\n\n    def get_permissions(self):\n        if self.request.query_params.get(\"public\"):\n            permission_classes = [AllowAny]\n        else:\n            permission_classes = [IsAuthenticated]\n        return [permission() for permission in permission_classes]\n\n    @extend_schema(\n        parameters=[\n            OpenApiParameter(\"favorite\", OpenApiTypes.BOOL),\n            OpenApiParameter(\"public\", OpenApiTypes.BOOL),\n            OpenApiParameter(\"in_trashcan\", OpenApiTypes.BOOL),\n            OpenApiParameter(\"hidden\", OpenApiTypes.BOOL),\n            OpenApiParameter(\"video\", OpenApiTypes.BOOL),\n            OpenApiParameter(\"username\", OpenApiTypes.STR),\n            OpenApiParameter(\"person\", OpenApiTypes.INT),\n            OpenApiParameter(\"last_modified\", OpenApiTypes.DATE),\n        ],\n        description=\"Gives you a list of days with the number of elements. This is not paginated and can be large.\",\n    )\n    def list(self, *args, **kwargs):\n        serializer = IncompleteAlbumDateSerializer(self.get_queryset(), many=True)\n        return Response({\"results\": serializer.data})\n"
  },
  {
    "path": "api/views/custom_api_view.py",
    "content": "from rest_framework import mixins, viewsets\n\n\nclass ListViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):\n    \"\"\"A viewset that provides `list` actions.\n\n    To use it, override the class and set the `.queryset` and\n    `.serializer_class` attributes.\n    \"\"\"\n\n    pass\n"
  },
  {
    "path": "api/views/dataviz.py",
    "content": "import os\n\nfrom django.http import FileResponse, HttpResponseForbidden\nfrom drf_spectacular.utils import extend_schema\nfrom rest_framework.response import Response\nfrom rest_framework.views import APIView\n\nfrom api.stats import (\n    get_count_stats,\n    get_photo_month_counts,\n    get_searchterms_wordcloud,\n    get_server_stats,\n    get_location_clusters,\n    get_location_sunburst,\n    get_location_timeline,\n)\n\nfrom api.face_classify import cluster_faces\nfrom api.social_graph import build_social_graph\nfrom api.util import logger\n\n\nclass ClusterFaceView(APIView):\n    @extend_schema(\n        deprecated=True,\n        description=\"Use POST method\",\n    )\n    def get(self, request, format=None):\n        return self._cluster_faces(request.user)\n\n    def post(self, request, format=None):\n        return self._cluster_faces(request.user)\n\n    def _cluster_faces(self, user):\n        res = cluster_faces(user)\n        return Response(res)\n\n\nclass SocialGraphView(APIView):\n    def get(self, request, format=None):\n        try:\n            res = build_social_graph(request.user)\n            return Response(res)\n        except Exception:\n            logger.exception(f\"Error in SocialGraphView for user {request.user.id}\")\n            return Response({\"error\": \"Failed to build social graph\"}, status=500)\n\n\nclass ServerLogsView(APIView):\n    def get(self, request, format=None):\n        if not (request.user and request.user.is_staff):\n            return HttpResponseForbidden()\n\n        BASE_LOGS = os.environ.get(\"BASE_LOGS\", \"/logs/\")\n        log_file = os.path.join(BASE_LOGS, \"ownphotos.log\")\n\n        if os.path.exists(log_file):\n            return FileResponse(\n                open(log_file, \"rb\"), as_attachment=True, filename=\"ownphotos.log\"\n            )\n        else:\n            return Response({\"error\": \"Log file not found\"}, status=404)\n\n\nclass ServerStatsView(APIView):\n    def get(self, request, format=None):\n        if not (request.user and request.user.is_staff):\n            return HttpResponseForbidden()\n        res = get_server_stats()\n        return Response(res)\n\n\nclass StatsView(APIView):\n    def get(self, request, format=None):\n        res = get_count_stats(user=request.user)\n        return Response(res)\n\n\nclass LocationClustersView(APIView):\n    def get(self, request, format=None):\n        res = get_location_clusters(request.user)\n        return Response(res)\n\n\nclass LocationSunburst(APIView):\n    def get(self, request, format=None):\n        res = get_location_sunburst(request.user)\n        return Response(res)\n\n\nclass LocationTimeline(APIView):\n    def get(self, request, format=None):\n        res = get_location_timeline(request.user)\n        return Response(res)\n\n\nclass PhotoMonthCountsView(APIView):\n    def get(self, request, format=None):\n        res = get_photo_month_counts(request.user)\n        return Response(res)\n\n\nclass SearchTermWordCloudView(APIView):\n    def get(self, request, format=None):\n        res = get_searchterms_wordcloud(request.user)\n        return Response(res)\n"
  },
  {
    "path": "api/views/duplicates.py",
    "content": "\"\"\"\nAPI views for Duplicate management - finding and cleaning up duplicate photos.\n\nHandles duplicate types:\n- EXACT_COPY: Byte-for-byte identical files (same MD5 hash, different paths)\n- VISUAL_DUPLICATE: Visually similar photos (similar perceptual hash)\n\nDuplicates are separate from Stacks because they have different purposes:\n- Duplicates: Storage cleanup (review and delete redundant copies)\n- Stacks: Photo organization (browse related photos like RAW+JPEG, bursts)\n\"\"\"\n\nfrom django.core.paginator import Paginator\nfrom django.db.models import Count, Sum\nfrom django_q.tasks import async_task\nfrom drf_spectacular.utils import OpenApiParameter, extend_schema\nfrom rest_framework import status\nfrom rest_framework.permissions import IsAuthenticated\nfrom rest_framework.response import Response\nfrom rest_framework.views import APIView\n\nfrom api.models import Photo\nfrom api.models.duplicate import Duplicate\nfrom api.util import logger\n\n\nclass DuplicateListView(APIView):\n    \"\"\"List all duplicate groups for the current user with pagination and filters.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    @extend_schema(\n        parameters=[\n            OpenApiParameter(\n                \"duplicate_type\",\n                str,\n                description=\"Filter by duplicate type: exact_copy, visual_duplicate\",\n            ),\n            OpenApiParameter(\n                \"status\",\n                str,\n                description=\"Filter by review status: pending, resolved, dismissed\",\n            ),\n            OpenApiParameter(\n                \"page\",\n                int,\n                description=\"Page number (default: 1)\",\n            ),\n            OpenApiParameter(\n                \"page_size\",\n                int,\n                description=\"Number of items per page (default: 20, max: 100)\",\n            ),\n        ],\n    )\n    def get(self, request):\n        duplicate_type_filter = request.query_params.get(\"duplicate_type\", None)\n        status_filter = request.query_params.get(\"status\", None)\n        page = max(1, int(request.query_params.get(\"page\", 1)))\n        page_size = max(1, min(int(request.query_params.get(\"page_size\", 20)), 100))\n\n        duplicates = (\n            Duplicate.objects.filter(owner=request.user)\n            .prefetch_related(\"photos__thumbnail\", \"kept_photo__thumbnail\")\n            .annotate(photos_count=Count(\"photos\"))\n            .order_by(\"-created_at\")\n        )\n\n        if duplicate_type_filter:\n            duplicates = duplicates.filter(duplicate_type=duplicate_type_filter)\n\n        if status_filter:\n            duplicates = duplicates.filter(review_status=status_filter)\n\n        # Only return duplicates with at least 2 photos\n        duplicates = duplicates.filter(photos_count__gte=2)\n\n        # Paginate results\n        paginator = Paginator(duplicates, page_size)\n        page_obj = paginator.get_page(page)\n\n        # Serialize manually to include nested photo data\n        results = []\n        for duplicate in page_obj.object_list:\n            photos = duplicate.photos.all()[:4]  # Preview first 4 photos\n            results.append(\n                {\n                    \"id\": str(duplicate.id),\n                    \"duplicate_type\": duplicate.duplicate_type,\n                    \"duplicate_type_display\": duplicate.get_duplicate_type_display(),\n                    \"review_status\": duplicate.review_status,\n                    \"review_status_display\": duplicate.get_review_status_display(),\n                    \"photo_count\": duplicate.photos_count,\n                    \"potential_savings\": duplicate.potential_savings,\n                    \"similarity_score\": duplicate.similarity_score,\n                    \"created_at\": duplicate.created_at,\n                    \"kept_photo\": {\n                        \"image_hash\": duplicate.kept_photo.image_hash,\n                        \"thumbnail_url\": f\"/media/square_thumbnails_small/{duplicate.kept_photo.image_hash}\"\n                        if hasattr(duplicate.kept_photo, \"thumbnail\")\n                        and duplicate.kept_photo.thumbnail.square_thumbnail_small\n                        else None,\n                    }\n                    if duplicate.kept_photo\n                    else None,\n                    \"preview_photos\": [\n                        {\n                            \"image_hash\": p.image_hash,\n                            \"thumbnail_url\": f\"/media/square_thumbnails_small/{p.image_hash}\"\n                            if hasattr(p, \"thumbnail\")\n                            and p.thumbnail.square_thumbnail_small\n                            else None,\n                        }\n                        for p in photos\n                    ],\n                }\n            )\n\n        return Response(\n            {\n                \"results\": results,\n                \"count\": paginator.count,\n                \"num_pages\": paginator.num_pages,\n                \"page\": page,\n                \"page_size\": page_size,\n                \"has_next\": page_obj.has_next(),\n                \"has_previous\": page_obj.has_previous(),\n            }\n        )\n\n\nclass DuplicateDetailView(APIView):\n    \"\"\"Get details of a specific duplicate group with all photos.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def get(self, request, duplicate_id):\n        try:\n            duplicate = Duplicate.objects.annotate(photos_count=Count(\"photos\")).get(\n                id=duplicate_id, owner=request.user\n            )\n        except Duplicate.DoesNotExist:\n            return Response(\n                {\"error\": \"Duplicate group not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        photos = duplicate.photos.select_related(\n            \"thumbnail\", \"main_file\", \"metadata\"\n        ).all()\n\n        photo_data = []\n        for p in photos:\n            # Width, height, and camera are on the metadata model\n            width = None\n            height = None\n            camera = None\n            if hasattr(p, \"metadata\") and p.metadata:\n                width = p.metadata.width\n                height = p.metadata.height\n                camera = p.metadata.camera_model\n\n            data = {\n                \"id\": str(p.id),\n                \"image_hash\": p.image_hash,\n                \"width\": width,\n                \"height\": height,\n                \"size\": p.size,\n                \"camera\": camera,\n                \"exif_timestamp\": p.exif_timestamp,\n                \"is_kept\": duplicate.kept_photo\n                and p.image_hash == duplicate.kept_photo.image_hash,\n                \"file_path\": p.main_file.path if p.main_file else None,\n                \"file_type\": p.main_file.get_type_display() if p.main_file else None,\n                \"thumbnail_url\": f\"/media/square_thumbnails_small/{p.image_hash}\"\n                if hasattr(p, \"thumbnail\") and p.thumbnail.square_thumbnail_small\n                else None,\n                \"thumbnail_big_url\": f\"/media/thumbnails_big/{p.image_hash}\"\n                if hasattr(p, \"thumbnail\") and p.thumbnail.thumbnail_big\n                else None,\n            }\n            photo_data.append(data)\n\n        # Get suggested best photo\n        suggested_photo = duplicate.auto_select_best_photo()\n\n        return Response(\n            {\n                \"id\": str(duplicate.id),\n                \"duplicate_type\": duplicate.duplicate_type,\n                \"duplicate_type_display\": duplicate.get_duplicate_type_display(),\n                \"review_status\": duplicate.review_status,\n                \"review_status_display\": duplicate.get_review_status_display(),\n                \"photo_count\": duplicate.photos_count,\n                \"potential_savings\": duplicate.potential_savings,\n                \"similarity_score\": duplicate.similarity_score,\n                \"created_at\": duplicate.created_at,\n                \"updated_at\": duplicate.updated_at,\n                \"kept_photo_hash\": duplicate.kept_photo.image_hash\n                if duplicate.kept_photo\n                else None,\n                \"suggested_photo_hash\": suggested_photo.image_hash\n                if suggested_photo\n                else None,\n                \"photos\": photo_data,\n            }\n        )\n\n\nclass DuplicateResolveView(APIView):\n    \"\"\"Resolve a duplicate group by selecting a photo to keep and optionally trashing others.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def post(self, request, duplicate_id):\n        try:\n            duplicate = Duplicate.objects.get(id=duplicate_id, owner=request.user)\n        except Duplicate.DoesNotExist:\n            return Response(\n                {\"error\": \"Duplicate group not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        keep_photo_hash = request.data.get(\"keep_photo_hash\")\n        trash_others = request.data.get(\"trash_others\", True)\n\n        if not keep_photo_hash:\n            return Response(\n                {\"error\": \"keep_photo_hash is required\"},\n                status=status.HTTP_400_BAD_REQUEST,\n            )\n\n        # Verify photo exists in duplicate group\n        try:\n            keep_photo = duplicate.photos.get(image_hash=keep_photo_hash)\n        except Photo.DoesNotExist:\n            return Response(\n                {\"error\": \"Photo not found in this duplicate group\"},\n                status=status.HTTP_400_BAD_REQUEST,\n            )\n\n        # Resolve the duplicate\n        duplicate.resolve(keep_photo, trash_others)\n\n        logger.info(\n            f\"Resolved duplicate {duplicate.id}: kept {keep_photo_hash}, trashed {duplicate.trashed_count}\"\n        )\n        return Response(\n            {\n                \"status\": \"resolved\",\n                \"kept_photo\": keep_photo_hash,\n                \"trashed_count\": duplicate.trashed_count,\n            }\n        )\n\n\nclass DuplicateDismissView(APIView):\n    \"\"\"Dismiss a duplicate group (mark photos as not actually duplicates).\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def post(self, request, duplicate_id):\n        try:\n            duplicate = Duplicate.objects.get(id=duplicate_id, owner=request.user)\n        except Duplicate.DoesNotExist:\n            return Response(\n                {\"error\": \"Duplicate group not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        duplicate.dismiss()\n\n        logger.info(f\"Dismissed duplicate {duplicate.id}\")\n        return Response({\"status\": \"dismissed\"})\n\n\nclass DuplicateRevertView(APIView):\n    \"\"\"Revert a resolved duplicate (restore trashed photos, reset to pending).\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def post(self, request, duplicate_id):\n        try:\n            duplicate = Duplicate.objects.get(id=duplicate_id, owner=request.user)\n        except Duplicate.DoesNotExist:\n            return Response(\n                {\"error\": \"Duplicate group not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        if duplicate.review_status != Duplicate.ReviewStatus.RESOLVED:\n            return Response(\n                {\"error\": \"Can only revert resolved duplicates\"},\n                status=status.HTTP_400_BAD_REQUEST,\n            )\n\n        restored_count = duplicate.revert()\n\n        logger.info(\n            f\"Reverted duplicate {duplicate.id}: restored {restored_count} photos\"\n        )\n        return Response({\"status\": \"reverted\", \"restored_count\": restored_count})\n\n\nclass DuplicateDeleteView(APIView):\n    \"\"\"Delete a duplicate group (unlinks photos but doesn't delete them).\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def delete(self, request, duplicate_id):\n        try:\n            duplicate = Duplicate.objects.get(id=duplicate_id, owner=request.user)\n        except Duplicate.DoesNotExist:\n            return Response(\n                {\"error\": \"Duplicate group not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        # Unlink photos from this duplicate group (ManyToMany)\n        photo_count = duplicate.photos.count()\n        for photo in duplicate.photos.all():\n            photo.duplicates.remove(duplicate)\n\n        # Delete duplicate group\n        duplicate_id_str = str(duplicate.id)\n        duplicate.delete()\n\n        logger.info(\n            f\"Deleted duplicate group {duplicate_id_str}: unlinked {photo_count} photos\"\n        )\n        return Response({\"status\": \"deleted\", \"unlinked_count\": photo_count})\n\n\nclass DetectDuplicatesView(APIView):\n    \"\"\"Trigger duplicate detection for the current user.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    @extend_schema(\n        parameters=[\n            OpenApiParameter(\n                \"detect_exact_copies\",\n                bool,\n                description=\"Detect exact file copies (default: true)\",\n            ),\n            OpenApiParameter(\n                \"detect_visual_duplicates\",\n                bool,\n                description=\"Detect visually similar photos (default: true)\",\n            ),\n            OpenApiParameter(\n                \"visual_threshold\",\n                int,\n                description=\"Hamming distance threshold for visual duplicates (default: 10)\",\n            ),\n            OpenApiParameter(\n                \"clear_pending\",\n                bool,\n                description=\"Clear existing pending duplicates before detection (default: false)\",\n            ),\n            OpenApiParameter(\n                \"batch_size\",\n                int,\n                description=\"Number of photos to process per batch for visual detection (default: 10000, min: 100, max: 50000)\",\n            ),\n        ],\n    )\n    def post(self, request):\n        from api.duplicate_detection import batch_detect_duplicates\n\n        # Validate and clamp batch_size to reasonable range\n        batch_size = int(request.data.get(\"batch_size\", 10000))\n        if batch_size < 100:\n            batch_size = 100  # Minimum to avoid too many batches\n        elif batch_size > 50000:\n            batch_size = 50000  # Maximum to prevent memory issues\n\n        options = {\n            \"detect_exact_copies\": request.data.get(\"detect_exact_copies\", True),\n            \"detect_visual_duplicates\": request.data.get(\n                \"detect_visual_duplicates\", True\n            ),\n            \"visual_threshold\": int(request.data.get(\"visual_threshold\", 10)),\n            \"clear_pending\": request.data.get(\"clear_pending\", False),\n            \"batch_size\": batch_size,\n        }\n\n        # Queue background job\n        async_task(batch_detect_duplicates, request.user, options)\n\n        logger.info(\n            f\"Duplicate detection queued for user {request.user.username} with options: {options}\"\n        )\n        return Response(\n            {\n                \"status\": \"queued\",\n                \"message\": \"Duplicate detection started\",\n                \"options\": options,\n            },\n            status=status.HTTP_202_ACCEPTED,\n        )\n\n\nclass DuplicateStatsView(APIView):\n    \"\"\"Get duplicate statistics for the current user.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def get(self, request):\n        duplicates = Duplicate.objects.filter(owner=request.user)\n\n        # Count by type\n        by_type = {}\n        for dup_type in Duplicate.DuplicateType.values:\n            by_type[dup_type] = duplicates.filter(duplicate_type=dup_type).count()\n\n        # Count by review status\n        pending_count = duplicates.filter(\n            review_status=Duplicate.ReviewStatus.PENDING\n        ).count()\n        resolved_count = duplicates.filter(\n            review_status=Duplicate.ReviewStatus.RESOLVED\n        ).count()\n        dismissed_count = duplicates.filter(\n            review_status=Duplicate.ReviewStatus.DISMISSED\n        ).count()\n\n        # Calculate potential savings (from pending duplicates)\n        total_savings = (\n            duplicates.filter(review_status=Duplicate.ReviewStatus.PENDING).aggregate(\n                total=Sum(\"potential_savings\")\n            )[\"total\"]\n            or 0\n        )\n\n        # Count photos in duplicate groups\n        photos_in_duplicates = (\n            Photo.objects.filter(owner=request.user, duplicates__isnull=False)\n            .distinct()\n            .count()\n        )\n\n        total_photos = Photo.objects.filter(\n            owner=request.user, hidden=False, in_trashcan=False\n        ).count()\n\n        return Response(\n            {\n                \"total_duplicates\": duplicates.count(),\n                \"pending_duplicates\": pending_count,\n                \"resolved_duplicates\": resolved_count,\n                \"dismissed_duplicates\": dismissed_count,\n                \"by_type\": by_type,\n                \"photos_in_duplicates\": photos_in_duplicates,\n                \"total_photos\": total_photos,\n                \"potential_savings_bytes\": total_savings,\n                \"potential_savings_mb\": round(total_savings / (1024 * 1024), 2)\n                if total_savings\n                else 0,\n            }\n        )\n"
  },
  {
    "path": "api/views/faces.py",
    "content": "import uuid\n\nfrom django.db.models import Case, CharField, Count, IntegerField, Q, Value, When\nfrom django_q.tasks import Chain\nfrom drf_spectacular.types import OpenApiTypes\nfrom drf_spectacular.utils import OpenApiParameter, extend_schema\nfrom rest_framework import status\nfrom rest_framework.response import Response\nfrom rest_framework.views import APIView\n\nfrom api.directory_watcher import generate_face_embeddings, scan_faces\nfrom api.face_classify import cluster_all_faces\nfrom api.ml_models import do_all_models_exist, download_models\nfrom api.models import Face, User\nfrom api.models.person import Person, get_or_create_person\nfrom api.models.photo_search import PhotoSearch\nfrom api.serializers.face import (\n    FaceListSerializer,\n    IncompletePersonFaceListSerializer,\n    PersonFaceListSerializer,\n)\nfrom api.util import logger\nfrom api.views.custom_api_view import ListViewSet\nfrom api.views.pagination import RegularResultsSetPagination\n\n\nclass ScanFacesView(APIView):\n    @extend_schema(\n        deprecated=True,\n        description=\"Use POST method\",\n    )\n    def get(self, request, format=None):\n        return self._scan_faces(request)\n\n    def post(self, request, format=None):\n        return self._scan_faces(request)\n\n    def _scan_faces(self, request, format=None):\n        chain = Chain()\n        if not do_all_models_exist():\n            chain.append(download_models, request.user)\n        try:\n            job_id = uuid.uuid4()\n            chain.append(scan_faces, request.user, job_id, True)\n            chain.run()\n            return Response({\"status\": True, \"job_id\": job_id})\n        except BaseException:\n            logger.exception(\"An Error occurred\")\n            return Response({\"status\": False})\n\n\nclass TrainFaceView(APIView):\n    @staticmethod\n    def _train_faces(request):\n        chain = Chain()\n        if not do_all_models_exist():\n            chain.append(download_models, request.user)\n        try:\n            job_id = uuid.uuid4()\n            chain.append(generate_face_embeddings, request.user, uuid.uuid4())\n            chain.append(cluster_all_faces, request.user, job_id)\n            chain.run()\n            return Response({\"status\": True, \"job_id\": job_id})\n        except BaseException:\n            logger.exception()\n            return Response({\"status\": False})\n\n    def post(self, request, format=None):\n        return self._train_faces(request)\n\n\nclass FaceListView(ListViewSet):\n    serializer_class = PersonFaceListSerializer\n    pagination_class = RegularResultsSetPagination\n\n    def get_queryset(self):\n        personid = self.request.query_params.get(\"person\", \"0\")\n\n        if personid == \"0\":\n            personid = None\n\n        analysis_method = self.request.query_params.get(\"analysis_method\", \"clustering\")\n        min_confidence = float(self.request.query_params.get(\"min_confidence\", 0))\n\n        if (\n            self.request.query_params.get(\"inferred\", \"\").lower() == \"false\"\n            and personid\n        ):\n            analysis_method = None\n        if analysis_method == \"classification\":\n            conditional_filter = Q(person=None)\n            if not personid:\n                conditional_filter = conditional_filter & Q(\n                    classification_probability__lte=min_confidence\n                )\n            else:\n                conditional_filter = (\n                    conditional_filter\n                    & Q(classification_person=personid)\n                    & Q(classification_probability__gte=min_confidence)\n                )\n            order_by = [\"-classification_probability\", \"id\"]\n        if analysis_method == \"clustering\":\n            if not personid:\n                conditional_filter = Q(person=None) & (\n                    Q(cluster_person=None) | Q(cluster_probability__lte=min_confidence)\n                )\n            else:\n                conditional_filter = (\n                    Q(cluster_person=personid)\n                    & Q(person=None)\n                    & Q(cluster_probability__gte=min_confidence)\n                )\n            order_by = [\"-cluster_probability\", \"id\"]\n        if not analysis_method:\n            conditional_filter = Q(person=personid)\n            order_by = [\"-id\"]\n        if self.request.query_params.get(\"order_by\", \"\").lower() == \"date\":\n            order_by = [\"photo__exif_timestamp\", *order_by]\n        return (\n            Face.objects.filter(\n                Q(photo__owner=self.request.user),\n                Q(deleted=False),\n                conditional_filter,\n            )\n            .annotate(analysis_method=Value(analysis_method, output_field=CharField()))\n            .prefetch_related(\"photo\")\n            .order_by(*order_by)\n        )\n\n    @extend_schema(\n        parameters=[\n            OpenApiParameter(\"person\", OpenApiTypes.STR),\n            OpenApiParameter(\"inferred\", OpenApiTypes.BOOL),\n            OpenApiParameter(\"order_by\", OpenApiTypes.STR),\n        ],\n    )\n    def list(self, *args, **kwargs):\n        return super().list(*args, **kwargs)\n\n\nclass FaceIncompleteListViewSet(ListViewSet):\n    serializer_class = IncompletePersonFaceListSerializer\n    pagination_class = None\n\n    def get_queryset(self):\n        inferred = self.request.query_params.get(\"inferred\", \"\").lower() == \"true\"\n        analysis_method = self.request.query_params.get(\"analysis_method\", \"clustering\")\n        min_confidence = float(self.request.query_params.get(\"min_confidence\", 0))\n\n        queryset = Person.objects.filter(cluster_owner=self.request.user)\n        if inferred:\n            if analysis_method == \"classification\":\n                conditional_count = Count(\n                    Case(\n                        When(\n                            Q(classification_faces__deleted=False)\n                            & Q(classification_faces__person=None)\n                            & Q(\n                                classification_faces__classification_probability__gte=min_confidence\n                            ),\n                            then=1,\n                        ),\n                        output_field=IntegerField(),\n                    )\n                )\n            if analysis_method == \"clustering\":\n                conditional_count = Count(\n                    Case(\n                        When(\n                            Q(cluster_faces__deleted=False)\n                            & Q(cluster_faces__person=None)\n                            & Q(cluster_faces__cluster_probability__gte=min_confidence),\n                            then=1,\n                        ),\n                        output_field=IntegerField(),\n                    )\n                )\n        else:\n            queryset = queryset.filter(kind=Person.KIND_USER)\n            conditional_count = Count(\n                Case(\n                    When(\n                        Q(faces__deleted=False),\n                        then=1,\n                    ),\n                    output_field=IntegerField(),\n                )\n            )\n\n        queryset = (\n            queryset.annotate(viewable_face_count=conditional_count)\n            .filter(viewable_face_count__gt=0)\n            .order_by(\"name\")\n        )\n\n        return queryset\n\n    @extend_schema(\n        parameters=[\n            OpenApiParameter(\"inferred\", OpenApiTypes.BOOL),\n        ],\n    )\n    def list(self, *args, **kwargs):\n        queryset = self.get_queryset()\n\n        serializer = self.get_serializer(queryset, many=True)\n        real_persons = serializer.data\n\n        min_confidence = float(self.request.query_params.get(\"min_confidence\", 0))\n\n        if self.request.query_params.get(\"inferred\", \"\").lower() == \"true\":\n            if (\n                self.request.query_params.get(\"analysis_method\", \"clustering\")\n                == \"classification\"\n            ):\n                unknown_faces_count = Face.objects.filter(\n                    Q(deleted=False)\n                    & Q(person=None)\n                    & Q(photo__owner=self.request.user)\n                    & Q(classification_probability__lte=min_confidence),\n                ).count()\n            else:\n                unknown_faces_count = Face.objects.filter(\n                    (\n                        Q(cluster_person=None)\n                        | Q(cluster_probability__lte=min_confidence)\n                    )\n                    & Q(deleted=False)\n                    & Q(person=None)\n                    & Q(photo__owner=self.request.user),\n                ).count()\n        else:\n            unknown_faces_count = Face.objects.filter(\n                person=None, deleted=False, photo__owner=self.request.user\n            ).count()\n\n        if unknown_faces_count > 0:\n            unknown_person = {\n                \"id\": 0,\n                \"name\": \"Unknown - Other\",\n                \"face_count\": unknown_faces_count,\n                \"kind\": Person.UNKNOWN_PERSON_NAME,\n            }\n            real_persons.append(unknown_person)\n\n        return Response(real_persons, status=status.HTTP_200_OK)\n\n\nclass SetFacePersonLabel(APIView):\n    def post(self, request, format=None):\n        data = dict(request.data)\n        person = None\n        cluster_person = None\n        classification_person = None\n        if data[\"person_name\"] != Person.UNKNOWN_PERSON_NAME:\n            person = get_or_create_person(\n                name=data[\"person_name\"], owner=self.request.user, kind=Person.KIND_USER\n            )\n\n        faces = Face.objects.in_bulk(data[\"face_ids\"])\n\n        updated = []\n        not_updated = []\n        for face in faces.values():\n            if face.photo.owner == request.user:\n                face.person = person\n                if not person:\n                    face.cluster_person = cluster_person\n                    face.classification_person = classification_person\n                face.save()\n                updated.append(FaceListSerializer(face).data)\n            else:\n                not_updated.append(FaceListSerializer(face).data)\n        if person:\n            person._calculate_face_count()\n            person._set_default_cover_photo()\n        search_instance, created = PhotoSearch.objects.get_or_create(photo=face.photo)\n        search_instance.recreate_search_captions()\n        search_instance.save()\n\n        # Write face regions to image files if user preference is enabled\n        if request.user.save_face_tags_to_disk:\n            use_sidecar = (\n                request.user.save_metadata_to_disk == User.SaveMetadata.SIDECAR_FILE\n            )\n            updated_photos = {\n                f.photo for f in faces.values() if f.photo.owner == request.user\n            }\n            for photo in updated_photos:\n                try:\n                    photo._save_metadata(\n                        use_sidecar=use_sidecar,\n                        metadata_types=[\"face_tags\"],\n                    )\n                except Exception:\n                    logger.exception(\n                        f\"Failed to write face tags for photo {photo.image_hash}\"\n                    )\n\n        return Response(\n            {\n                \"status\": True,\n                \"results\": updated,\n                \"updated\": updated,\n                \"not_updated\": not_updated,\n            }\n        )\n\n\nclass DeleteFaces(APIView):\n    def post(self, request, format=None):\n        data = dict(request.data)\n        faces = Face.objects.in_bulk(data[\"face_ids\"])\n\n        deleted = []\n        not_deleted = []\n        for face in faces.values():\n            if face.photo.owner == request.user:\n                deleted.append(face.image.url)\n                face.deleted = True\n                face.save()\n            else:\n                not_deleted.append(face.image.url)\n\n        return Response(\n            {\n                \"status\": True,\n                \"results\": deleted,\n                \"not_deleted\": not_deleted,\n                \"deleted\": deleted,\n            }\n        )\n"
  },
  {
    "path": "api/views/geocode.py",
    "content": "from rest_framework.response import Response\nfrom rest_framework.views import APIView\n\nfrom api.geocode.geocode import search_location\n\n\nclass GeocodeSearchView(APIView):\n    \"\"\"Search for locations by name/address.\"\"\"\n\n    def get(self, request, format=None):\n        query = request.query_params.get(\"q\", \"\").strip()\n        if not query:\n            return Response([])\n        limit = int(request.query_params.get(\"limit\", 5))\n        results = search_location(query, limit=limit)\n        return Response(results)\n\n"
  },
  {
    "path": "api/views/jobs.py",
    "content": "from django.db.models import Prefetch\nfrom rest_framework import viewsets\nfrom rest_framework.response import Response\nfrom rest_framework.views import APIView\n\nfrom api.models import LongRunningJob, User\nfrom api.serializers.job import LongRunningJobSerializer\nfrom api.views.pagination import TinyResultsSetPagination\n\n\nclass LongRunningJobViewSet(viewsets.ModelViewSet):\n    queryset = (\n        LongRunningJob.objects.prefetch_related(\n            Prefetch(\n                \"started_by\",\n                queryset=User.objects.only(\"id\", \"username\", \"first_name\", \"last_name\"),\n            ),\n        )\n        .all()\n        .order_by(\"-started_at\")\n    )\n    serializer_class = LongRunningJobSerializer\n    pagination_class = TinyResultsSetPagination\n\n\nclass QueueAvailabilityView(APIView):\n    def get(self, request, format=None):\n        job_detail = None\n\n        running_job = (\n            LongRunningJob.objects.filter(finished=False).order_by(\"-started_at\").last()\n        )\n        if running_job:\n            job_detail = LongRunningJobSerializer(running_job).data\n\n        return Response(\n            {\n                \"status\": True,\n                \"queue_can_accept_job\": job_detail is None,\n                \"job_detail\": job_detail,\n            }\n        )\n"
  },
  {
    "path": "api/views/pagination.py",
    "content": "from rest_framework.pagination import PageNumberPagination\n\n\nclass HugeResultsSetPagination(PageNumberPagination):\n    page_size = 2500\n    page_size_query_param = \"page_size\"\n    max_page_size = 5000\n\n\nclass StandardResultsSetPagination(PageNumberPagination):\n    page_size = 1000\n    page_size_query_param = \"page_size\"\n    max_page_size = 2000\n\n\nclass RegularResultsSetPagination(PageNumberPagination):\n    page_size = 100\n    page_size_query_param = \"page_size\"\n    max_page_size = 200\n\n\nclass TinyResultsSetPagination(PageNumberPagination):\n    page_size = 20\n    page_size_query_param = \"page_size\"\n    max_page_size = 50\n"
  },
  {
    "path": "api/views/photo_filters.py",
    "content": "\"\"\"\nPhoto filtering utilities for bulk operations.\n\nThis module provides reusable functions to build photo querysets from filter parameters,\nenabling server-side \"Select All\" operations without sending individual photo IDs.\n\"\"\"\n\nfrom django.db.models import Q\n\nfrom api.models import Photo\n\n\ndef build_photo_queryset(user, params: dict):\n    \"\"\"Build a Photo queryset from filter parameters.\n\n    This function reuses the same filtering logic as AlbumDateListViewSet to ensure\n    consistency between what users see in the UI and what bulk operations affect.\n\n    Args:\n        user: The authenticated user making the request\n        params: Dictionary of filter parameters:\n            - favorite: bool - Filter by favorite status (rating >= user.favorite_min_rating)\n            - public: bool - Filter by public photos only\n            - hidden: bool - Filter by hidden photos\n            - in_trashcan: bool - Filter by trashed photos\n            - video: bool - Filter by videos only\n            - photo: bool - Filter by photos only (non-videos)\n            - person: int - Filter by person ID (faces)\n            - folder: str - Filter by folder path prefix\n            - username: str - Filter by owner username (for public photos)\n            - show_all_stack_photos: bool - If True, show all photos in stacks (default: False)\n\n    Returns:\n        QuerySet[Photo]: Filtered photo queryset\n    \"\"\"\n    filters = [Q(thumbnail__aspect_ratio__isnull=False)]\n\n    # Owner filter - default to current user unless viewing public photos\n    if not params.get(\"public\"):\n        filters.append(Q(owner=user))\n\n    # Favorite filter\n    if params.get(\"favorite\"):\n        min_rating = user.favorite_min_rating\n        filters.append(Q(rating__gte=min_rating))\n\n    # Public photos filter\n    if params.get(\"public\"):\n        if params.get(\"username\"):\n            filters.append(Q(owner__username=params[\"username\"]))\n        filters.append(Q(public=True))\n\n    # Hidden filter\n    if params.get(\"hidden\"):\n        filters.append(Q(hidden=True))\n    else:\n        filters.append(Q(hidden=False))\n\n    # Video/photo type filter\n    if params.get(\"video\"):\n        filters.append(Q(video=True))\n    elif params.get(\"photo\"):\n        filters.append(Q(video=False))\n\n    # Trashcan filter\n    if params.get(\"in_trashcan\"):\n        filters.append(Q(in_trashcan=True) & Q(removed=False))\n    else:\n        filters.append(Q(in_trashcan=False))\n\n    # Person/face filter\n    if params.get(\"person\"):\n        filters.append(Q(faces__person__id=params[\"person\"]))\n\n    # Folder path filter\n    if params.get(\"folder\"):\n        filters.append(Q(files__path__startswith=params[\"folder\"]))\n\n    # Stack filtering: Show photos that are either:\n    # 1. Not in any stack, OR\n    # 2. The primary photo of their stack\n    # This applies stacking behavior for ALL stack types (manual, burst, etc.)\n    # Non-primary photos are hidden in the timeline but accessible via stack expansion\n    if not params.get(\"show_all_stack_photos\"):\n        filters.append(\n            Q(stacks__isnull=True) |\n            Q(primary_in_stack__isnull=False)\n        )\n\n    return Photo.objects.filter(*filters).distinct()\n\n"
  },
  {
    "path": "api/views/photo_metadata.py",
    "content": "\"\"\"\nAPI views for PhotoMetadata management.\n\nProvides endpoints for:\n- Viewing detailed metadata\n- Editing metadata with history tracking\n- Reverting metadata changes\n- Viewing edit history\n\"\"\"\n\nfrom django.shortcuts import get_object_or_404\nfrom drf_spectacular.utils import extend_schema, OpenApiParameter\nfrom rest_framework.decorators import action\nfrom rest_framework.permissions import IsAuthenticated\nfrom rest_framework.response import Response\nfrom rest_framework.views import APIView\nfrom rest_framework.viewsets import ViewSet\n\nfrom api.models import Photo\nfrom api.models.photo_metadata import MetadataEdit, PhotoMetadata\nfrom api.serializers.photo_metadata import (\n    MetadataEditSerializer,\n    PhotoMetadataSerializer,\n    PhotoMetadataUpdateSerializer,\n)\n\n\nclass PhotoMetadataViewSet(ViewSet):\n    \"\"\"\n    ViewSet for photo metadata operations.\n    \n    Provides:\n    - GET /api/photos/{photo_id}/metadata/ - Get full metadata\n    - PATCH /api/photos/{photo_id}/metadata/ - Update metadata (creates history)\n    - GET /api/photos/{photo_id}/metadata/history/ - Get edit history\n    - POST /api/photos/{photo_id}/metadata/revert/{edit_id}/ - Revert a change\n    \"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def _get_photo(self, request, photo_id: str) -> Photo:\n        \"\"\"Get photo by ID or image_hash, checking permissions.\"\"\"\n        # UUID format is 36 chars with 4 hyphens (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)\n        # MD5 hashes are 32 hex chars without hyphens\n        # Python's uuid.UUID() accepts both, so we need to check format explicitly\n        is_uuid_format = len(photo_id) == 36 and photo_id.count(\"-\") == 4\n        \n        if is_uuid_format:\n            photo = get_object_or_404(Photo, pk=photo_id)\n        else:\n            photo = get_object_or_404(Photo, image_hash=photo_id)\n        \n        # Check ownership\n        if photo.owner != request.user and not request.user.is_staff:\n            from rest_framework.exceptions import PermissionDenied\n            raise PermissionDenied(\"You don't have permission to access this photo's metadata.\")\n        \n        return photo\n\n    def _get_or_create_metadata(self, photo: Photo) -> PhotoMetadata:\n        \"\"\"Get or create PhotoMetadata for a photo.\"\"\"\n        metadata, created = PhotoMetadata.objects.get_or_create(\n            photo=photo,\n            defaults={\n                # If creating new, leave fields empty - they'll be populated on next scan\n                \"date_taken\": photo.exif_timestamp,\n                \"gps_latitude\": photo.exif_gps_lat,\n                \"gps_longitude\": photo.exif_gps_lon,\n                \"rating\": photo.rating,\n                \"source\": PhotoMetadata.Source.EMBEDDED,\n            }\n        )\n        return metadata\n\n    @extend_schema(\n        description=\"Get full structured metadata for a photo.\",\n        responses={200: PhotoMetadataSerializer},\n    )\n    def retrieve(self, request, photo_id: str):\n        \"\"\"Get full metadata for a photo.\"\"\"\n        photo = self._get_photo(request, photo_id)\n        metadata = self._get_or_create_metadata(photo)\n        serializer = PhotoMetadataSerializer(metadata)\n        return Response(serializer.data)\n\n    @extend_schema(\n        description=\"Update photo metadata. Changes are tracked in edit history.\",\n        request=PhotoMetadataUpdateSerializer,\n        responses={200: PhotoMetadataSerializer},\n    )\n    def partial_update(self, request, photo_id: str):\n        \"\"\"Update metadata with change tracking.\"\"\"\n        photo = self._get_photo(request, photo_id)\n        metadata = self._get_or_create_metadata(photo)\n        \n        serializer = PhotoMetadataUpdateSerializer(\n            metadata,\n            data=request.data,\n            partial=True,\n            context={\"request\": request}\n        )\n        serializer.is_valid(raise_exception=True)\n        serializer.save()\n        \n        # Return full metadata\n        return Response(PhotoMetadataSerializer(metadata).data)\n\n    @extend_schema(\n        description=\"Get edit history for a photo's metadata.\",\n        responses={200: MetadataEditSerializer(many=True)},\n    )\n    @action(detail=True, methods=[\"get\"], url_path=\"history\")\n    def history(self, request, photo_id: str):\n        \"\"\"Get edit history for a photo.\"\"\"\n        photo = self._get_photo(request, photo_id)\n        edits = MetadataEdit.objects.filter(photo=photo).order_by(\"-created_at\")\n        \n        # Pagination\n        page = int(request.query_params.get(\"page\", 1))\n        page_size = int(request.query_params.get(\"page_size\", 20))\n        start = (page - 1) * page_size\n        end = start + page_size\n        \n        serializer = MetadataEditSerializer(edits[start:end], many=True)\n        return Response({\n            \"results\": serializer.data,\n            \"count\": edits.count(),\n            \"page\": page,\n            \"page_size\": page_size,\n        })\n\n    @extend_schema(\n        description=\"Revert a specific metadata edit.\",\n        responses={200: PhotoMetadataSerializer},\n    )\n    @action(detail=True, methods=[\"post\"], url_path=r\"revert/(?P<edit_id>[^/.]+)\")\n    def revert(self, request, photo_id: str, edit_id: str):\n        \"\"\"Revert a specific metadata edit.\"\"\"\n        photo = self._get_photo(request, photo_id)\n        edit = get_object_or_404(MetadataEdit, pk=edit_id, photo=photo)\n        \n        # Get or create metadata\n        metadata = self._get_or_create_metadata(photo)\n        \n        # Restore the old value\n        field_name = edit.field_name\n        old_value = edit.old_value\n        current_value = getattr(metadata, field_name, None)\n        \n        # Create a new edit record for the revert\n        MetadataEdit.objects.create(\n            photo=photo,\n            user=request.user,\n            field_name=field_name,\n            old_value=current_value,\n            new_value=old_value,\n        )\n        \n        # Apply the revert\n        setattr(metadata, field_name, old_value)\n        metadata.version += 1\n        metadata.save()\n        \n        return Response(PhotoMetadataSerializer(metadata).data)\n\n    @extend_schema(\n        description=\"Revert all edits and restore original embedded metadata.\",\n        responses={200: PhotoMetadataSerializer},\n    )\n    @action(detail=True, methods=[\"post\"], url_path=\"revert-all\")\n    def revert_all(self, request, photo_id: str):\n        \"\"\"Revert all edits and restore original metadata from file by re-extracting EXIF.\"\"\"\n        photo = self._get_photo(request, photo_id)\n        \n        try:\n            metadata = photo.metadata\n            # Record the revert\n            MetadataEdit.objects.create(\n                photo=photo,\n                user=request.user,\n                field_name=\"_all\",\n                old_value={\"action\": \"revert_all\"},\n                new_value={\"source\": \"embedded\"},\n            )\n            \n            # Re-extract EXIF data from the file to restore original values\n            # This will update PhotoMetadata with fresh data from the image file\n            PhotoMetadata.extract_exif_data(photo, commit=True)\n            \n            # Refresh metadata from database\n            metadata.refresh_from_db()\n            metadata.source = PhotoMetadata.Source.EMBEDDED\n            metadata.version += 1\n            metadata.save()\n            \n        except PhotoMetadata.DoesNotExist:\n            # If no metadata exists, extract it fresh\n            metadata = PhotoMetadata.extract_exif_data(photo, commit=True)\n        \n        return Response(PhotoMetadataSerializer(metadata).data)\n\n\nclass BulkMetadataView(APIView):\n    \"\"\"\n    Bulk metadata operations for multiple photos.\n    \"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    @extend_schema(\n        description=\"Get metadata summary for multiple photos.\",\n        parameters=[\n            OpenApiParameter(\"photo_ids\", str, description=\"Comma-separated photo IDs or image hashes\"),\n        ],\n    )\n    def get(self, request):\n        \"\"\"Get metadata summary for multiple photos.\"\"\"\n        photo_ids = request.query_params.get(\"photo_ids\", \"\").split(\",\")\n        photo_ids = [pid.strip() for pid in photo_ids if pid.strip()]\n        \n        if not photo_ids:\n            return Response({\"error\": \"No photo_ids provided\"}, status=400)\n        \n        if len(photo_ids) > 100:\n            return Response({\"error\": \"Maximum 100 photos per request\"}, status=400)\n        \n        # Get photos (by UUID or image_hash)\n        from django.db.models import Q\n        import uuid\n        \n        uuid_ids = []\n        hash_ids = []\n        for pid in photo_ids:\n            # UUID format: 36 chars with 4 hyphens (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)\n            # MD5 hashes are 32 hex chars without hyphens\n            # Note: uuid.UUID() accepts both formats, so we must check explicitly\n            is_uuid_format = len(pid) == 36 and pid.count(\"-\") == 4\n            if is_uuid_format:\n                try:\n                    uuid.UUID(pid)\n                    uuid_ids.append(pid)\n                except (ValueError, AttributeError):\n                    hash_ids.append(pid)\n            else:\n                hash_ids.append(pid)\n        \n        photos = Photo.objects.filter(\n            Q(pk__in=uuid_ids) | Q(image_hash__in=hash_ids),\n            owner=request.user,\n        ).select_related(\"metadata\")\n        \n        results = {}\n        for photo in photos:\n            try:\n                metadata = photo.metadata\n                results[str(photo.id)] = {\n                    \"camera\": metadata.camera_display,\n                    \"lens\": metadata.lens_display,\n                    \"date_taken\": metadata.date_taken,\n                    \"has_location\": metadata.has_location,\n                    \"rating\": metadata.rating,\n                }\n            except PhotoMetadata.DoesNotExist:\n                # No metadata exists - return minimal info with None values\n                results[str(photo.id)] = {\n                    \"camera\": None,\n                    \"lens\": None,\n                    \"date_taken\": photo.exif_timestamp,\n                    \"has_location\": photo.exif_gps_lat is not None,\n                    \"rating\": photo.rating,\n                }\n        \n        return Response(results)\n\n    @extend_schema(\n        description=\"Update metadata for multiple photos.\",\n    )\n    def patch(self, request):\n        \"\"\"Bulk update metadata for multiple photos.\"\"\"\n        photo_ids = request.data.get(\"photo_ids\", [])\n        updates = request.data.get(\"updates\", {})\n        \n        if not photo_ids:\n            return Response({\"error\": \"No photo_ids provided\"}, status=400)\n        \n        if not updates:\n            return Response({\"error\": \"No updates provided\"}, status=400)\n        \n        if len(photo_ids) > 100:\n            return Response({\"error\": \"Maximum 100 photos per request\"}, status=400)\n        \n        # Validate allowed fields\n        allowed_fields = {\"title\", \"caption\", \"keywords\", \"rating\", \"copyright\", \"creator\"}\n        invalid_fields = set(updates.keys()) - allowed_fields\n        if invalid_fields:\n            return Response(\n                {\"error\": f\"Invalid fields: {invalid_fields}. Allowed: {allowed_fields}\"},\n                status=400\n            )\n        \n        # Get photos\n        from django.db.models import Q\n        import uuid\n        \n        uuid_ids = []\n        hash_ids = []\n        for pid in photo_ids:\n            pid_str = str(pid)\n            # UUID format: 36 chars with 4 hyphens\n            is_uuid_format = len(pid_str) == 36 and pid_str.count(\"-\") == 4\n            if is_uuid_format:\n                try:\n                    uuid.UUID(pid_str)\n                    uuid_ids.append(pid)\n                except (ValueError, AttributeError):\n                    hash_ids.append(pid)\n            else:\n                hash_ids.append(pid)\n        \n        photos = Photo.objects.filter(\n            Q(pk__in=uuid_ids) | Q(image_hash__in=hash_ids),\n            owner=request.user,\n        )\n        \n        updated_count = 0\n        for photo in photos:\n            metadata, _ = PhotoMetadata.objects.get_or_create(photo=photo)\n            \n            for field_name, new_value in updates.items():\n                old_value = getattr(metadata, field_name, None)\n                if old_value != new_value:\n                    MetadataEdit.objects.create(\n                        photo=photo,\n                        user=request.user,\n                        field_name=field_name,\n                        old_value=old_value,\n                        new_value=new_value,\n                    )\n                    setattr(metadata, field_name, new_value)\n            \n            metadata.source = PhotoMetadata.Source.USER_EDIT\n            metadata.version += 1\n            metadata.save()\n            updated_count += 1\n        \n        return Response({\n            \"updated_count\": updated_count,\n            \"message\": f\"Updated metadata for {updated_count} photos\",\n        })\n"
  },
  {
    "path": "api/views/photos.py",
    "content": "from django.db.models import Count, Prefetch, Q\nfrom drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema\nfrom rest_framework import filters, status, viewsets\nfrom rest_framework.decorators import action\nfrom rest_framework.permissions import IsAdminUser\nfrom rest_framework.response import Response\nfrom rest_framework.views import APIView\n\nfrom api.models import AlbumUser, File, Photo, User\nfrom api.models.photo_stack import PhotoStack\nfrom api.models.person import Person\nfrom api.models.photo_caption import PhotoCaption\nfrom api.permissions import IsOwnerOrReadOnly, IsPhotoOrAlbumSharedTo\nfrom api.serializers.album_user import AlbumUserListSerializer\nfrom api.serializers.photos import (\n    PhotoDetailsSummarySerializer,\n    PhotoEditSerializer,\n    PhotoSerializer,\n    PhotoSummarySerializer,\n)\nfrom api.util import logger\nfrom api.views.custom_api_view import ListViewSet\nfrom api.views.pagination import (\n    HugeResultsSetPagination,\n    RegularResultsSetPagination,\n    StandardResultsSetPagination,\n)\n\n\nclass RecentlyAddedPhotoListViewSet(ListViewSet):\n    serializer_class = PhotoSummarySerializer\n    pagination_class = HugeResultsSetPagination\n\n    def get_queryset(self):\n        latest_photo = self._get_latest_photo()\n        if latest_photo is None:\n            return Photo.objects.none()\n        latest_date = latest_photo.added_on\n\n        # Prefetch stacks with type filter and annotated photo count\n        # to avoid N+1 queries in PhotoSummarySerializer.get_stacks()\n        valid_stack_types = PhotoStack.VALID_STACK_TYPES + [\n            PhotoStack.StackType.RAW_JPEG_PAIR,\n            PhotoStack.StackType.LIVE_PHOTO,\n        ]\n        stacks_prefetch = Prefetch(\n            \"stacks\",\n            queryset=PhotoStack.objects.filter(\n                stack_type__in=valid_stack_types\n            ).annotate(photo_count_annotation=Count(\"photos\")),\n        )\n\n        queryset = (\n            Photo.visible.filter(\n                Q(owner=self.request.user)\n                & Q(thumbnail__aspect_ratio__isnull=False)\n                & Q(added_on__date=latest_date.date())\n            )\n            .select_related(\"thumbnail\", \"search_instance\", \"main_file\")\n            .prefetch_related(\n                Prefetch(\n                    \"owner\",\n                    queryset=User.objects.only(\n                        \"id\", \"username\", \"first_name\", \"last_name\"\n                    ),\n                ),\n                Prefetch(\n                    \"main_file__embedded_media\",\n                    queryset=File.objects.only(\"hash\"),\n                ),\n                stacks_prefetch,\n                \"files\",  # For get_has_raw_variant()\n            )\n            .only(\n                \"image_hash\",\n                \"thumbnail__aspect_ratio\",\n                \"thumbnail__dominant_color\",\n                \"video\",\n                \"main_file\",\n                \"search_instance__search_location\",\n                \"rating\",\n                \"owner\",\n                \"exif_gps_lat\",\n                \"exif_gps_lon\",\n                \"removed\",\n                \"in_trashcan\",\n                \"exif_timestamp\",\n                \"video_length\",\n            )\n            .order_by(\"-added_on\")\n        )\n        return queryset\n\n    def list(self, *args, **kwargs):\n        queryset = self.get_queryset()\n        latest_photo = self._get_latest_photo()\n        latest_date = latest_photo.added_on if latest_photo else None\n        serializer = PhotoSummarySerializer(queryset, many=True)\n        return Response({\"date\": latest_date, \"results\": serializer.data})\n\n    def _get_latest_photo(self):\n        if not hasattr(self, \"_latest_photo\"):\n            self._latest_photo = (\n                Photo.visible.filter(Q(owner=self.request.user))\n                .only(\"added_on\")\n                .order_by(\"-added_on\")\n                .first()\n            )\n        return self._latest_photo\n\n\nclass NoTimestampPhotoViewSet(ListViewSet):\n    serializer_class = PhotoSummarySerializer\n    pagination_class = RegularResultsSetPagination\n    filter_backends = (filters.SearchFilter,)\n    search_fields = [\n        \"search_instance__search_captions\",\n        \"search_instance__search_location\",\n        \"faces__person__name\",\n    ]\n\n    def get_queryset(self):\n        return (\n            Photo.visible.filter(Q(exif_timestamp=None) & Q(owner=self.request.user))\n            .select_related(\"thumbnail\", \"search_instance\", \"main_file\")\n            .prefetch_related(\n                Prefetch(\n                    \"owner\",\n                    queryset=User.objects.only(\n                        \"id\", \"username\", \"first_name\", \"last_name\"\n                    ),\n                ),\n                Prefetch(\n                    \"main_file__embedded_media\",\n                    queryset=File.objects.only(\"hash\"),\n                ),\n            )\n            .only(\n                \"image_hash\",\n                \"thumbnail__aspect_ratio\",\n                \"thumbnail__dominant_color\",\n                \"video\",\n                \"main_file\",\n                \"search_instance__search_location\",\n                \"rating\",\n                \"owner\",\n                \"exif_gps_lat\",\n                \"exif_gps_lon\",\n                \"removed\",\n                \"in_trashcan\",\n                \"exif_timestamp\",\n                \"video_length\",\n            )\n            .order_by(\"added_on\")\n        )\n\n    def list(self, *args, **kwargs):\n        return super().list(*args, **kwargs)\n\n\nclass SetPhotosDeleted(APIView):\n    def post(self, request, format=None):\n        from api.views.photo_filters import build_photo_queryset\n\n        data = dict(request.data)\n        val_deleted = data[\"deleted\"]\n\n        # NEW: Support select_all mode for bulk operations\n        if data.get(\"select_all\"):\n            query_params = data.get(\"query\", {})\n            excluded_hashes = data.get(\"excluded_hashes\", [])\n\n            photos_qs = build_photo_queryset(request.user, query_params)\n            if excluded_hashes:\n                photos_qs = photos_qs.exclude(image_hash__in=excluded_hashes)\n\n            # If restoring from trash, reset stacks to pending for re-evaluation\n            if not val_deleted:\n                from api.models.stack_review import StackReview\n                from api.models.photo_stack import PhotoStack\n\n                # Get stack IDs from photos that have stacks (ManyToMany)\n                stack_ids = set(\n                    PhotoStack.objects.filter(photos__in=photos_qs).values_list(\n                        \"id\", flat=True\n                    )\n                )\n                if stack_ids:\n                    StackReview.objects.filter(\n                        stack_id__in=stack_ids, decision=StackReview.Decision.RESOLVED\n                    ).update(decision=StackReview.Decision.PENDING)\n                    logger.info(\n                        f\"Reset {len(stack_ids)} photo stacks to pending after restore\"\n                    )\n\n            count = photos_qs.update(in_trashcan=val_deleted)\n\n            if val_deleted:\n                logger.info(\n                    f\"{count} photos were moved to trash via select_all for user {request.user.id}.\"\n                )\n            else:\n                logger.info(\n                    f\"{count} photos were restored from trash via select_all for user {request.user.id}.\"\n                )\n\n            return Response({\"status\": True, \"count\": count})\n\n        # Existing logic for individual hashes\n        image_hashes = data[\"image_hashes\"]\n\n        # Get all photos with related data in one query to prevent N+1 queries from serializer\n        photos = (\n            Photo.objects.filter(image_hash__in=image_hashes, owner=request.user)\n            .select_related(\"owner\", \"thumbnail\", \"main_file\")\n            .prefetch_related(\n                \"files\", \"faces__person\", \"shared_to\", \"main_file__embedded_media\"\n            )\n        )\n\n        # Also prefetch search and caption instances if they exist\n        photos = photos.select_related(\"search_instance\", \"caption_instance\")\n\n        # Group photos by whether they need updating\n        photos_to_update = []\n        updated_data = []\n        not_updated_data = []\n\n        for photo in photos:\n            if photo.in_trashcan != val_deleted:\n                photos_to_update.append(photo.image_hash)\n                photo.in_trashcan = val_deleted\n                updated_data.append(PhotoSerializer(photo).data)\n            else:\n                not_updated_data.append(PhotoSerializer(photo).data)\n\n        # Bulk update in one query\n        if photos_to_update:\n            Photo.objects.filter(\n                image_hash__in=photos_to_update, owner=request.user\n            ).update(in_trashcan=val_deleted)\n\n            # If restoring from trash, reset stacks to pending for re-evaluation\n            if not val_deleted:\n                from api.models.stack_review import StackReview\n                from api.models.photo_stack import PhotoStack\n\n                # Get stack IDs from photos that have stacks (ManyToMany)\n                stack_ids = set(\n                    PhotoStack.objects.filter(\n                        photos__image_hash__in=photos_to_update\n                    ).values_list(\"id\", flat=True)\n                )\n                if stack_ids:\n                    StackReview.objects.filter(\n                        stack_id__in=stack_ids, decision=StackReview.Decision.RESOLVED\n                    ).update(decision=StackReview.Decision.PENDING)\n                    logger.info(\n                        f\"Reset {len(stack_ids)} photo stacks to pending after restore\"\n                    )\n\n        # Handle missing photos\n        found_hashes = {photo.image_hash for photo in photos}\n        missing_hashes = set(image_hashes) - found_hashes\n        for missing_hash in missing_hashes:\n            logger.warning(\n                f\"Could not set photo {missing_hash} to deleted. It does not exist or is not owned by user.\"\n            )\n\n        if val_deleted:\n            logger.info(\n                f\"{len(updated_data)} photos were moved to trash. {len(not_updated_data)} photos were already in trash.\"\n            )\n        else:\n            logger.info(\n                f\"{len(updated_data)} photos were restored from trash. {len(not_updated_data)} photos were already restored.\"\n            )\n        return Response(\n            {\n                \"status\": True,\n                \"results\": updated_data,\n                \"updated\": updated_data,\n                \"not_updated\": not_updated_data,\n            }\n        )\n\n\nclass SetPhotosFavorite(APIView):\n    def post(self, request, format=None):\n        from api.views.photo_filters import build_photo_queryset\n\n        data = dict(request.data)\n        val_favorite = data[\"favorite\"]\n        user = request.user\n\n        # NEW: Support select_all mode for bulk operations\n        if data.get(\"select_all\"):\n            query_params = data.get(\"query\", {})\n            excluded_hashes = data.get(\"excluded_hashes\", [])\n\n            photos_qs = build_photo_queryset(request.user, query_params)\n            if excluded_hashes:\n                photos_qs = photos_qs.exclude(image_hash__in=excluded_hashes)\n\n            if val_favorite:\n                # Only update photos that aren't already favorites\n                count = photos_qs.filter(rating__lt=user.favorite_min_rating).update(\n                    rating=user.favorite_min_rating\n                )\n                logger.info(\n                    f\"{count} photos were added to favorites via select_all for user {user.id}.\"\n                )\n            else:\n                # Only update photos that are currently favorites\n                count = photos_qs.filter(rating__gte=user.favorite_min_rating).update(\n                    rating=0\n                )\n                logger.info(\n                    f\"{count} photos were removed from favorites via select_all for user {user.id}.\"\n                )\n\n            return Response({\"status\": True, \"count\": count})\n\n        # Existing logic for individual hashes\n        image_hashes = data[\"image_hashes\"]\n\n        # Get all photos with related data in one query to prevent N+1 queries from serializer\n        photos = (\n            Photo.objects.filter(image_hash__in=image_hashes, owner=request.user)\n            .select_related(\n                \"owner\", \"thumbnail\", \"main_file\", \"search_instance\", \"caption_instance\"\n            )\n            .prefetch_related(\n                \"files\", \"faces__person\", \"shared_to\", \"main_file__embedded_media\"\n            )\n        )\n\n        # Group photos by whether they need updating\n        photos_to_favorite = []\n        photos_to_unfavorite = []\n        updated_data = []\n        not_updated_data = []\n\n        for photo in photos:\n            if val_favorite and photo.rating < user.favorite_min_rating:\n                photos_to_favorite.append(photo.image_hash)\n                photo.rating = user.favorite_min_rating\n                updated_data.append(PhotoSerializer(photo).data)\n            elif not val_favorite and photo.rating >= user.favorite_min_rating:\n                photos_to_unfavorite.append(photo.image_hash)\n                photo.rating = 0\n                updated_data.append(PhotoSerializer(photo).data)\n            else:\n                not_updated_data.append(PhotoSerializer(photo).data)\n\n        # Bulk update in separate queries for different rating values\n        if photos_to_favorite:\n            Photo.objects.filter(\n                image_hash__in=photos_to_favorite, owner=request.user\n            ).update(rating=user.favorite_min_rating)\n\n        if photos_to_unfavorite:\n            Photo.objects.filter(\n                image_hash__in=photos_to_unfavorite, owner=request.user\n            ).update(rating=0)\n\n        # Handle missing photos\n        found_hashes = {photo.image_hash for photo in photos}\n        missing_hashes = set(image_hashes) - found_hashes\n        for missing_hash in missing_hashes:\n            logger.warning(\n                f\"Could not set photo {missing_hash} to favorite. It does not exist or is not owned by user.\"\n            )\n\n        if val_favorite:\n            logger.info(\n                f\"{len(updated_data)} photos were added to favorites. {len(not_updated_data)} photos were already in favorites.\"\n            )\n        else:\n            logger.info(\n                f\"{len(updated_data)} photos were removed from favorites. {len(not_updated_data)} photos were already not in favorites.\"\n            )\n        return Response(\n            {\n                \"status\": True,\n                \"results\": updated_data,\n                \"updated\": updated_data,\n                \"not_updated\": not_updated_data,\n            }\n        )\n\n\nclass SetPhotosHidden(APIView):\n    def post(self, request, format=None):\n        from api.views.photo_filters import build_photo_queryset\n\n        data = dict(request.data)\n        val_hidden = data[\"hidden\"]\n\n        # NEW: Support select_all mode for bulk operations\n        if data.get(\"select_all\"):\n            query_params = data.get(\"query\", {})\n            excluded_hashes = data.get(\"excluded_hashes\", [])\n\n            photos_qs = build_photo_queryset(request.user, query_params)\n            if excluded_hashes:\n                photos_qs = photos_qs.exclude(image_hash__in=excluded_hashes)\n\n            count = photos_qs.update(hidden=val_hidden)\n\n            if val_hidden:\n                logger.info(\n                    f\"{count} photos were set hidden via select_all for user {request.user.id}.\"\n                )\n            else:\n                logger.info(\n                    f\"{count} photos were set unhidden via select_all for user {request.user.id}.\"\n                )\n\n            return Response({\"status\": True, \"count\": count})\n\n        # Existing logic for individual hashes\n        image_hashes = data[\"image_hashes\"]\n\n        # Get all photos with related data in one query to prevent N+1 queries from serializer\n        photos = (\n            Photo.objects.filter(image_hash__in=image_hashes, owner=request.user)\n            .select_related(\n                \"owner\", \"thumbnail\", \"main_file\", \"search_instance\", \"caption_instance\"\n            )\n            .prefetch_related(\n                \"files\", \"faces__person\", \"shared_to\", \"main_file__embedded_media\"\n            )\n        )\n\n        # Group photos by whether they need updating\n        photos_to_update = []\n        updated_data = []\n        not_updated_data = []\n\n        for photo in photos:\n            if photo.hidden != val_hidden:\n                photos_to_update.append(photo.image_hash)\n                photo.hidden = val_hidden\n                updated_data.append(PhotoSerializer(photo).data)\n            else:\n                not_updated_data.append(PhotoSerializer(photo).data)\n\n        # Bulk update in one query\n        if photos_to_update:\n            Photo.objects.filter(\n                image_hash__in=photos_to_update, owner=request.user\n            ).update(hidden=val_hidden)\n\n        # Handle missing photos\n        found_hashes = {photo.image_hash for photo in photos}\n        missing_hashes = set(image_hashes) - found_hashes\n        for missing_hash in missing_hashes:\n            logger.warning(\n                f\"Could not set photo {missing_hash} to hidden. It does not exist or is not owned by user.\"\n            )\n\n        if val_hidden:\n            logger.info(\n                f\"{len(updated_data)} photos were set hidden. {len(not_updated_data)} photos were already hidden.\"\n            )\n        else:\n            logger.info(\n                f\"{len(updated_data)} photos were set unhidden. {len(not_updated_data)} photos were already unhidden.\"\n            )\n        return Response(\n            {\n                \"status\": True,\n                \"results\": updated_data,\n                \"updated\": updated_data,\n                \"not_updated\": not_updated_data,\n            }\n        )\n\n\nclass PhotoViewSet(viewsets.ModelViewSet):\n    serializer_class = PhotoSerializer\n    pagination_class = HugeResultsSetPagination\n    filter_backends = (filters.SearchFilter,)\n    search_fields = [\n        \"search_instance__search_captions\",\n        \"search_instance__search_location\",\n        \"faces__person__name\",\n        \"exif_timestamp\",\n        \"main_file__path\",\n    ]\n\n    def get_object(self):\n        \"\"\"\n        Override get_object to support lookup by both UUID (pk) and image_hash.\n        This provides backward compatibility with existing URLs using image_hash.\n        \"\"\"\n        queryset = self.get_queryset()\n        lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field\n        lookup_value = self.kwargs.get(lookup_url_kwarg)\n\n        if lookup_value:\n            # Determine if this is a UUID (36 chars with hyphens) or image_hash (32 hex chars)\n            # Note: Python's uuid.UUID() accepts 32 hex chars without hyphens, but those\n            # are MD5 hashes used for backward compatibility, not actual UUIDs\n            is_uuid_format = len(lookup_value) == 36 and lookup_value.count(\"-\") == 4\n\n            if is_uuid_format:\n                try:\n                    import uuid\n\n                    uuid.UUID(lookup_value)\n                    filter_kwargs = {\"pk\": lookup_value}\n                except (ValueError, AttributeError):\n                    filter_kwargs = {\"image_hash\": lookup_value}\n            else:\n                # 32 hex chars = MD5 image_hash (backward compatibility)\n                filter_kwargs = {\"image_hash\": lookup_value}\n\n            obj = queryset.filter(**filter_kwargs).first()\n            if obj is None:\n                from rest_framework.exceptions import NotFound\n\n                raise NotFound()\n\n            # May raise a permission denied\n            self.check_object_permissions(self.request, obj)\n            return obj\n\n        return super().get_object()\n\n    @action(\n        detail=True,\n        methods=[\"get\"],\n        name=\"summary\",\n        serializer_class=PhotoDetailsSummarySerializer,\n    )\n    def summary(self, request, pk):\n        # Support both UUID and image_hash lookups\n        # Note: 32 hex chars could parse as UUID but are actually MD5 hashes\n        # Use Photo.objects instead of get_queryset() to include processing photos\n        is_uuid_format = len(pk) == 36 and pk.count(\"-\") == 4\n\n        if is_uuid_format:\n            try:\n                import uuid\n\n                uuid.UUID(pk)\n                queryset = Photo.objects.filter(pk=pk)\n            except (ValueError, AttributeError):\n                queryset = Photo.objects.filter(image_hash=pk)\n        else:\n            queryset = Photo.objects.filter(image_hash=pk)\n\n        if not queryset.exists():\n            return Response(status=status.HTTP_404_NOT_FOUND)\n\n        photo = queryset.first()\n        # Check permissions - owner, shared, or public\n        if not (\n            photo.owner == request.user\n            or photo.shared_to.filter(id=request.user.id).exists()\n            or photo.public\n        ):\n            return Response(status=status.HTTP_404_NOT_FOUND)\n\n        # Serializer expects a queryset (calls .get() internally)\n        serializer = PhotoDetailsSummarySerializer(queryset, many=False)\n        return Response(serializer.data)\n\n    @action(\n        detail=True,\n        methods=[\"get\"],\n        name=\"albums\",\n        serializer_class=AlbumUserListSerializer,\n    )\n    def albums(self, request, pk):\n        \"\"\"Return user albums that contain this photo.\"\"\"\n        # Support both UUID and image_hash lookups\n        try:\n            import uuid\n\n            uuid.UUID(pk)\n            photo = Photo.objects.filter(pk=pk).first()\n        except (ValueError, AttributeError):\n            photo = Photo.objects.filter(image_hash=pk).first()\n\n        if not photo:\n            return Response(status=status.HTTP_404_NOT_FOUND)\n        albums = AlbumUser.objects.filter(\n            Q(photos=photo) & (Q(owner=request.user) | Q(shared_to=request.user))\n        ).distinct()\n        serializer = AlbumUserListSerializer(albums, many=True)\n        return Response({\"results\": serializer.data})\n\n    def get_permissions(self):\n        if self.action in (\"list\", \"retrieve\", \"summary\", \"albums\"):\n            permission_classes = [IsPhotoOrAlbumSharedTo]\n        else:  # pragma: no cover - unused\n            if getattr(self.request, \"user\", None) and self.request.user.is_staff:\n                permission_classes = [IsAdminUser]\n            else:\n                permission_classes = [IsOwnerOrReadOnly]\n        return [permission() for permission in permission_classes]\n\n    def get_queryset(self):\n        if self.request.user.is_anonymous:\n            return (\n                Photo.visible.filter(Q(public=True))\n                .prefetch_related(\"stacks\")\n                .order_by(\"-exif_timestamp\")\n            )\n        else:\n            # Include photos that are:\n            # 1. Owned by the user\n            # 2. Shared directly with the user\n            # 3. Public (for retrieve access)\n            # Note: Photos in shared albums are handled by the permission class\n            return (\n                Photo.visible.filter(\n                    Q(owner=self.request.user)\n                    | Q(shared_to=self.request.user)\n                    | Q(public=True)\n                )\n                .prefetch_related(\"stacks\")\n                .order_by(\"-exif_timestamp\")\n            )\n\n    def retrieve(self, *args, **kwargs):\n        return super().retrieve(*args, **kwargs)\n\n    def list(self, *args, **kwargs):  # pragma: no cover - unused\n        return super().list(*args, **kwargs)\n\n\nclass PhotoEditViewSet(viewsets.ModelViewSet):\n    serializer_class = PhotoEditSerializer\n    pagination_class = StandardResultsSetPagination\n\n    def get_queryset(self):\n        return Photo.visible.filter(Q(owner=self.request.user))\n\n    def get_object(self):\n        \"\"\"\n        Override get_object to support lookup by both UUID (pk) and image_hash.\n        \"\"\"\n        queryset = self.get_queryset()\n        lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field\n        lookup_value = self.kwargs.get(lookup_url_kwarg)\n\n        if lookup_value:\n            # Check if proper UUID format (36 chars with hyphens) vs MD5 hash (32 hex chars)\n            is_uuid_format = len(lookup_value) == 36 and lookup_value.count(\"-\") == 4\n\n            if is_uuid_format:\n                try:\n                    import uuid\n\n                    uuid.UUID(lookup_value)\n                    filter_kwargs = {\"pk\": lookup_value}\n                except (ValueError, AttributeError):\n                    filter_kwargs = {\"image_hash\": lookup_value}\n            else:\n                filter_kwargs = {\"image_hash\": lookup_value}\n\n            obj = queryset.filter(**filter_kwargs).first()\n            if obj is None:\n                from rest_framework.exceptions import NotFound\n\n                raise NotFound()\n\n            self.check_object_permissions(self.request, obj)\n            return obj\n\n        return super().get_object()\n\n    def retrieve(\n        self, *args, **kwargs\n    ):  # pragma: no cover TODO(sickelap): remove unused code\n        return super().retrieve(*args, **kwargs)\n\n    def list(\n        self, *args, **kwargs\n    ):  # pragma: no cover TODO(sickelap): remove unused code\n        return super().list(*args, **kwargs)\n\n\nclass SetPhotosShared(APIView):\n    def post(self, request, format=None):\n        from api.views.photo_filters import build_photo_queryset\n\n        data = dict(request.data)\n        shared = data[\"val_shared\"]  # bool\n        target_user_id = data[\"target_user_id\"]  # user pk, int\n\n        through_model = Photo.shared_to.through\n\n        # NEW: Support select_all mode for bulk operations\n        if data.get(\"select_all\"):\n            query_params = data.get(\"query\", {})\n            excluded_hashes = data.get(\"excluded_hashes\", [])\n\n            photos_qs = build_photo_queryset(request.user, query_params)\n            if excluded_hashes:\n                photos_qs = photos_qs.exclude(image_hash__in=excluded_hashes)\n\n            image_hashes = list(photos_qs.values_list(\"image_hash\", flat=True))\n        else:\n            image_hashes = data[\"image_hashes\"]\n\n        \"\"\"\n        From https://stackoverflow.com/questions/6996176/how-to-create-an-object-for-a-django-model-with-a-many-to-many-field/10116452#10116452\n        # Access the through model directly\n        ThroughModel = Sample.users.through\n\n        users = Users.objects.filter(pk__in=[1,2])\n\n        sample_object = Sample()\n        sample_object.save()\n\n        ThroughModel.objects.bulk_create([\n            ThroughModel(users_id=users[0].pk, sample_id=sample_object.pk),\n            ThroughModel(users_id=users[1].pk, sample_id=sample_object.pk)\n        ])\n        \"\"\"\n\n        # Look up photo UUIDs from image_hashes (image_hash is no longer the primary key)\n        photos = Photo.objects.filter(image_hash__in=image_hashes).only(\n            \"id\", \"image_hash\"\n        )\n        photo_ids = [photo.id for photo in photos]\n\n        if shared:\n            already_existing = through_model.objects.filter(\n                user_id=target_user_id, photo_id__in=photo_ids\n            ).only(\"photo_id\")\n            already_existing_photo_ids = set(e.photo_id for e in already_existing)\n            res = through_model.objects.bulk_create(\n                [\n                    through_model(user_id=target_user_id, photo_id=photo_id)\n                    for photo_id in photo_ids\n                    if photo_id not in already_existing_photo_ids\n                ]\n            )\n            logger.info(\n                f\"Shared {request.user.id}'s {len(res)} images to user {target_user_id}\"\n            )\n            res_count = len(res)\n        else:\n            res = through_model.objects.filter(\n                user_id=target_user_id, photo_id__in=photo_ids\n            ).delete()\n            logger.info(\n                f\"Unshared {request.user.id}'s {len(res)} images to user {target_user_id}\"\n            )\n            res_count = res[0]\n\n        return Response({\"status\": True, \"count\": res_count})\n\n\nclass SetPhotosPublic(APIView):\n    def post(self, request, format=None):\n        from api.views.photo_filters import build_photo_queryset\n\n        data = dict(request.data)\n        val_public = data[\"val_public\"]\n\n        # NEW: Support select_all mode for bulk operations\n        if data.get(\"select_all\"):\n            query_params = data.get(\"query\", {})\n            excluded_hashes = data.get(\"excluded_hashes\", [])\n\n            photos_qs = build_photo_queryset(request.user, query_params)\n            if excluded_hashes:\n                photos_qs = photos_qs.exclude(image_hash__in=excluded_hashes)\n\n            count = photos_qs.update(public=val_public)\n\n            if val_public:\n                logger.info(\n                    f\"{count} photos were set public via select_all for user {request.user.id}.\"\n                )\n            else:\n                logger.info(\n                    f\"{count} photos were set private via select_all for user {request.user.id}.\"\n                )\n\n            return Response({\"status\": True, \"count\": count})\n\n        # Existing logic for individual hashes\n        image_hashes = data[\"image_hashes\"]\n\n        # Get all photos with related data in one query to prevent N+1 queries from serializer\n        photos = (\n            Photo.objects.filter(image_hash__in=image_hashes, owner=request.user)\n            .select_related(\n                \"owner\", \"thumbnail\", \"main_file\", \"search_instance\", \"caption_instance\"\n            )\n            .prefetch_related(\n                \"files\", \"faces__person\", \"shared_to\", \"main_file__embedded_media\"\n            )\n        )\n\n        # Group photos by whether they need updating\n        photos_to_update = []\n        updated_data = []\n        not_updated_data = []\n\n        for photo in photos:\n            if photo.public != val_public:\n                photos_to_update.append(photo.image_hash)\n                photo.public = val_public\n                updated_data.append(PhotoSerializer(photo).data)\n            else:\n                not_updated_data.append(PhotoSerializer(photo).data)\n\n        # Bulk update in one query\n        if photos_to_update:\n            Photo.objects.filter(\n                image_hash__in=photos_to_update, owner=request.user\n            ).update(public=val_public)\n\n        # Handle missing photos\n        found_hashes = {photo.image_hash for photo in photos}\n        missing_hashes = set(image_hashes) - found_hashes\n        for missing_hash in missing_hashes:\n            logger.warning(\n                f\"Could not set photo {missing_hash} to public. It does not exist or is not owned by user.\"\n            )\n\n        if val_public:\n            logger.info(\n                f\"{len(updated_data)} photos were set public. {len(not_updated_data)} photos were already public.\"\n            )\n        else:\n            logger.info(\n                f\"{len(updated_data)} photos were set private. {len(not_updated_data)} photos were already public.\"\n            )\n\n        return Response(\n            {\n                \"status\": True,\n                \"results\": updated_data,\n                \"updated\": updated_data,\n                \"not_updated\": not_updated_data,\n            }\n        )\n\n\nclass GeneratePhotoCaption(APIView):\n    permission_classes = (IsOwnerOrReadOnly,)\n\n    def post(self, request, format=None):\n        data = dict(request.data)\n        image_hash = data[\"image_hash\"]\n\n        photo = Photo.objects.filter(image_hash=image_hash, owner=request.user).first()\n        if photo is None:\n            return Response(\n                {\"status\": False, \"message\": \"photo not found\"},\n                status=404,\n            )\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n        res = caption_instance.generate_captions_im2txt()\n\n        if res:\n            return Response({\"status\": True})\n        else:\n            return Response(\n                {\n                    \"status\": False,\n                    \"message\": \"Failed to generate caption. Check service logs for details.\",\n                },\n                status=500,\n            )\n\n\nclass SavePhotoCaption(APIView):\n    permission_classes = (IsOwnerOrReadOnly,)\n\n    def post(self, request, format=None):\n        data = dict(request.data)\n        image_hash = data[\"image_hash\"]\n        caption = data[\"caption\"]\n\n        photo = Photo.objects.filter(image_hash=image_hash, owner=request.user).first()\n        if photo is None:\n            return Response(\n                {\"status\": False, \"message\": \"photo not found\"},\n                status=404,\n            )\n\n        caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n        res = caption_instance.save_user_caption(caption)\n        return Response({\"status\": res})\n\n\nclass DeletePhotos(APIView):\n    def delete(self, request):\n        from api.views.photo_filters import build_photo_queryset\n\n        data = dict(request.data)\n\n        # NEW: Support select_all mode for bulk operations\n        if data.get(\"select_all\"):\n            query_params = data.get(\"query\", {})\n            excluded_hashes = data.get(\"excluded_hashes\", [])\n\n            # For delete, we need to ensure photos are in trashcan\n            # Override query to filter for trashcan photos only\n            query_params[\"in_trashcan\"] = True\n\n            photos_qs = build_photo_queryset(request.user, query_params)\n            if excluded_hashes:\n                photos_qs = photos_qs.exclude(image_hash__in=excluded_hashes)\n\n            # Need to call manual_delete on each photo for proper cleanup\n            deleted_count = 0\n            for photo in photos_qs:\n                if photo.owner == request.user:\n                    photo.manual_delete()\n                    deleted_count += 1\n\n            logger.info(\n                f\"{deleted_count} photos were permanently deleted via select_all for user {request.user.id}.\"\n            )\n\n            return Response({\"status\": True, \"count\": deleted_count})\n\n        # Existing logic for individual hashes\n        # Use filter since image_hash may not be unique after UUID migration\n        image_hashes = data[\"image_hashes\"]\n        photos = Photo.objects.filter(image_hash__in=image_hashes)\n        photos_by_hash = {photo.image_hash: photo for photo in photos}\n\n        deleted = []\n        not_deleted = []\n        for image_hash in image_hashes:\n            photo = photos_by_hash.get(image_hash)\n            if photo is None:\n                continue  # Photo not found\n            if photo.owner == request.user and photo.in_trashcan:\n                deleted.append(photo.image_hash)\n                photo.manual_delete()\n            else:\n                not_deleted.append(photo.image_hash)\n\n        return Response(\n            {\n                \"status\": True,\n                \"results\": deleted,\n                \"not_deleted\": not_deleted,\n                \"deleted\": deleted,\n            }\n        )\n\n\nclass FileVariantDownloadView(APIView):\n    \"\"\"\n    Download a specific file variant for a photo.\n\n    Supports downloading RAW, JPEG, video (Live Photo), or other variants\n    associated with a Photo entity (PhotoPrism-like file variant model).\n    \"\"\"\n\n    @extend_schema(\n        parameters=[\n            OpenApiParameter(\n                \"file_hash\",\n                OpenApiTypes.STR,\n                description=\"Hash of the specific file variant to download\",\n            ),\n        ],\n    )\n    def get(self, request, image_hash, file_hash):\n        \"\"\"Download a specific file variant by hash.\"\"\"\n        import magic\n        import os\n        from django.http import FileResponse, HttpResponse\n\n        # Find the photo\n        try:\n            photo = Photo.objects.get(\n                image_hash=image_hash,\n                owner=request.user,\n            )\n        except Photo.DoesNotExist:\n            return Response(\n                {\"error\": \"Photo not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n        except Photo.MultipleObjectsReturned:\n            # Multiple photos with same hash - get the one owned by user\n            photo = Photo.objects.filter(\n                image_hash=image_hash,\n                owner=request.user,\n            ).first()\n            if not photo:\n                return Response(\n                    {\"error\": \"Photo not found\"}, status=status.HTTP_404_NOT_FOUND\n                )\n\n        # Find the requested file variant\n        file_variant = photo.files.filter(hash=file_hash).first()\n        if not file_variant:\n            return Response(\n                {\"error\": \"File variant not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        # Check file exists\n        if not os.path.exists(file_variant.path):\n            return Response(\n                {\"error\": \"File not found on disk\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        # Serve the file\n        try:\n            response = FileResponse(\n                open(file_variant.path, \"rb\"),\n                as_attachment=True,\n                filename=os.path.basename(file_variant.path),\n            )\n\n            # Set content type\n            try:\n                mime = magic.Magic(mime=True)\n                response[\"Content-Type\"] = mime.from_file(file_variant.path)\n            except Exception:\n                response[\"Content-Type\"] = \"application/octet-stream\"\n\n            return response\n\n        except (FileNotFoundError, PermissionError) as e:\n            logger.error(f\"Error serving file {file_variant.path}: {e}\")\n            return Response(\n                {\"error\": \"Could not read file\"},\n                status=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            )\n\n\nclass SetMainFileView(APIView):\n    \"\"\"\n    Set the main (primary) file for a photo.\n\n    Changes which file variant is used as the main display file for the photo.\n    Useful when a photo has multiple variants (RAW, JPEG, etc.).\n    \"\"\"\n\n    def post(self, request, image_hash):\n        \"\"\"Set the main file for a photo.\"\"\"\n        file_hash = request.data.get(\"file_hash\")\n\n        if not file_hash:\n            return Response(\n                {\"error\": \"file_hash is required\"}, status=status.HTTP_400_BAD_REQUEST\n            )\n\n        # Find the photo\n        try:\n            photo = Photo.objects.get(\n                image_hash=image_hash,\n                owner=request.user,\n            )\n        except Photo.DoesNotExist:\n            return Response(\n                {\"error\": \"Photo not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n        except Photo.MultipleObjectsReturned:\n            photo = Photo.objects.filter(\n                image_hash=image_hash,\n                owner=request.user,\n            ).first()\n            if not photo:\n                return Response(\n                    {\"error\": \"Photo not found\"}, status=status.HTTP_404_NOT_FOUND\n                )\n\n        # Find the requested file variant\n        file_variant = photo.files.filter(hash=file_hash).first()\n        if not file_variant:\n            return Response(\n                {\"error\": \"File variant not found in this photo\"},\n                status=status.HTTP_404_NOT_FOUND,\n            )\n\n        # Update main file\n        photo.main_file = file_variant\n        photo.save(update_fields=[\"main_file\", \"last_modified\"])\n\n        logger.info(f\"Set main file for photo {image_hash} to {file_hash}\")\n\n        return Response(\n            {\n                \"status\": \"updated\",\n                \"main_file_hash\": file_hash,\n            }\n        )\n\n\nclass SaveMetadataView(APIView):\n    def post(self, request, format=None):\n        \"\"\"Bulk-write metadata to image files for the authenticated user's photos.\n\n        Accepts {\"types\": [\"ratings\", \"face_tags\"]} to control what gets written.\n        Defaults to [\"ratings\"] if not specified.\n        \"\"\"\n        metadata_types = request.data.get(\"types\", [\"ratings\"])\n        use_sidecar = (\n            request.user.save_metadata_to_disk == User.SaveMetadata.SIDECAR_FILE\n        )\n\n        photos = Photo.objects.filter(owner=request.user)\n\n        # When writing face tags, only include photos that have labeled faces\n        if \"face_tags\" in metadata_types and metadata_types == [\"face_tags\"]:\n            photos = photos.filter(\n                faces__person__kind=Person.KIND_USER,\n                faces__deleted=False,\n            ).distinct()\n\n        written = 0\n        errors = 0\n        for photo in photos.iterator():\n            try:\n                photo._save_metadata(\n                    use_sidecar=use_sidecar, metadata_types=metadata_types\n                )\n                written += 1\n            except Exception:\n                errors += 1\n                logger.exception(\n                    f\"Failed to save metadata for photo {photo.image_hash}\"\n                )\n\n        return Response({\"status\": True, \"written\": written, \"errors\": errors})\n"
  },
  {
    "path": "api/views/public_albums.py",
    "content": "from django.db.models import Q\nfrom django.utils import timezone\nfrom django.utils.dateparse import parse_datetime\nfrom drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema\nfrom rest_framework.permissions import AllowAny\nfrom rest_framework.response import Response\nfrom rest_framework.views import APIView\n\nfrom api.models import AlbumUser, Photo\nfrom api.models.album_user_share import AlbumUserShare\nfrom api.serializers.album_user import (\n    AlbumUserListSerializer,\n    AlbumUserPublicSerializer,\n)\nfrom api.serializers.photos import PublicPhotoDetailSerializer\n\n\nclass SetUserAlbumPublic(APIView):\n    def post(self, request, format=None):\n        data = dict(request.data)\n        val_public = data.get(\"val_public\")\n        album_id = data.get(\"album_id\")\n        slug = data.get(\"slug\")\n        expires_at = data.get(\"expires_at\")  # ISO string or None\n        \n        # Sharing options - None means use user default, True/False overrides\n        sharing_options = data.get(\"sharing_options\", {})\n        \n        if album_id is None or val_public is None:\n            return Response(\n                {\"status\": False, \"message\": \"Missing parameters\"}, status=400\n            )\n\n        try:\n            album = AlbumUser.objects.get(id=album_id)\n        except AlbumUser.DoesNotExist:\n            return Response({\"status\": False, \"message\": \"No such album\"}, status=404)\n\n        if album.owner != request.user:\n            return Response(\n                {\"status\": False, \"message\": \"You are not the owner of this album\"},\n                status=403,\n            )\n\n        share, _ = AlbumUserShare.objects.get_or_create(album=album)\n        share.enabled = bool(val_public)\n        if slug is not None:\n            share.slug = slug or None\n        if expires_at is not None:\n            try:\n                dt = parse_datetime(expires_at)\n                share.expires_at = dt\n            except Exception:\n                pass\n        \n        # Update sharing options if provided\n        if sharing_options:\n            # Each option can be True, False, or None (use default)\n            if \"share_location\" in sharing_options:\n                share.share_location = sharing_options.get(\"share_location\")\n            if \"share_camera_info\" in sharing_options:\n                share.share_camera_info = sharing_options.get(\"share_camera_info\")\n            if \"share_timestamps\" in sharing_options:\n                share.share_timestamps = sharing_options.get(\"share_timestamps\")\n            if \"share_captions\" in sharing_options:\n                share.share_captions = sharing_options.get(\"share_captions\")\n            if \"share_faces\" in sharing_options:\n                share.share_faces = sharing_options.get(\"share_faces\")\n        \n        share.save()\n\n        return Response({\"status\": True, \"album\": AlbumUserListSerializer(album).data})\n\n\nclass PublicAlbumBySlug(APIView):\n    permission_classes = [AllowAny]\n\n    @extend_schema(\n        parameters=[OpenApiParameter(\"slug\", OpenApiTypes.STR)],\n        description=\"Returns a public user album by slug if active\",\n    )\n    def get(self, request, slug):\n        album = (\n            AlbumUser.objects.filter(Q(share__enabled=True) & Q(share__slug=slug))\n            .filter(\n                Q(share__expires_at__isnull=True)\n                | Q(share__expires_at__gte=timezone.now())\n            )\n            .select_related(\"share\", \"owner\")\n            .first()\n        )\n        if not album:\n            return Response(status=404)\n        \n        # Include effective sharing settings in response\n        sharing_settings = album.share.get_effective_sharing_settings()\n        \n        serializer = AlbumUserPublicSerializer(album, context={\"request\": request})\n        return Response({\n            \"results\": serializer.data,\n            \"sharing_settings\": sharing_settings,\n        })\n\n\nclass PublicPhotoDetailBySlug(APIView):\n    \"\"\"Get photo details for a photo in a publicly shared album.\"\"\"\n    \n    permission_classes = [AllowAny]\n\n    @extend_schema(\n        parameters=[\n            OpenApiParameter(\"slug\", OpenApiTypes.STR, description=\"Album share slug\"),\n            OpenApiParameter(\"photo_id\", OpenApiTypes.STR, description=\"Photo ID or image hash\"),\n        ],\n        description=\"Returns photo details for a photo in a public album, filtered by sharing settings\",\n    )\n    def get(self, request, slug, photo_id):\n        # Find the public album\n        album = (\n            AlbumUser.objects.filter(Q(share__enabled=True) & Q(share__slug=slug))\n            .filter(\n                Q(share__expires_at__isnull=True)\n                | Q(share__expires_at__gte=timezone.now())\n            )\n            .select_related(\"share\", \"owner\")\n            .first()\n        )\n        if not album:\n            return Response({\"error\": \"Album not found or not public\"}, status=404)\n        \n        # Find the photo - support both UUID and image_hash lookups\n        # UUID format is 36 chars with hyphens, image_hash is 32 hex chars\n        is_uuid_format = len(photo_id) == 36 and photo_id.count(\"-\") == 4\n        \n        if is_uuid_format:\n            photo = album.photos.filter(\n                pk=photo_id,\n                hidden=False,\n                in_trashcan=False,\n            ).first()\n        else:\n            photo = album.photos.filter(\n                image_hash=photo_id,\n                hidden=False,\n                in_trashcan=False,\n            ).first()\n        \n        if not photo:\n            return Response({\"error\": \"Photo not found in album\"}, status=404)\n        \n        # Get effective sharing settings\n        sharing_settings = album.share.get_effective_sharing_settings()\n        \n        # Serialize with sharing settings in context\n        serializer = PublicPhotoDetailSerializer(\n            photo,\n            context={\"request\": request, \"sharing_settings\": sharing_settings}\n        )\n        \n        return Response({\n            \"results\": serializer.data,\n            \"sharing_settings\": sharing_settings,\n        })\n"
  },
  {
    "path": "api/views/search.py",
    "content": "from django.db.models import Prefetch, Q\nfrom rest_framework.response import Response\n\nfrom api.filters import SemanticSearchFilter\nfrom api.models import File, Photo, User\nfrom api.serializers.photos import GroupedPhotosSerializer, PhotoSummarySerializer\nfrom api.serializers.PhotosGroupedByDate import get_photos_ordered_by_date\nfrom api.views.custom_api_view import ListViewSet\nfrom api.views.pagination import HugeResultsSetPagination\n\n\nclass SearchListViewSet(ListViewSet):\n    serializer_class = GroupedPhotosSerializer\n    pagination_class = HugeResultsSetPagination\n    filter_backends = (SemanticSearchFilter,)\n\n    search_fields = [\n        \"search_instance__search_captions\",\n        \"search_instance__search_location\",\n        \"exif_timestamp\",\n    ]\n\n    def get_queryset(self):\n        return Photo.visible.filter(Q(owner=self.request.user)).order_by(\n            \"-exif_timestamp\"\n        )\n\n    def list(self, request):\n        if request.user.semantic_search_topk == 0:\n            queryset = self.filter_queryset(\n                Photo.visible.filter(Q(owner=self.request.user))\n                .select_related(\"thumbnail\", \"search_instance\", \"main_file\")\n                .prefetch_related(\n                    Prefetch(\n                        \"owner\",\n                        queryset=User.objects.only(\n                            \"id\", \"username\", \"first_name\", \"last_name\"\n                        ),\n                    ),\n                    Prefetch(\n                        \"main_file__embedded_media\",\n                        queryset=File.objects.only(\"hash\"),\n                    ),\n                )\n                .order_by(\"-exif_timestamp\")\n                .only(\n                    \"image_hash\",\n                    \"thumbnail__aspect_ratio\",\n                    \"thumbnail__dominant_color\",\n                    \"video\",\n                    \"main_file\",\n                    \"search_instance__search_location\",\n                    \"public\",\n                    \"rating\",\n                    \"hidden\",\n                    \"exif_timestamp\",\n                    \"owner\",\n                    \"video_length\",\n                    \"exif_gps_lat\",\n                    \"exif_gps_lon\",\n                    \"removed\",\n                    \"in_trashcan\",\n                )\n            )\n            grouped_photos = get_photos_ordered_by_date(queryset)\n            serializer = GroupedPhotosSerializer(grouped_photos, many=True)\n            return Response({\"results\": serializer.data})\n        else:\n            queryset = self.filter_queryset(\n                Photo.visible.filter(Q(owner=self.request.user))\n                .select_related(\"thumbnail\", \"search_instance\", \"main_file\")\n                .prefetch_related(\n                    Prefetch(\n                        \"owner\",\n                        queryset=User.objects.only(\n                            \"id\", \"username\", \"first_name\", \"last_name\"\n                        ),\n                    ),\n                    Prefetch(\n                        \"main_file__embedded_media\",\n                        queryset=File.objects.only(\"hash\"),\n                    ),\n                )\n                .only(\n                    \"image_hash\",\n                    \"thumbnail__aspect_ratio\",\n                    \"thumbnail__dominant_color\",\n                    \"video\",\n                    \"main_file\",\n                    \"search_instance__search_location\",\n                    \"public\",\n                    \"rating\",\n                    \"hidden\",\n                    \"exif_timestamp\",\n                    \"owner\",\n                    \"video_length\",\n                    \"exif_gps_lat\",\n                    \"exif_gps_lon\",\n                    \"removed\",\n                    \"in_trashcan\",\n                )\n            )\n            serializer = PhotoSummarySerializer(queryset, many=True)\n            return Response({\"results\": serializer.data})\n"
  },
  {
    "path": "api/views/services.py",
    "content": "from rest_framework import status, viewsets\nfrom rest_framework.decorators import action\nfrom rest_framework.permissions import IsAdminUser\nfrom rest_framework.response import Response\n\nfrom api.services import SERVICES, is_healthy, start_service, stop_service\n\n\nclass ServiceViewSet(viewsets.ViewSet):\n    permission_classes = [IsAdminUser]\n\n    def list(self, request):\n        return Response({\"services\": SERVICES})\n\n    def retrieve(self, request, pk=None):\n        service_name = pk\n\n        if service_name not in SERVICES:\n            return Response(\n                {\"error\": f\"Service {service_name} not found\"},\n                status=status.HTTP_404_NOT_FOUND,\n            )\n\n        healthy = is_healthy(service_name)\n        return Response({\"service_name\": service_name, \"healthy\": healthy})\n\n    @action(detail=True, methods=[\"post\"])\n    def start(self, request, pk=None):\n        service_name = pk\n\n        if service_name not in SERVICES:\n            return Response(\n                {\"error\": f\"Service {service_name} not found\"},\n                status=status.HTTP_404_NOT_FOUND,\n            )\n\n        start_result = start_service(service_name)\n        if start_result:\n            return Response(\n                {\"message\": f\"Service {service_name} started successfully\"},\n                status=status.HTTP_200_OK,\n            )\n        else:\n            return Response(\n                {\"error\": f\"Failed to start service {service_name}\"},\n                status=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            )\n\n    @action(detail=True, methods=[\"post\"])\n    def stop(self, request, pk=None):\n        service_name = pk\n\n        if service_name not in SERVICES:\n            return Response(\n                {\"error\": f\"Service {service_name} not found\"},\n                status=status.HTTP_404_NOT_FOUND,\n            )\n\n        stop_result = stop_service(service_name)\n        if stop_result:\n            return Response(\n                {\"message\": f\"Service {service_name} stopped successfully\"},\n                status=status.HTTP_200_OK,\n            )\n        else:\n            return Response(\n                {\"error\": f\"Failed to stop service {service_name}\"},\n                status=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            )\n"
  },
  {
    "path": "api/views/sharing.py",
    "content": "from django.db.models import Count, Prefetch, Q\n\nfrom api.models import AlbumUser, Photo, User\nfrom api.serializers.album_user import AlbumUserListSerializer\nfrom api.serializers.photos import (\n    PhotoSummarySerializer,\n    SharedFromMePhotoThroughSerializer,\n)\nfrom api.views.custom_api_view import ListViewSet\nfrom api.views.pagination import HugeResultsSetPagination\n\n\nclass SharedToMePhotoSuperSimpleListViewSet(ListViewSet):\n    serializer_class = PhotoSummarySerializer\n    pagination_class = HugeResultsSetPagination\n\n    def get_queryset(self):\n        return (\n            Photo.visible.filter(Q(shared_to__id__exact=self.request.user.id))\n            .only(\n                \"image_hash\",\n                \"public\",\n                \"rating\",\n                \"owner\",\n                \"hidden\",\n                \"exif_timestamp\",\n            )\n            .prefetch_related(\"owner\")\n            .order_by(\"exif_timestamp\")\n        )\n\n\nclass SharedFromMePhotoSuperSimpleListViewSet(ListViewSet):\n    serializer_class = SharedFromMePhotoThroughSerializer\n    pagination_class = HugeResultsSetPagination\n\n    def get_queryset(self):\n        ThroughModel = Photo.shared_to.through\n\n        user_photos = Photo.visible.filter(Q(owner=self.request.user.id)).only(\n            \"image_hash\"\n        )\n        qs = (\n            ThroughModel.objects.filter(photo_id__in=user_photos)\n            .prefetch_related(\n                Prefetch(\n                    \"user\",\n                    queryset=User.objects.only(\n                        \"id\", \"username\", \"first_name\", \"last_name\"\n                    ),\n                )\n            )\n            .prefetch_related(\n                Prefetch(\n                    \"photo\",\n                    queryset=Photo.objects.filter(hidden=False).only(\n                        \"image_hash\", \"rating\", \"hidden\", \"exif_timestamp\", \"public\"\n                    ),\n                )\n            )\n            .order_by(\"photo__exif_timestamp\")\n        )\n        return qs\n\n\nclass SharedToMeAlbumUserListViewSet(ListViewSet):\n    serializer_class = AlbumUserListSerializer\n    pagination_class = HugeResultsSetPagination\n\n    def get_queryset(self):\n        return AlbumUser.objects.filter(shared_to__id__exact=self.request.user.id).order_by(\"id\")\n\n\nclass SharedFromMeAlbumUserListViewSet(ListViewSet):\n    serializer_class = AlbumUserListSerializer\n    pagination_class = HugeResultsSetPagination\n\n    def get_queryset(self):\n        return (\n            AlbumUser.objects.annotate(shared_to_count=Count(\"shared_to\"))\n            .filter(shared_to_count__gt=0)\n            .filter(owner=self.request.user.id)\n            .order_by(\"id\")\n        )\n"
  },
  {
    "path": "api/views/stacks.py",
    "content": "\"\"\"\nAPI views for PhotoStack management - organizational photo grouping.\n\nHandles organizational stack types: burst sequences, exposure brackets,\nand manual stacks.\n\nNOTE: RAW+JPEG pairs and Live Photos are NO LONGER stacks. They use\nthe Photo.files field for file variants (PhotoPrism-like model).\n\nNOTE: Duplicates (exact copies and visual duplicates) are handled \nseparately by the duplicates API in api/views/duplicates.py.\nStacks are for organization, duplicates are for storage cleanup.\n\"\"\"\n\nfrom django.core.paginator import Paginator\nfrom django.db.models import Count\nfrom django_q.tasks import async_task\nfrom drf_spectacular.utils import OpenApiParameter, extend_schema\nfrom rest_framework import status\nfrom rest_framework.permissions import IsAuthenticated\nfrom rest_framework.response import Response\nfrom rest_framework.views import APIView\n\nfrom api.models import Photo\nfrom api.models.photo_stack import PhotoStack\nfrom api.util import logger\n\n\nclass PhotoStackListView(APIView):\n    \"\"\"List all photo stacks for the current user with pagination and filters.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    @extend_schema(\n        parameters=[\n            OpenApiParameter(\n                \"stack_type\",\n                str,\n                description=\"Filter by stack type: raw_jpeg, burst, bracket, live_photo, manual\",\n            ),\n            OpenApiParameter(\n                \"page\",\n                int,\n                description=\"Page number (default: 1)\",\n            ),\n            OpenApiParameter(\n                \"page_size\",\n                int,\n                description=\"Number of items per page (default: 20, max: 100)\",\n            ),\n        ],\n    )\n    def get(self, request):\n        stack_type_filter = request.query_params.get(\"stack_type\", None)\n        \n        # Safely parse pagination parameters with defaults for invalid input\n        try:\n            page = max(1, int(request.query_params.get(\"page\", 1)))\n        except (ValueError, TypeError):\n            page = 1\n        \n        try:\n            page_size = max(1, min(int(request.query_params.get(\"page_size\", 20)), 100))\n        except (ValueError, TypeError):\n            page_size = 20\n\n        # Use model-defined valid stack types (excludes deprecated RAW_JPEG_PAIR, LIVE_PHOTO)\n        valid_stack_types = PhotoStack.VALID_STACK_TYPES\n\n        stacks = PhotoStack.objects.filter(\n            owner=request.user,\n            stack_type__in=valid_stack_types\n        ).prefetch_related(\n            \"photos__thumbnail\", \"primary_photo__thumbnail\"\n        ).annotate(\n            photos_count=Count(\"photos\")\n        ).order_by(\"-created_at\")\n\n        if stack_type_filter:\n            # Validate that the filter is a valid organizational stack type\n            # valid_stack_types contains TextChoices values which are strings\n            if stack_type_filter in [str(st) for st in valid_stack_types]:\n                stacks = stacks.filter(stack_type=stack_type_filter)\n\n        # Only return stacks with at least 2 photos\n        stacks = stacks.filter(photos_count__gte=2)\n\n        # Paginate results\n        paginator = Paginator(stacks, page_size)\n        page_obj = paginator.get_page(page)\n\n        # Serialize manually to include nested photo data\n        results = []\n        for stack in page_obj.object_list:\n            photos = stack.photos.all()[:4]  # Preview first 4 photos\n            results.append({\n                \"id\": str(stack.id),\n                \"stack_type\": stack.stack_type,\n                \"stack_type_display\": stack.get_stack_type_display(),\n                \"photo_count\": stack.photos_count,\n                \"sequence_start\": stack.sequence_start,\n                \"sequence_end\": stack.sequence_end,\n                \"created_at\": stack.created_at,\n                \"primary_photo\": {\n                    \"image_hash\": stack.primary_photo.image_hash,\n                    \"thumbnail_url\": f\"/media/square_thumbnails_small/{stack.primary_photo.image_hash}\" if hasattr(stack.primary_photo, 'thumbnail') and stack.primary_photo.thumbnail.square_thumbnail_small else None,\n                } if stack.primary_photo else None,\n                \"preview_photos\": [\n                    {\n                        \"image_hash\": p.image_hash,\n                        \"thumbnail_url\": f\"/media/square_thumbnails_small/{p.image_hash}\" if hasattr(p, 'thumbnail') and p.thumbnail.square_thumbnail_small else None,\n                    }\n                    for p in photos\n                ],\n            })\n\n        return Response({\n            \"results\": results,\n            \"count\": paginator.count,\n            \"num_pages\": paginator.num_pages,\n            \"page\": page,\n            \"page_size\": page_size,\n            \"has_next\": page_obj.has_next(),\n            \"has_previous\": page_obj.has_previous(),\n        })\n\n\nclass PhotoStackDetailView(APIView):\n    \"\"\"Get details of a specific photo stack with all photos.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def get(self, request, stack_id):\n        # Use model-defined valid stack types (excludes deprecated RAW_JPEG_PAIR, LIVE_PHOTO)\n        # Also include deprecated types for viewing existing stacks during migration\n        all_stack_types = PhotoStack.VALID_STACK_TYPES + [\n            PhotoStack.StackType.RAW_JPEG_PAIR,\n            PhotoStack.StackType.LIVE_PHOTO,\n        ]\n        \n        try:\n            stack = PhotoStack.objects.annotate(\n                photos_count=Count(\"photos\")\n            ).get(id=stack_id, owner=request.user, stack_type__in=all_stack_types)\n        except PhotoStack.DoesNotExist:\n            return Response(\n                {\"error\": \"Photo stack not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        photos = stack.photos.select_related('thumbnail', 'main_file', 'metadata').prefetch_related('files').all()\n\n        photo_data = []\n        for p in photos:\n            # Width, height, and camera are on the metadata model\n            width = None\n            height = None\n            camera = None\n            if hasattr(p, 'metadata') and p.metadata:\n                width = p.metadata.width\n                height = p.metadata.height\n                camera = p.metadata.camera_model\n\n            # Get all file variants for this photo\n            file_variants = []\n            for f in p.files.all():\n                file_variants.append({\n                    \"hash\": f.hash,\n                    \"path\": f.path,\n                    \"type\": f.get_type_display().lower(),\n                    \"is_main\": p.main_file and f.hash == p.main_file.hash,\n                    \"filename\": f.path.split(\"/\")[-1] if f.path else None,\n                })\n\n            data = {\n                \"id\": str(p.id),\n                \"image_hash\": p.image_hash,\n                \"width\": width,\n                \"height\": height,\n                \"size\": p.size,\n                \"camera\": camera,\n                \"exif_timestamp\": p.exif_timestamp,\n                \"is_primary\": stack.primary_photo and p.image_hash == stack.primary_photo.image_hash,\n                \"file_path\": p.main_file.path if p.main_file else None,\n                \"file_type\": p.main_file.get_type_display().lower() if p.main_file else None,\n                \"file_variants\": file_variants if file_variants else None,\n                \"thumbnail_url\": f\"/media/square_thumbnails_small/{p.image_hash}\" if hasattr(p, 'thumbnail') and p.thumbnail.square_thumbnail_small else None,\n                \"thumbnail_big_url\": f\"/media/thumbnails_big/{p.image_hash}\" if hasattr(p, 'thumbnail') and p.thumbnail.thumbnail_big else None,\n            }\n            photo_data.append(data)\n\n        return Response({\n            \"id\": str(stack.id),\n            \"stack_type\": stack.stack_type,\n            \"stack_type_display\": stack.get_stack_type_display(),\n            \"photo_count\": stack.photos_count,\n            \"sequence_start\": stack.sequence_start,\n            \"sequence_end\": stack.sequence_end,\n            \"created_at\": stack.created_at,\n            \"updated_at\": stack.updated_at,\n            \"primary_photo_hash\": stack.primary_photo.image_hash if stack.primary_photo else None,\n            \"photos\": photo_data,\n        })\n\n    def delete(self, request, stack_id):\n        \"\"\"Delete a stack (unlinks photos but doesn't delete them).\"\"\"\n        try:\n            stack = PhotoStack.objects.get(id=stack_id, owner=request.user)\n        except PhotoStack.DoesNotExist:\n            return Response(\n                {\"error\": \"Photo stack not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        # Unlink photos from this stack (ManyToMany)\n        photo_count = stack.photos.count()\n        for photo in stack.photos.all():\n            photo.stacks.remove(stack)\n\n        # Delete stack\n        stack_id_str = str(stack.id)\n        stack.delete()\n\n        logger.info(f\"Deleted stack {stack_id_str}: unlinked {photo_count} photos\")\n        return Response({\n            \"status\": \"deleted\",\n            \"unlinked_count\": photo_count\n        })\n\n\nclass PhotoStackDeleteView(APIView):\n    \"\"\"Delete a stack (unlinks photos but doesn't delete them).\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def delete(self, request, stack_id):\n        try:\n            stack = PhotoStack.objects.get(id=stack_id, owner=request.user)\n        except PhotoStack.DoesNotExist:\n            return Response(\n                {\"error\": \"Photo stack not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        # Unlink photos from this stack (ManyToMany)\n        photo_count = stack.photos.count()\n        for photo in stack.photos.all():\n            photo.stacks.remove(stack)\n\n        # Delete stack\n        stack_id_str = str(stack.id)\n        stack.delete()\n\n        logger.info(f\"Deleted stack {stack_id_str}: unlinked {photo_count} photos\")\n        return Response({\n            \"status\": \"deleted\",\n            \"unlinked_count\": photo_count\n        })\n\n\nclass PhotoStackSetPrimaryView(APIView):\n    \"\"\"Set the primary (cover) photo for a stack.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def post(self, request, stack_id):\n        try:\n            stack = PhotoStack.objects.get(id=stack_id, owner=request.user)\n        except PhotoStack.DoesNotExist:\n            return Response(\n                {\"error\": \"Photo stack not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        photo_hash = request.data.get(\"photo_hash\")\n        if not photo_hash:\n            return Response(\n                {\"error\": \"photo_hash is required\"},\n                status=status.HTTP_400_BAD_REQUEST\n            )\n\n        try:\n            photo = stack.photos.get(image_hash=photo_hash)\n        except Photo.DoesNotExist:\n            return Response(\n                {\"error\": \"Photo not found in this stack\"},\n                status=status.HTTP_400_BAD_REQUEST\n            )\n\n        stack.primary_photo = photo\n        stack.save(update_fields=['primary_photo', 'updated_at'])\n\n        logger.info(f\"Set primary photo for stack {stack.id} to {photo_hash}\")\n        return Response({\n            \"status\": \"updated\",\n            \"primary_photo_hash\": photo_hash\n        })\n\n\nclass DetectStacksView(APIView):\n    \"\"\"Trigger stack detection for the current user (bursts, brackets).\n    \n    NOTE: RAW+JPEG pairs and Live Photos are now handled as file variants\n    during scan (PhotoPrism-like model), not as stacks.\n    \n    Burst detection uses the user's configured burst_detection_rules from their profile.\n    \"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    @extend_schema(\n        parameters=[\n            OpenApiParameter(\n                \"detect_bursts\",\n                bool,\n                description=\"Detect burst sequences using user's configured rules (default: true)\",\n            ),\n        ],\n    )\n    def post(self, request):\n        from api.stack_detection import batch_detect_stacks\n        \n        # RAW+JPEG and Live Photos are now file variants, not stacks\n        # Only burst/bracket detection is done here\n        options = {\n            'detect_bursts': request.data.get('detect_bursts', True),\n        }\n\n        # Queue background job\n        async_task(batch_detect_stacks, request.user, options)\n        \n        logger.info(f\"Stack detection queued for user {request.user.username} with options: {options}\")\n        return Response(\n            {\n                \"status\": \"queued\",\n                \"message\": \"Stack detection started\",\n                \"options\": options,\n            },\n            status=status.HTTP_202_ACCEPTED,\n        )\n\n\nclass PhotoStackStatsView(APIView):\n    \"\"\"Get stack statistics for the current user.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def get(self, request):\n        # Include all stack types for stats (shows deprecated types for migration visibility)\n        all_stack_types = PhotoStack.VALID_STACK_TYPES + [\n            PhotoStack.StackType.RAW_JPEG_PAIR,\n            PhotoStack.StackType.LIVE_PHOTO,\n        ]\n        \n        stacks = PhotoStack.objects.filter(\n            owner=request.user,\n            stack_type__in=all_stack_types\n        )\n        \n        # Count by type (includes deprecated types so users can see migration progress)\n        by_type = {}\n        for stack_type in all_stack_types:\n            by_type[stack_type] = stacks.filter(stack_type=stack_type).count()\n        \n        # Count photos in stacks (ManyToMany - photos with at least one valid organizational stack)\n        photos_in_stacks = Photo.objects.filter(\n            owner=request.user,\n            stacks__stack_type__in=all_stack_types\n        ).distinct().count()\n        \n        total_photos = Photo.objects.filter(\n            owner=request.user, hidden=False, in_trashcan=False\n        ).count()\n\n        return Response({\n            \"total_stacks\": stacks.count(),\n            \"by_type\": by_type,\n            \"photos_in_stacks\": photos_in_stacks,\n            \"total_photos\": total_photos,\n        })\n\n\nclass CreateManualStackView(APIView):\n    \"\"\"Create a manual stack from selected photos.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def post(self, request):\n        photo_hashes = request.data.get(\"photo_hashes\", [])\n        \n        # De-duplicate the input to handle repeated hashes\n        unique_hashes = list(dict.fromkeys(photo_hashes))  # Preserves order\n        \n        if len(unique_hashes) < 2:\n            return Response(\n                {\"error\": \"At least 2 unique photos required to create a stack\"},\n                status=status.HTTP_400_BAD_REQUEST\n            )\n\n        # Verify all photos exist and belong to user\n        photos = Photo.objects.filter(\n            owner=request.user,\n            image_hash__in=unique_hashes\n        )\n        \n        if photos.count() != len(unique_hashes):\n            return Response(\n                {\"error\": \"Some photos not found\"},\n                status=status.HTTP_400_BAD_REQUEST\n            )\n\n        # Check if any photo is already in a manual stack\n        existing_stack = None\n        for photo in photos:\n            manual_stack = photo.stacks.filter(stack_type=PhotoStack.StackType.MANUAL).first()\n            if manual_stack:\n                existing_stack = manual_stack\n                break\n        \n        if existing_stack:\n            # Add to existing stack (ManyToMany)\n            stack = existing_stack\n            for photo in photos:\n                if not photo.stacks.filter(pk=stack.pk).exists():\n                    photo.stacks.add(stack)\n        else:\n            # Create new manual stack\n            stack = PhotoStack.objects.create(\n                owner=request.user,\n                stack_type=PhotoStack.StackType.MANUAL,\n            )\n            for photo in photos:\n                photo.stacks.add(stack)\n        \n        stack.auto_select_primary()\n        \n        logger.info(f\"Created/updated MANUAL stack {stack.id} with {photos.count()} photos\")\n        return Response({\n            \"status\": \"created\",\n            \"stack_id\": str(stack.id),\n            \"photo_count\": photos.count(),\n        }, status=status.HTTP_201_CREATED)\n\n\nclass AddToStackView(APIView):\n    \"\"\"Add photos to an existing stack.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def post(self, request, stack_id):\n        try:\n            stack = PhotoStack.objects.get(id=stack_id, owner=request.user)\n        except PhotoStack.DoesNotExist:\n            return Response(\n                {\"error\": \"Photo stack not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        photo_hashes = request.data.get(\"photo_hashes\", [])\n        if not photo_hashes:\n            return Response(\n                {\"error\": \"photo_hashes is required\"},\n                status=status.HTTP_400_BAD_REQUEST\n            )\n\n        photos = Photo.objects.filter(\n            owner=request.user,\n            image_hash__in=photo_hashes\n        )\n        \n        added_count = 0\n        for photo in photos:\n            if not photo.stacks.filter(pk=stack.pk).exists():\n                photo.stacks.add(stack)\n                added_count += 1\n\n        logger.info(f\"Added {added_count} photos to stack {stack.id}\")\n        return Response({\n            \"status\": \"updated\",\n            \"added_count\": added_count,\n            \"total_count\": stack.photos.count(),\n        })\n\n\nclass RemoveFromStackView(APIView):\n    \"\"\"Remove photos from a stack.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def post(self, request, stack_id):\n        try:\n            stack = PhotoStack.objects.get(id=stack_id, owner=request.user)\n        except PhotoStack.DoesNotExist:\n            return Response(\n                {\"error\": \"Photo stack not found\"}, status=status.HTTP_404_NOT_FOUND\n            )\n\n        photo_hashes = request.data.get(\"photo_hashes\", [])\n        if not photo_hashes:\n            return Response(\n                {\"error\": \"photo_hashes is required\"},\n                status=status.HTTP_400_BAD_REQUEST\n            )\n\n        photos = Photo.objects.filter(\n            owner=request.user,\n            image_hash__in=photo_hashes\n        )\n        \n        removed_count = 0\n        for photo in photos:\n            if photo.stacks.filter(pk=stack.pk).exists():\n                photo.stacks.remove(stack)\n                removed_count += 1\n\n        # Delete stack if it now has fewer than 2 photos\n        remaining_count = stack.photos.count()\n        if remaining_count < 2:\n            stack.delete()\n            logger.info(f\"Deleted stack {stack_id} after removing photos (only {remaining_count} left)\")\n            return Response({\n                \"status\": \"deleted\",\n                \"removed_count\": removed_count,\n                \"message\": \"Stack deleted because fewer than 2 photos remain\",\n            })\n\n        # Update primary if it was removed\n        if stack.primary_photo and stack.primary_photo.image_hash in photo_hashes:\n            stack.auto_select_primary()\n\n        logger.info(f\"Removed {removed_count} photos from stack {stack.id}\")\n        return Response({\n            \"status\": \"updated\",\n            \"removed_count\": removed_count,\n            \"total_count\": remaining_count,\n        })\n\n\nclass MergeStacksView(APIView):\n    \"\"\"Merge all manual stacks containing any of the selected photos into one stack.\"\"\"\n\n    permission_classes = [IsAuthenticated]\n\n    def post(self, request):\n        photo_hashes = request.data.get(\"photo_hashes\", [])\n        \n        if not photo_hashes:\n            return Response(\n                {\"error\": \"photo_hashes is required\"},\n                status=status.HTTP_400_BAD_REQUEST\n            )\n\n        # De-duplicate the input to handle repeated hashes\n        unique_hashes = list(dict.fromkeys(photo_hashes))  # Preserves order\n\n        # Verify all photos exist and belong to user\n        photos = Photo.objects.filter(\n            owner=request.user,\n            image_hash__in=unique_hashes\n        )\n        \n        if photos.count() != len(unique_hashes):\n            return Response(\n                {\"error\": \"Some photos not found\"},\n                status=status.HTTP_400_BAD_REQUEST\n            )\n\n        # Find all manual stacks that contain any of the selected photos\n        # Convert to list immediately to avoid multiple query evaluations with\n        # potentially inconsistent ordering\n        manual_stacks = list(PhotoStack.objects.filter(\n            owner=request.user,\n            stack_type=PhotoStack.StackType.MANUAL,\n            photos__in=photos\n        ).distinct())\n\n        if not manual_stacks:\n            return Response(\n                {\"error\": \"No manual stacks found containing selected photos\"},\n                status=status.HTTP_400_BAD_REQUEST\n            )\n\n        if len(manual_stacks) == 1:\n            # Only one stack found, nothing to merge\n            stack = manual_stacks[0]\n            return Response({\n                \"status\": \"no_merge_needed\",\n                \"stack_id\": str(stack.id),\n                \"photo_count\": stack.photos.count(),\n                \"message\": \"Only one stack found, nothing to merge\",\n            })\n\n        # Merge all stacks into the first one\n        target_stack = manual_stacks[0]\n        stacks_to_merge = manual_stacks[1:]\n        \n        for stack in stacks_to_merge:\n            target_stack.merge_with(stack)\n\n        # Recalculate primary if needed\n        if not target_stack.primary_photo:\n            target_stack.auto_select_primary()\n\n        logger.info(f\"Merged {len(stacks_to_merge)} manual stacks into {target_stack.id}\")\n        return Response({\n            \"status\": \"merged\",\n            \"stack_id\": str(target_stack.id),\n            \"photo_count\": target_stack.photos.count(),\n            \"merged_count\": len(stacks_to_merge),\n        }, status=status.HTTP_200_OK)\n"
  },
  {
    "path": "api/views/timezone.py",
    "content": "from rest_framework.response import Response\nfrom rest_framework.views import APIView\n\nfrom api import date_time_extractor\n\n\nclass TimeZoneView(APIView):\n    def get(self, request, format=None):\n        return Response(date_time_extractor.ALL_TIME_ZONES_JSON)\n"
  },
  {
    "path": "api/views/upload.py",
    "content": "import io\nimport os\n\nfrom chunked_upload.constants import http_status\nfrom chunked_upload.exceptions import ChunkedUploadError\nfrom chunked_upload.models import ChunkedUpload\nfrom chunked_upload.views import ChunkedUploadCompleteView, ChunkedUploadView\nfrom constance import config as site_config\nfrom django.core.files.base import ContentFile\nfrom django.shortcuts import get_object_or_404\nfrom django.utils.decorators import method_decorator\nfrom django.utils.text import get_valid_filename\nfrom django.views.decorators.csrf import csrf_exempt\nfrom django_q.tasks import Chain\nfrom rest_framework import viewsets\nfrom rest_framework.response import Response\nfrom rest_framework_simplejwt.exceptions import TokenError\nfrom rest_framework_simplejwt.tokens import AccessToken\n\nfrom api import util\nfrom api.directory_watcher import create_new_image, handle_new_image, is_valid_media\nfrom api.models import Photo, User\nfrom api.models.file import calculate_hash, calculate_hash_b64\nfrom api.models.photo_caption import PhotoCaption\n\n\ndef generate_captions_wrapper(photo, commit=True):\n    \"\"\"Wrapper function to generate captions for use in chain\"\"\"\n    caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)\n    caption_instance.generate_tag_captions(commit=commit)\n\n\nclass UploadPhotoExists(viewsets.ViewSet):\n    def retrieve(self, request, pk):\n        try:\n            Photo.objects.get(image_hash=pk)\n            return Response({\"exists\": True})\n        except Photo.DoesNotExist:\n            return Response({\"exists\": False})\n        except Photo.MultipleObjectsReturned:\n            # Multiple photos with same hash - photo exists\n            return Response({\"exists\": True})\n\n\n@method_decorator(csrf_exempt, name=\"dispatch\")\nclass UploadPhotosChunked(ChunkedUploadView):\n    model = ChunkedUpload\n\n    def check_permissions(self, request):\n        if not site_config.ALLOW_UPLOAD:\n            raise ChunkedUploadError(\n                status=http_status.HTTP_403_FORBIDDEN,\n                detail=\"Uploading is not allowed\",\n            )\n        jwt = request.COOKIES.get(\"jwt\")\n        if jwt is not None:\n            try:\n                token = AccessToken(jwt)\n            except TokenError:\n                raise ChunkedUploadError(\n                    status=http_status.HTTP_403_FORBIDDEN,\n                    detail=\"Authentication credentials were invalid\",\n                )\n        else:\n            raise ChunkedUploadError(\n                status=http_status.HTTP_403_FORBIDDEN,\n                detail=\"Authentication credentials were not provided\",\n            )\n        # To-Do: Check if file is allowed type\n        user = User.objects.filter(id=token[\"user_id\"]).first()\n        if not user or not user.is_authenticated:\n            raise ChunkedUploadError(\n                status=http_status.HTTP_403_FORBIDDEN,\n                detail=\"Authentication credentials were not provided\",\n            )\n\n    def create_chunked_upload(self, save=False, **attrs):\n        \"\"\"Creates new chunked upload instance. Called if no 'upload_id' is\n        found in the POST data.\n        \"\"\"\n        chunked_upload = self.model(**attrs)\n        # file starts empty\n        chunked_upload.file.save(name=\"tmp\", content=ContentFile(\"\"), save=save)\n        return chunked_upload\n\n\n@method_decorator(csrf_exempt, name=\"dispatch\")\nclass UploadPhotosChunkedComplete(ChunkedUploadCompleteView):\n    model = ChunkedUpload\n\n    def check_permissions(self, request):\n        if not site_config.ALLOW_UPLOAD:\n            raise ChunkedUploadError(\n                status=http_status.HTTP_403_FORBIDDEN,\n                detail=\"Uploading is not allowed\",\n            )\n        jwt = request.COOKIES.get(\"jwt\")\n        if jwt is not None:\n            try:\n                token = AccessToken(jwt)\n            except TokenError:\n                raise ChunkedUploadError(\n                    status=http_status.HTTP_403_FORBIDDEN,\n                    detail=\"Authentication credentials were invalid\",\n                )\n        else:\n            raise ChunkedUploadError(\n                status=http_status.HTTP_403_FORBIDDEN,\n                detail=\"Authentication credentials were not provided\",\n            )\n        user = User.objects.filter(id=token[\"user_id\"]).first()\n        if not user or not user.is_authenticated:\n            raise ChunkedUploadError(\n                status=http_status.HTTP_403_FORBIDDEN,\n                detail=\"Authentication credentials were not provided\",\n            )\n\n    def on_completion(self, uploaded_file, request):\n        jwt = request.COOKIES.get(\"jwt\")\n        if jwt is not None:\n            try:\n                token = AccessToken(jwt)\n            except TokenError:\n                raise ChunkedUploadError(\n                    status=http_status.HTTP_403_FORBIDDEN,\n                    detail=\"Authentication credentials were invalid\",\n                )\n        else:\n            raise ChunkedUploadError(\n                status=http_status.HTTP_403_FORBIDDEN,\n                detail=\"Authentication credentials were not provided\",\n            )\n\n        user = User.objects.filter(id=token[\"user_id\"]).first()\n        if not user or not user.is_authenticated:\n            raise ChunkedUploadError(\n                status=http_status.HTTP_403_FORBIDDEN,\n                detail=\"Authentication credentials were not provided\",\n            )\n\n        # Validate that user has a configured scan directory\n        if not user.scan_directory or user.scan_directory.strip() == \"\":\n            raise ChunkedUploadError(\n                status=http_status.HTTP_400_BAD_REQUEST,\n                detail=\"Upload failed: No scan directory configured. Please contact your administrator to set up a scan directory for your account.\",\n            )\n\n        # Validate that the scan directory exists\n        if not os.path.exists(user.scan_directory):\n            raise ChunkedUploadError(\n                status=http_status.HTTP_400_BAD_REQUEST,\n                detail=f\"Upload failed: Scan directory '{user.scan_directory}' does not exist. Please contact your administrator.\",\n            )\n\n        if not is_valid_media(uploaded_file.file.path, user):\n            chunked_upload = get_object_or_404(\n                ChunkedUpload, upload_id=request.POST.get(\"upload_id\")\n            )\n            chunked_upload.delete(delete_file=True)\n            raise ChunkedUploadError(\n                status=http_status.HTTP_400_BAD_REQUEST,\n                detail=\"File type not allowed\",\n            )\n\n        # Sanitize file name\n        filename = get_valid_filename(request.POST.get(\"filename\"))\n\n        # To-Do: Get origin device\n        device = \"web\"\n\n        if not os.path.exists(os.path.join(user.scan_directory, \"uploads\")):\n            os.mkdir(os.path.join(user.scan_directory, \"uploads\"))\n        if not os.path.exists(os.path.join(user.scan_directory, \"uploads\", device)):\n            os.mkdir(os.path.join(user.scan_directory, \"uploads\", device))\n        photo = uploaded_file\n        image_hash = calculate_hash_b64(user, io.BytesIO(photo.read()))\n        photo_path = \"\"\n\n        if not Photo.objects.filter(image_hash=image_hash).exists():\n            if not os.path.exists(\n                os.path.join(user.scan_directory, \"uploads\", device, filename)\n            ):\n                photo_path = os.path.join(\n                    user.scan_directory, \"uploads\", device, filename\n                )\n            else:\n                existing_photo_hash = calculate_hash(\n                    user, os.path.join(user.scan_directory, \"uploads\", device, filename)\n                )\n\n                file_name = os.path.splitext(os.path.basename(filename))[0]\n                file_name_extension = os.path.splitext(os.path.basename(filename))[1]\n\n                if existing_photo_hash == image_hash:\n                    # File already exist, do not copy it in the upload folder\n                    util.logger.info(\n                        f\"Photo {filename} duplicated with hash {image_hash} \"\n                    )\n                else:\n                    photo_path = os.path.join(\n                        user.scan_directory,\n                        \"uploads\",\n                        device,\n                        file_name + \"_\" + image_hash + file_name_extension,\n                    )\n\n        else:\n            util.logger.info(f\"Photo {filename} duplicated with hash {image_hash} \")\n\n        if photo_path:\n            with open(photo_path, \"wb\") as f:\n                photo.seek(0)\n                f.write(photo.read())\n\n        chunked_upload = get_object_or_404(\n            ChunkedUpload, upload_id=request.POST.get(\"upload_id\")\n        )\n        chunked_upload.delete(delete_file=True)\n\n        if not photo_path:\n            return Response(\n                {\"detail\": \"Photo duplicated. No new import performed.\"},\n                status=http_status.HTTP_200_OK,\n            )\n\n        chain = Chain()\n        photo = create_new_image(user, photo_path)\n        chain.append(handle_new_image, user, photo_path, image_hash, photo)\n        chain.append(generate_captions_wrapper, photo, True)\n        chain.append(photo._geolocate)\n        chain.append(photo._add_location_to_album_dates)\n        chain.append(photo._extract_faces)\n        chain.run()\n"
  },
  {
    "path": "api/views/user.py",
    "content": "from django.conf import settings\nfrom rest_framework import status, viewsets\nfrom rest_framework.permissions import AllowAny, IsAdminUser\nfrom rest_framework.response import Response\nfrom rest_framework.views import APIView\n\nfrom api.api_util import path_to_dict\nfrom api.date_time_extractor import DEFAULT_RULES_JSON, PREDEFINED_RULES_JSON\nfrom api.burst_detection_rules import (\n    DEFAULT_RULES_JSON as BURST_DEFAULT_RULES_JSON,\n    PREDEFINED_RULES_JSON as BURST_PREDEFINED_RULES_JSON,\n)\nfrom api.models import User\nfrom api.permissions import IsAdminOrFirstTimeSetupOrRegistrationAllowed, IsAdminOrSelf\nfrom api.serializers.user import (\n    DeleteUserSerializer,\n    ManageUserSerializer,\n    PublicUserSerializer,\n    SignupUserSerializer,\n    UserSerializer,\n)\nfrom api.util import is_valid_path, logger\n\n\nclass DefaultRulesView(APIView):\n    def get(self, request, format=None):\n        res = DEFAULT_RULES_JSON\n        return Response(res)\n\n\nclass PredefinedRulesView(APIView):\n    def get(self, request, format=None):\n        res = PREDEFINED_RULES_JSON\n        return Response(res)\n\n\nclass DefaultBurstRulesView(APIView):\n    \"\"\"Get default burst detection rules.\"\"\"\n    def get(self, request, format=None):\n        res = BURST_DEFAULT_RULES_JSON\n        return Response(res)\n\n\nclass PredefinedBurstRulesView(APIView):\n    \"\"\"Get all predefined burst detection rules (default + optional).\"\"\"\n    def get(self, request, format=None):\n        res = BURST_PREDEFINED_RULES_JSON\n        return Response(res)\n\n\nclass RootPathTreeView(APIView):\n    permission_classes = (IsAdminUser,)\n\n    def get(self, request, format=None):\n        try:\n            path = self.request.query_params.get(\"path\")\n            base_path = settings.DATA_ROOT\n\n            # Default to root directory if no path is provided\n            if not path:\n                path = base_path\n\n            # Validate the requested path\n            if not is_valid_path(path, base_path):\n                return Response(\n                    {\n                        \"message\": \"Access denied. Path is outside the allowed directory.\"\n                    },\n                    status=403,\n                )\n\n            res = [path_to_dict(path)]\n            return Response(res)\n        except Exception as e:\n            logger.exception(str(e))\n            return Response({\"message\": str(e)}, status=500)\n\n\nclass IsFirstTimeSetupView(APIView):\n    permission_classes = (AllowAny,)\n\n    def get(self, request):\n        return Response(\n            {\"isFirstTimeSetup\": not User.objects.filter(is_superuser=True).exists()}\n        )\n\n\n# To-Do: This executes multiple querys per users\nclass UserViewSet(viewsets.ModelViewSet):\n    def get_queryset(self):\n        queryset = (\n            User.objects.exclude(is_active=False)\n            .only(\n                \"id\",\n                \"username\",\n                \"email\",\n                \"scan_directory\",\n                \"transcode_videos\",\n                \"confidence\",\n                \"confidence_person\",\n                \"semantic_search_topk\",\n                \"first_name\",\n                \"last_name\",\n                \"date_joined\",\n                \"avatar\",\n                \"nextcloud_server_address\",\n                \"nextcloud_username\",\n                \"nextcloud_scan_directory\",\n                \"favorite_min_rating\",\n                \"image_scale\",\n                \"save_metadata_to_disk\",\n                \"datetime_rules\",\n                \"default_timezone\",\n                \"is_superuser\",\n                \"public_sharing\",\n            )\n            .order_by(\"id\")\n        )\n        if not self.request.user.is_authenticated:\n            return queryset.exclude(public_sharing=False)\n        return queryset\n\n    def get_serializer_class(self):\n        if self.action == \"create\":\n            if self.request.user.is_authenticated and self.request.user.is_superuser:\n                return UserSerializer\n            return SignupUserSerializer\n        if not self.request.user.is_authenticated:\n            return PublicUserSerializer\n        return UserSerializer\n\n    def get_permissions(self):\n        permission_classes = [IsAdminUser]\n        if self.action == \"create\":\n            permission_classes = [IsAdminOrFirstTimeSetupOrRegistrationAllowed]\n        elif self.action in [\"list\", \"retrieve\"]:\n            permission_classes = [AllowAny]\n        elif self.action in [\"update\", \"partial_update\"]:\n            permission_classes = [IsAdminOrSelf]\n        return [p() for p in permission_classes]\n\n\nclass DeleteUserViewSet(viewsets.ModelViewSet):\n    queryset = User.objects.all().order_by(\"id\")\n    serializer_class = DeleteUserSerializer\n    permission_classes = (IsAdminUser,)\n\n    def destroy(self, request, *args, **kwargs):\n        if not request.user.is_superuser:\n            return Response(status=status.HTTP_401_UNAUTHORIZED)\n        instance = self.get_object()\n\n        if instance.is_superuser:\n            return Response(status=status.HTTP_400_BAD_REQUEST)\n\n        return super().destroy(request, *args, **kwargs)\n\n\nclass ManageUserViewSet(viewsets.ModelViewSet):\n    queryset = User.objects.all().order_by(\"id\")\n    serializer_class = ManageUserSerializer\n    permission_classes = (IsAdminUser,)\n\n    def retrieve(self, *args, **kwargs):\n        return super().retrieve(*args, **kwargs)\n\n    def list(self, *args, **kwargs):\n        return super().list(*args, **kwargs)\n"
  },
  {
    "path": "api/views/views.py",
    "content": "import os\nimport subprocess\nimport uuid\nfrom urllib.parse import quote\n\nimport jsonschema\nimport magic\nfrom constance import config as site_config\nfrom django.conf import settings\nfrom django.db.models import Q, Sum\nfrom django.http import (\n    FileResponse,\n    HttpResponse,\n    HttpResponseForbidden,\n    StreamingHttpResponse,\n)\nfrom django.utils.decorators import method_decorator\nfrom django.utils.encoding import iri_to_uri\nfrom django.utils import timezone\nfrom django.views.decorators.cache import cache_page\nfrom django.views.decorators.vary import vary_on_cookie\nfrom django_q.tasks import AsyncTask, Chain\nfrom drf_spectacular.utils import extend_schema\nfrom rest_framework import viewsets\nfrom rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated\nfrom rest_framework.response import Response\nfrom rest_framework.views import APIView, exception_handler\nfrom rest_framework_simplejwt.exceptions import TokenError\nfrom rest_framework_simplejwt.tokens import AccessToken\n\nfrom api.all_tasks import create_download_job, delete_zip_file\nfrom api.api_util import get_search_term_examples\nfrom api.autoalbum import delete_missing_photos\nfrom api.directory_watcher import scan_photos\nfrom api.ml_models import do_all_models_exist, download_models\nfrom api.models import AlbumUser, LongRunningJob, Photo, User\nfrom api.schemas.site_settings import site_settings_schema\nfrom api.serializers.album_user import AlbumUserEditSerializer, AlbumUserListSerializer\nfrom api.util import logger\nfrom api.views.pagination import StandardResultsSetPagination\n\n\ndef custom_exception_handler(exc, context):\n    # Call REST framework's default exception handler first,\n    # to get the standard error response.\n    response = exception_handler(exc, context)\n\n    # Update the structure of the response data and enrich auth errors.\n    if response is not None:\n        customized_response = {\"errors\": []}\n\n        if isinstance(response.data, dict):\n            for key, value in response.data.items():\n                error = {\"field\": key, \"message\": \"\".join(str(value))}\n                customized_response[\"errors\"].append(error)\n        elif isinstance(response.data, list):\n            # Handle ValidationError raised with a string (creates a list)\n            for item in response.data:\n                error = {\"field\": \"non_field_errors\", \"message\": str(item)}\n                customized_response[\"errors\"].append(error)\n\n        # Add actionable guidance for unauthenticated/forbidden responses\n        if getattr(response, \"status_code\", None) in (401, 403) and settings.DEBUG:\n            customized_response[\"errors\"].append(\n                {\n                    \"field\": \"auth\",\n                    \"message\": (\n                        \"Authentication required. Obtain a JWT via POST /api/auth/token/obtain/ \"\n                        'with JSON {\"username\":\"<user>\", \"password\":\"<pass>\"}. '\n                        \"Then call APIs with header Authorization: Bearer <access_token> (or use the 'jwt' cookie set by the obtain/refresh endpoints). \"\n                        \"See /api/help and docs at https://docs.librephotos.com/docs/user-guide/api-authentication.\"\n                    ),\n                }\n            )\n\n        response.data = customized_response\n\n    return response\n\n\nclass AlbumUserEditViewSet(viewsets.ModelViewSet):\n    serializer_class = AlbumUserEditSerializer\n    pagination_class = StandardResultsSetPagination\n\n    def retrieve(self, *args, **kwargs):\n        return super().retrieve(*args, **kwargs)\n\n    def list(self, *args, **kwargs):\n        return super().list(*args, **kwargs)\n\n    def get_queryset(self):\n        if self.request.user.is_anonymous:\n            return AlbumUser.objects.none()\n        return AlbumUser.objects.filter(owner=self.request.user).order_by(\"title\")\n\n    def get_permissions(self):\n        if self.action in [\"list\", \"retrieve\"]:\n            self.permission_classes = (IsAuthenticated,)\n        else:\n            self.permission_classes = (IsAuthenticated,)\n\n        return super().get_permissions()\n\n\n# API Views\nclass SiteSettingsView(APIView):\n    def get_permissions(self):\n        if self.request.method == \"GET\":\n            self.permission_classes = (AllowAny,)\n        else:\n            self.permission_classes = (IsAdminUser,)\n\n        return super(SiteSettingsView, self).get_permissions()\n\n    def get(self, request, format=None):\n        out = {}\n        out[\"allow_registration\"] = site_config.ALLOW_REGISTRATION\n        out[\"allow_upload\"] = site_config.ALLOW_UPLOAD\n        out[\"skip_patterns\"] = site_config.SKIP_PATTERNS\n        out[\"heavyweight_process\"] = 0\n        out[\"map_api_provider\"] = site_config.MAP_API_PROVIDER\n        out[\"map_api_key\"] = site_config.MAP_API_KEY\n        out[\"captioning_model\"] = site_config.CAPTIONING_MODEL\n        out[\"llm_model\"] = site_config.LLM_MODEL\n        out[\"tagging_model\"] = site_config.TAGGING_MODEL\n        return Response(out)\n\n    def post(self, request, format=None):\n        jsonschema.validate(request.data, site_settings_schema)\n        if \"allow_registration\" in request.data.keys():\n            site_config.ALLOW_REGISTRATION = request.data[\"allow_registration\"]\n        if \"allow_upload\" in request.data.keys():\n            site_config.ALLOW_UPLOAD = request.data[\"allow_upload\"]\n        if \"skip_patterns\" in request.data.keys():\n            site_config.SKIP_PATTERNS = request.data[\"skip_patterns\"]\n        if \"map_api_provider\" in request.data.keys():\n            site_config.MAP_API_PROVIDER = request.data[\"map_api_provider\"]\n        if \"map_api_key\" in request.data.keys():\n            site_config.MAP_API_KEY = request.data[\"map_api_key\"]\n        if \"captioning_model\" in request.data.keys():\n            site_config.CAPTIONING_MODEL = request.data[\"captioning_model\"]\n        if \"llm_model\" in request.data.keys():\n            site_config.LLM_MODEL = request.data[\"llm_model\"]\n        if \"tagging_model\" in request.data.keys():\n            site_config.TAGGING_MODEL = request.data[\"tagging_model\"]\n        if not do_all_models_exist():\n            AsyncTask(download_models, User.objects.get(id=request.user.id)).run()\n\n        return self.get(request, format=format)\n\n\nclass SetUserAlbumShared(APIView):\n    def post(self, request, format=None):\n        data = dict(request.data)\n        shared = data[\"shared\"]  # bool\n        target_user_id = data[\"target_user_id\"]  # user pk, int\n        user_album_id = data[\"album_id\"]\n\n        try:\n            target_user = User.objects.get(id=target_user_id)\n        except User.DoesNotExist:\n            logger.warning(\n                f\"Cannot share album to user: target user_id {target_user_id} does not exist\"\n            )\n            return Response(\n                {\"status\": False, \"message\": \"No such user\"}, status_code=400\n            )\n\n        try:\n            user_album_to_share = AlbumUser.objects.get(id=user_album_id)\n        except AlbumUser.DoesNotExist:\n            logger.warning(\n                f\"Cannot share album to user: source user_album_id {user_album_id} does not exist\"\n            )\n            return Response(\n                {\"status\": False, \"message\": \"No such album\"}, status_code=400\n            )\n\n        if user_album_to_share.owner != request.user:\n            logger.warning(\n                f\"Cannot share album to user: source user_album_id {user_album_id} does not belong to user_id {request.user.id}\"\n            )\n            return Response(\n                {\"status\": False, \"message\": \"You cannot share an album you don't own\"},\n                status_code=400,\n            )\n\n        if shared:\n            user_album_to_share.shared_to.add(target_user)\n            logger.info(\n                f\"Shared user {request.user.id}'s album {user_album_id} to user {target_user_id}\"\n            )\n        else:\n            user_album_to_share.shared_to.remove(target_user)\n            logger.info(\n                f\"Unshared user {request.user.id}'s album {user_album_id} to user {target_user_id}\"\n            )\n\n        user_album_to_share.save()\n        return Response(AlbumUserListSerializer(user_album_to_share).data)\n\n\n# Utility views\n\n\nclass StorageStatsView(APIView):\n    def get(self, request, format=None):\n        import shutil\n\n        total_storage, used_storage, free_storage = shutil.disk_usage(\n            settings.DATA_ROOT\n        )\n        return Response(\n            {\n                \"total_storage\": total_storage,\n                \"used_storage\": used_storage,\n                \"free_storage\": free_storage,\n            }\n        )\n\n\nclass ApiHelpView(APIView):\n    permission_classes = (AllowAny,)\n\n    def get(self, request, format=None):\n        base = \"\"\n        try:\n            base = request.build_absolute_uri(\"/\").rstrip(\"/\")\n        except Exception:\n            base = \"\"\n\n        data = {\n            \"about\": \"LibrePhotos API Help\",\n            \"authentication\": {\n                \"default_authentication_classes\": [\n                    \"rest_framework_simplejwt.authentication.JWTAuthentication\",\n                    \"rest_framework.authentication.BasicAuthentication\",\n                ],\n                \"jwt\": {\n                    \"obtain\": f\"{base}/api/auth/token/obtain/\",\n                    \"refresh\": f\"{base}/api/auth/token/refresh/\",\n                    \"how_to\": \"POST username and password as JSON to obtain, then send Authorization: Bearer <access_token> or rely on 'jwt' cookie set by obtain/refresh endpoints.\",\n                },\n                \"basic\": {\n                    \"how_to\": \"Send Authorization: Basic base64(username:password).\",\n                },\n            },\n            \"useful_endpoints\": {\n                \"api_root\": f\"{base}/\",  # browsable API may be disabled when serving frontend\n                \"help\": f\"{base}/api/help\",\n                \"photos\": f\"{base}/api/photos/\",\n                \"search\": f\"{base}/api/photos/searchlist/\",\n            },\n            \"documentation\": {\n                \"api_authentication\": \"https://docs.librephotos.com/docs/user-guide/api-authentication\",\n            },\n            \"examples\": {\n                \"obtain_token_curl\": (\n                    \"curl -X POST \\\"{base}/api/auth/token/obtain/\\\" -H 'Content-Type: application/json' \"\n                    \"-d '{\"\n                    \"username\"\n                    \": \"\n                    \"myuser\"\n                    \", \"\n                    \"password\"\n                    \": \"\n                    \"mypassword\"\n                    \"}'\"\n                ),\n                \"call_api_with_bearer\": (\n                    \"curl -H 'Authorization: Bearer <access_token>' \\\"{base}/api/photos/\\\"\"\n                ),\n                \"call_api_with_basic\": (\n                    'curl -u myuser:mypassword \"{base}/api/photos/\"'\n                ),\n            },\n        }\n\n        # Add schema links in DEBUG mode if available\n        try:\n            if settings.DEBUG:\n                data.setdefault(\"useful_endpoints\", {}).update(\n                    {\n                        \"openapi_schema\": f\"{base}/api/schema\",\n                        \"swagger_ui\": f\"{base}/api/swagger\",\n                        \"redoc\": f\"{base}/api/redoc\",\n                    }\n                )\n        except Exception:\n            pass\n\n        return Response(data)\n\n\nclass ImageTagView(APIView):\n    @method_decorator(cache_page(60 * 60 * 2))\n    def get(self, request, format=None):\n        # Add an exception for the directory '/code'\n        subprocess.run(\n            [\"git\", \"config\", \"--global\", \"--add\", \"safe.directory\", \"/code\"],\n            check=False,\n        )\n\n        # Get the current commit hash\n        git_hash = (\n            subprocess.check_output([\"git\", \"rev-parse\", \"--short\", \"HEAD\"])\n            .strip()\n            .decode(\"utf-8\")\n        )\n        return Response(\n            {\"image_tag\": os.environ.get(\"IMAGE_TAG\", \"\"), \"git_hash\": git_hash}\n        )\n\n\nclass SearchTermExamples(APIView):\n    @method_decorator(vary_on_cookie)\n    @method_decorator(cache_page(60 * 60 * 2))\n    def get(self, request, format=None):\n        search_term_examples = get_search_term_examples(request.user)\n        return Response({\"results\": search_term_examples})\n\n\n# long running jobs\nclass ScanPhotosView(APIView):\n    def post(self, request, format=None):\n        return self._scan_photos(request)\n\n    @extend_schema(\n        deprecated=True,\n        description=\"Use POST method instead\",\n    )\n    def get(self, request, format=None):\n        return self._scan_photos(request)\n\n    def _scan_photos(self, request):\n        # Validate that user has a configured scan directory\n        if not request.user.scan_directory or request.user.scan_directory.strip() == \"\":\n            return Response(\n                {\n                    \"status\": False,\n                    \"message\": \"Scan failed: No scan directory configured. Please contact your administrator to set up a scan directory for your account.\",\n                },\n                status=400,\n            )\n\n        # Validate that the scan directory exists\n        if not os.path.exists(request.user.scan_directory):\n            return Response(\n                {\n                    \"status\": False,\n                    \"message\": f\"Scan failed: Scan directory '{request.user.scan_directory}' does not exist. Please contact your administrator.\",\n                },\n                status=400,\n            )\n\n        chain = Chain()\n        if not do_all_models_exist():\n            chain.append(download_models, request.user)\n        try:\n            job_id = uuid.uuid4()\n            chain.append(\n                scan_photos, request.user, False, job_id, request.user.scan_directory\n            )\n            chain.run()\n            return Response({\"status\": True, \"job_id\": job_id})\n        except BaseException:\n            logger.exception(\"An Error occurred\")\n            return Response({\"status\": False})\n\n\n# To-Do: Allow for custom paths\nclass SelectiveScanPhotosView(APIView):\n    def get(self, request, format=None):\n        # Validate that user has a configured scan directory\n        if not request.user.scan_directory or request.user.scan_directory.strip() == \"\":\n            return Response(\n                {\n                    \"status\": False,\n                    \"message\": \"Scan failed: No scan directory configured. Please contact your administrator to set up a scan directory for your account.\",\n                },\n                status=400,\n            )\n\n        # Validate that the scan directory exists\n        if not os.path.exists(request.user.scan_directory):\n            return Response(\n                {\n                    \"status\": False,\n                    \"message\": f\"Scan failed: Scan directory '{request.user.scan_directory}' does not exist. Please contact your administrator.\",\n                },\n                status=400,\n            )\n\n        chain = Chain()\n        if not do_all_models_exist():\n            chain.append(download_models, request.user)\n        # To-Do: Sanatize the scan_directory\n        try:\n            job_id = uuid.uuid4()\n            chain.append(\n                scan_photos,\n                request.user,\n                False,\n                job_id,\n                os.path.join(request.user.scan_directory, \"uploads\", \"web\"),\n            )\n            chain.run()\n            return Response({\"status\": True, \"job_id\": job_id})\n        except BaseException:\n            logger.exception(\"An Error occurred\")\n            return Response({\"status\": False})\n\n\nclass FullScanPhotosView(APIView):\n    def post(self, request, format=None):\n        return self._scan_photos(request)\n\n    @extend_schema(\n        deprecated=True,\n        description=\"Use POST method instead\",\n    )\n    def get(self, request, format=None):\n        return self._scan_photos(request)\n\n    def _scan_photos(self, request):\n        chain = Chain()\n        if not do_all_models_exist():\n            chain.append(download_models, request.user)\n        try:\n            job_id = uuid.uuid4()\n            chain.append(\n                scan_photos, request.user, True, job_id, request.user.scan_directory\n            )\n            chain.run()\n            return Response({\"status\": True, \"job_id\": job_id})\n        except BaseException:\n            logger.exception(\"An Error occurred\")\n            return Response({\"status\": False})\n\n\nclass DeleteMissingPhotosView(APIView):\n    def post(self, request, format=None):\n        return self._delete_missing_photos(request, format)\n\n    @extend_schema(\n        deprecated=True,\n        description=\"Use POST method instead\",\n    )\n    def get(self, request, format=None):\n        return self._delete_missing_photos(request, format)\n\n    def _delete_missing_photos(self, request, format=None):\n        try:\n            job_id = uuid.uuid4()\n            delete_missing_photos(request.user, job_id)\n            return Response({\"status\": True, \"job_id\": job_id})\n        except BaseException:\n            logger.exception(\"An Error occurred\")\n            return Response({\"status\": False})\n\n\nclass MediaAccessView(APIView):\n    permission_classes = (AllowAny,)\n\n    def _get_protected_media_url(self, path, fname):\n        return f\"protected_media/{path}/{fname}\"\n\n    # @silk_profile(name='media')\n    def get(self, request, path, fname, format=None):\n        jwt = request.COOKIES.get(\"jwt\")\n        image_hash = fname.split(\".\")[0].split(\"_\")[0]\n        try:\n            photo = Photo.objects.get(image_hash=image_hash)\n        except Photo.DoesNotExist:\n            return HttpResponse(status=404)\n        except Photo.MultipleObjectsReturned:\n            # Multiple photos with same hash - find one that matches permissions\n            photos = Photo.objects.filter(image_hash=image_hash)\n            photo = None\n            # First try to find one that's public or in a public album\n            for p in photos:\n                if p.public or p.albumuser_set.filter(public=True).exists():\n                    photo = p\n                    break\n            # If none found, we'll check user permissions below\n            if photo is None:\n                photo = photos.first()\n\n        # grant access if the requested photo is public or part of any public user album\n        if photo.public or photo.albumuser_set.filter(public=True).exists():\n            response = HttpResponse()\n            response[\"Content-Type\"] = \"image/jpeg\"\n            response[\"X-Accel-Redirect\"] = self._get_protected_media_url(path, fname)\n            return response\n\n        # forbid access if trouble with jwt\n        if jwt is not None:\n            try:\n                token = AccessToken(jwt)\n            except TokenError:\n                return HttpResponseForbidden()\n        else:\n            return HttpResponseForbidden()\n\n        # grant access if the user is owner of the requested photo,\n        # the photo is shared with the user, or the photo belongs to a public user album\n        image_hash = fname.split(\".\")[0].split(\"_\")[0]  # janky alert\n        user = User.objects.filter(id=token[\"user_id\"]).only(\"id\").first()\n        if photo.owner == user or user in photo.shared_to.all():\n            response = HttpResponse()\n            response[\"Content-Type\"] = \"image/jpeg\"\n            response[\"X-Accel-Redirect\"] = self._get_protected_media_url(path, fname)\n            return response\n        else:\n            for album in photo.albumuser_set.only(\"shared_to\", \"public\"):\n                if album.public or user in album.shared_to.all():\n                    response = HttpResponse()\n                    response[\"Content-Type\"] = \"image/jpeg\"\n                    response[\"X-Accel-Redirect\"] = self._get_protected_media_url(\n                        path, fname\n                    )\n                    return response\n        return HttpResponse(status=404)\n\n\nclass VideoTranscoder:\n    process = \"\"\n\n    def __init__(self, path):\n        ffmpeg_command = [\n            \"ffmpeg\",\n            \"-i\",\n            path,\n            \"-vcodec\",\n            \"libx264\",\n            \"-preset\",\n            \"ultrafast\",\n            \"-movflags\",\n            \"frag_keyframe+empty_moov\",\n            \"-filter:v\",\n            (\"scale=-2:\" + str(720)),\n            \"-f\",\n            \"mp4\",\n            \"-\",\n        ]\n        self.process = subprocess.Popen(\n            ffmpeg_command,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n        )\n\n    def __del__(self):\n        self.process.kill()\n\n\ndef gen(transcoder):\n    yield from iter(transcoder.process.stdout.readline, b\"\")\n\n\nclass UnifiedMediaAccessView(APIView):\n    \"\"\"\n    Unified media access endpoint supporting both proxy and no-proxy setups,\n    and handling public album media access.\n    \"\"\"\n\n    permission_classes = (AllowAny,)\n\n    def _should_use_proxy(self):\n        return not getattr(settings, \"SERVE_FRONTEND\", False)\n\n    def _protected_media_url(self, path, fname):\n        path = path.lstrip(\"/\")\n        return f\"/protected_media/{path}/{fname}\"\n\n    def _serve_file_direct(self, file_path, content_type=None):\n        if not os.path.exists(file_path):\n            return HttpResponse(status=404)\n        try:\n            response = FileResponse(open(file_path, \"rb\"))\n            if content_type:\n                response[\"Content-Type\"] = content_type\n            else:\n                try:\n                    mime = magic.Magic(mime=True)\n                    response[\"Content-Type\"] = mime.from_file(file_path)\n                except Exception:\n                    response[\"Content-Type\"] = \"application/octet-stream\"\n            return response\n        except (FileNotFoundError, PermissionError):\n            return HttpResponse(status=404)\n        except Exception:\n            return HttpResponse(status=500)\n\n    def _generate_response_proxy(self, photo, path, fname, transcode_videos):\n        if \"thumbnail\" in path:\n            response = HttpResponse()\n\n            # thumbnails_big is always .webp (static image), even for videos\n            if \"thumbnails_big\" in path:\n                response[\"Content-Type\"] = \"image/webp\"\n                response[\"X-Accel-Redirect\"] = self._protected_media_url(\n                    path, fname + \".webp\"\n                )\n                return response\n\n            # For square_thumbnails, use the actual extension from the model\n            ext = (\n                os.path.splitext(getattr(photo.thumbnail, \"square_thumbnail\").path)[1]\n                if hasattr(photo, \"thumbnail\")\n                else \"\"\n            )\n            if \"jpg\" in ext:\n                response[\"Content-Type\"] = \"image/jpg\"\n                response[\"X-Accel-Redirect\"] = getattr(\n                    photo.thumbnail, \"thumbnail_big\", photo.thumbnail.square_thumbnail\n                ).path\n            if \"webp\" in ext:\n                response[\"Content-Type\"] = \"image/webp\"\n                response[\"X-Accel-Redirect\"] = self._protected_media_url(\n                    path, fname + \".webp\"\n                )\n            if \"mp4\" in ext:\n                response[\"Content-Type\"] = \"video/mp4\"\n                response[\"X-Accel-Redirect\"] = self._protected_media_url(\n                    path, fname + \".mp4\"\n                )\n            return response\n\n        if \"faces\" in path:\n            response = HttpResponse()\n            response[\"Content-Type\"] = \"image/jpg\"\n            response[\"X-Accel-Redirect\"] = self._protected_media_url(path, fname)\n            return response\n\n        if photo.video:\n            mime = magic.Magic(mime=True)\n            filename = mime.from_file(photo.main_file.path)\n            if transcode_videos:\n                response = StreamingHttpResponse(\n                    gen(VideoTranscoder(photo.main_file.path)),\n                    content_type=\"video/mp4\",\n                )\n                return response\n            response = HttpResponse()\n            response[\"Content-Type\"] = filename\n            response[\"X-Accel-Redirect\"] = iri_to_uri(\n                photo.main_file.path.replace(settings.DATA_ROOT, \"/original\")\n            )\n            return response\n\n        response = HttpResponse()\n        response[\"Content-Type\"] = \"image/jpg\"\n        response[\"X-Accel-Redirect\"] = self._protected_media_url(path, fname)\n        return response\n\n    def _generate_response_direct(self, photo, path, fname, transcode_videos):\n        if \"thumbnail\" in path:\n            file_path = os.path.join(settings.MEDIA_ROOT, path, fname)\n            if not os.path.exists(file_path):\n                if not fname.endswith(\".webp\"):\n                    webp = os.path.join(settings.MEDIA_ROOT, path, fname + \".webp\")\n                    if os.path.exists(webp):\n                        return self._serve_file_direct(webp, \"image/webp\")\n                if not fname.endswith(\".mp4\"):\n                    mp4 = os.path.join(settings.MEDIA_ROOT, path, fname + \".mp4\")\n                    if os.path.exists(mp4):\n                        return self._serve_file_direct(mp4, \"video/mp4\")\n            if hasattr(photo, \"thumbnail\"):\n                ext = os.path.splitext(photo.thumbnail.square_thumbnail.path)[1]\n                if \"jpg\" in ext:\n                    return self._serve_file_direct(\n                        photo.thumbnail.thumbnail_big.path, \"image/jpg\"\n                    )\n            return self._serve_file_direct(file_path)\n\n        if \"faces\" in path:\n            file_path = os.path.join(settings.MEDIA_ROOT, path, fname)\n            return self._serve_file_direct(file_path, \"image/jpg\")\n\n        if photo.video:\n            return self._serve_file_direct(photo.main_file.path)\n\n        file_path = os.path.join(settings.MEDIA_ROOT, path, fname)\n        return self._serve_file_direct(file_path, \"image/jpg\")\n\n    def _public_album_active_q(self):\n        return Q(share__enabled=True) & (\n            Q(share__expires_at__isnull=True) | Q(share__expires_at__gte=timezone.now())\n        )\n\n    def get(self, request, path, fname, album_id=None, format=None):\n        use_proxy = self._should_use_proxy()\n\n        # ZIP files\n        if path.lower() == \"zip\":\n            jwt = request.COOKIES.get(\"jwt\")\n            if jwt is not None:\n                try:\n                    token = AccessToken(jwt)\n                except TokenError:\n                    return HttpResponseForbidden()\n            else:\n                return HttpResponseForbidden()\n            try:\n                filename = fname + str(token[\"user_id\"]) + \".zip\"\n                if use_proxy:\n                    response = HttpResponse()\n                    response[\"Content-Type\"] = \"application/x-zip-compressed\"\n                    response[\"X-Accel-Redirect\"] = self._protected_media_url(\n                        path, filename\n                    )\n                    return response\n                file_path = os.path.join(settings.MEDIA_ROOT, path, filename)\n                return self._serve_file_direct(\n                    file_path, \"application/x-zip-compressed\"\n                )\n            except Exception:\n                return HttpResponseForbidden()\n\n        # Avatars\n        if path.lower() == \"avatars\":\n            jwt = request.COOKIES.get(\"jwt\")\n            if jwt is not None:\n                try:\n                    token = AccessToken(jwt)\n                except TokenError:\n                    return HttpResponseForbidden()\n            else:\n                return HttpResponseForbidden()\n            try:\n                _ = User.objects.filter(id=token[\"user_id\"]).only(\"id\").first()\n                if use_proxy:\n                    response = HttpResponse()\n                    response[\"Content-Type\"] = \"image/png\"\n                    response[\"X-Accel-Redirect\"] = self._protected_media_url(\n                        path, fname\n                    )\n                    return response\n                file_path = os.path.join(settings.MEDIA_ROOT, path, fname)\n                return self._serve_file_direct(file_path, \"image/png\")\n            except Exception:\n                return HttpResponse(status=404)\n\n        # Embedded media\n        if path.lower() == \"embedded_media\":\n            jwt = request.COOKIES.get(\"jwt\")\n            query = Q(public=True)\n            if request.user.is_authenticated:\n                query = Q(owner=request.user)\n            if jwt is not None:  # pragma: no cover\n                try:\n                    token = AccessToken(jwt)\n                    user = User.objects.filter(id=token[\"user_id\"]).only(\"id\").first()\n                    query = Q(owner=user)\n                except TokenError:\n                    pass\n            try:\n                # Check if fname is UUID format (36 chars with 4 hyphens) or image_hash\n                is_uuid_format = len(fname) == 36 and fname.count(\"-\") == 4\n                if is_uuid_format:\n                    photo = Photo.objects.filter(query, pk=fname).first()\n                else:\n                    photo = Photo.objects.filter(query, image_hash=fname).first()\n                if not photo or photo.main_file.embedded_media.count() < 1:\n                    raise Photo.DoesNotExist()\n            except Photo.DoesNotExist:\n                return HttpResponse(status=404)\n            if use_proxy:\n                response = HttpResponse()\n                response[\"Content-Type\"] = \"video/mp4\"\n                response[\"X-Accel-Redirect\"] = self._protected_media_url(\n                    path, fname + \"_1.mp4\"\n                )\n                return response\n            file_path = os.path.join(settings.MEDIA_ROOT, path, fname + \"_1.mp4\")\n            return self._serve_file_direct(file_path, \"video/mp4\")\n\n        # Determine photo by hash\n        image_hash = fname.split(\".\")[0].split(\"_\")[0]\n\n        # Public album access\n        if album_id is not None:\n            album = (\n                AlbumUser.objects.filter(id=album_id)\n                .filter(self._public_album_active_q())\n                .first()\n            )\n            if album is None:\n                return HttpResponse(status=404)\n            try:\n                photo = album.photos.only(\n                    \"image_hash\", \"video\", \"main_file\", \"thumbnail\"\n                ).get(image_hash=image_hash)\n            except Photo.DoesNotExist:\n                return HttpResponse(status=404)\n\n            if \"thumbnail\" in path or \"thumbnails\" in path or \"faces\" in path:\n                if use_proxy:\n                    return self._generate_response_proxy(photo, path, fname, False)\n                return self._generate_response_direct(photo, path, fname, False)\n\n            if use_proxy:\n                response = HttpResponse()\n                try:\n                    mime = magic.Magic(mime=True)\n                    filename = mime.from_file(photo.main_file.path)\n                except Exception:\n                    filename = \"application/octet-stream\"\n                response[\"Content-Type\"] = filename if photo.video else \"image/webp\"\n                if photo.main_file.path.startswith(settings.PHOTOS):\n                    internal_path = (\n                        \"/original\" + photo.main_file.path[len(settings.PHOTOS) :]\n                    )\n                else:\n                    internal_path = photo.main_file.path\n                response[\"X-Accel-Redirect\"] = iri_to_uri(internal_path)\n                return response\n            try:\n                mime = magic.Magic(mime=True)\n                content_type = mime.from_file(photo.main_file.path)\n            except Exception:\n                content_type = \"application/octet-stream\"\n            return self._serve_file_direct(\n                photo.main_file.path, content_type if photo.video else \"image/webp\"\n            )\n\n        # Non-photos (thumbnails, faces, etc.)\n        if path.lower() != \"photos\":\n            # Try UUID lookup first (for new-style requests after migration 0099),\n            # then fall back to image_hash lookup (for legacy/backward compatibility)\n            is_uuid_format = len(image_hash) == 36 and image_hash.count(\"-\") == 4\n            try:\n                if is_uuid_format:\n                    photo = Photo.objects.get(pk=image_hash)\n                else:\n                    photo = Photo.objects.get(image_hash=image_hash)\n            except Photo.DoesNotExist:\n                return HttpResponse(status=404)\n            except Photo.MultipleObjectsReturned:\n                # Multiple photos with same hash - find one that matches permissions\n                photos = Photo.objects.filter(image_hash=image_hash)\n                photo = None\n                # First try to find one in a public album\n                for p in photos:\n                    if p.albumuser_set.filter(self._public_album_active_q()).exists():\n                        photo = p\n                        break\n                # If none found, we'll check user permissions below\n                if photo is None:\n                    photo = photos.first()\n\n            if photo.albumuser_set.filter(self._public_album_active_q()).exists():\n                if use_proxy:\n                    return self._generate_response_proxy(photo, path, fname, False)\n                return self._generate_response_direct(photo, path, fname, False)\n\n            jwt = request.COOKIES.get(\"jwt\")\n            if jwt is not None:\n                try:\n                    token = AccessToken(jwt)\n                except TokenError:\n                    return HttpResponseForbidden()\n            else:\n                return HttpResponseForbidden()\n\n            user = (\n                User.objects.filter(id=token[\"user_id\"])\n                .only(\"id\", \"transcode_videos\")\n                .first()\n            )\n            if photo.owner == user or user in photo.shared_to.all():\n                if use_proxy:\n                    return self._generate_response_proxy(\n                        photo, path, fname, user.transcode_videos\n                    )\n                return self._generate_response_direct(\n                    photo, path, fname, user.transcode_videos\n                )\n            else:\n                for album in photo.albumuser_set.only(\"shared_to\", \"public\"):\n                    if getattr(album, \"public\", False) or user in album.shared_to.all():\n                        if use_proxy:\n                            return self._generate_response_proxy(\n                                photo, path, fname, user.transcode_videos\n                            )\n                        return self._generate_response_direct(\n                            photo, path, fname, user.transcode_videos\n                        )\n            return HttpResponse(status=404)\n\n        # Original photos (path == photos)\n        try:\n            photo = Photo.objects.get(image_hash=image_hash)\n        except Photo.DoesNotExist:\n            return HttpResponse(status=404)\n        except Photo.MultipleObjectsReturned:\n            # Multiple photos with same hash - find one that matches permissions\n            photos = Photo.objects.filter(image_hash=image_hash)\n            photo = None\n            # First try to find one in a public album\n            for p in photos:\n                if p.albumuser_set.filter(self._public_album_active_q()).exists():\n                    photo = p\n                    break\n            # If none found, we'll check user permissions below\n            if photo is None:\n                photo = photos.first()\n\n        if photo.albumuser_set.filter(self._public_album_active_q()).exists():\n            if use_proxy:\n                try:\n                    mime = magic.Magic(mime=True)\n                    filename = mime.from_file(photo.main_file.path)\n                except Exception:\n                    filename = \"application/octet-stream\"\n                response = HttpResponse()\n                response[\"Content-Type\"] = filename if photo.video else \"image/webp\"\n                if photo.main_file.path.startswith(\"/nextcloud_media/\"):\n                    internal_path = \"/nextcloud_original\" + photo.main_file.path[21:]\n                elif photo.main_file.path.startswith(settings.PHOTOS):\n                    internal_path = (\n                        \"/original\" + photo.main_file.path[len(settings.PHOTOS) :]\n                    )\n                else:\n                    internal_path = quote(photo.main_file.path)\n                response[\"X-Accel-Redirect\"] = iri_to_uri(internal_path)\n                return response\n            try:\n                mime = magic.Magic(mime=True)\n                content_type = mime.from_file(photo.main_file.path)\n            except Exception:\n                content_type = \"application/octet-stream\"\n            return self._serve_file_direct(photo.main_file.path, content_type)\n\n        jwt = request.COOKIES.get(\"jwt\")\n        if jwt is not None:\n            try:\n                token = AccessToken(jwt)\n            except TokenError:\n                return HttpResponseForbidden()\n        else:\n            return HttpResponseForbidden()\n\n        user = User.objects.filter(id=token[\"user_id\"]).only(\"id\").first()\n        if photo.owner == user or user in photo.shared_to.all():\n            if use_proxy:\n                response = HttpResponse()\n                try:\n                    mime = magic.Magic(mime=True)\n                    filename = mime.from_file(photo.main_file.path)\n                except Exception:\n                    filename = \"application/octet-stream\"\n                response[\"Content-Type\"] = filename if photo.video else \"image/webp\"\n                if photo.main_file.path.startswith(\"/nextcloud_media/\"):\n                    internal_path = \"/nextcloud_original\" + photo.main_file.path[21:]\n                elif photo.main_file.path.startswith(settings.PHOTOS):\n                    internal_path = (\n                        \"/original\" + photo.main_file.path[len(settings.PHOTOS) :]\n                    )\n                else:\n                    internal_path = quote(photo.main_file.path)\n                response[\"Content-Disposition\"] = 'inline; filename=\"{}\"'.format(\n                    photo.main_file.path.split(\"/\")[-1]\n                )\n                response[\"X-Accel-Redirect\"] = iri_to_uri(internal_path)\n                return response\n            return self._serve_file_direct(photo.main_file.path)\n        else:\n            for album in photo.albumuser_set.only(\"shared_to\", \"public\"):\n                if getattr(album, \"public\", False) or user in album.shared_to.all():\n                    if use_proxy:\n                        response = HttpResponse()\n                        try:\n                            mime = magic.Magic(mime=True)\n                            filename = mime.from_file(photo.main_file.path)\n                        except Exception:\n                            filename = \"application/octet-stream\"\n                        response[\"Content-Type\"] = (\n                            filename if photo.video else \"image/webp\"\n                        )\n                        if photo.main_file.path.startswith(\"/nextcloud_media/\"):\n                            internal_path = (\n                                \"/nextcloud_original\" + photo.main_file.path[21:]\n                            )\n                        elif photo.main_file.path.startswith(settings.PHOTOS):\n                            internal_path = (\n                                \"/original\"\n                                + photo.main_file.path[len(settings.PHOTOS) :]\n                            )\n                        else:\n                            internal_path = quote(photo.main_file.path)\n                        response[\"X-Accel-Redirect\"] = iri_to_uri(internal_path)\n                        return response\n                    return self._serve_file_direct(photo.main_file.path)\n        return HttpResponse(status=404)\n\n\nclass ZipListPhotosView_V2(APIView):\n    def post(self, request):\n        import shutil\n\n        free_storage = shutil.disk_usage(\"/\").free\n        data = request.data\n        image_hashes = data.get(\"image_hashes\")\n        if not image_hashes:\n            return Response(data={\"error\": \"image_hashes required\"}, status=400)\n\n        # DRF may provide list values (QueryDict) or a single string\n        if isinstance(image_hashes, str):\n            image_hashes = [image_hashes]\n        elif isinstance(image_hashes, (list, tuple)):\n            # QueryDict -> dict() would produce list values, request.data keeps list too\n            pass\n        else:\n            image_hashes = list(image_hashes)\n\n        include_stacked = data.get(\"include_stacked_photos\", False)\n        if isinstance(include_stacked, (list, tuple)):\n            include_stacked = include_stacked[0] if include_stacked else False\n        if isinstance(include_stacked, str):\n            include_stacked = include_stacked.strip().lower() in (\"1\", \"true\", \"yes\", \"on\")\n        include_stacked = bool(include_stacked)\n\n        photo_query = Photo.objects.filter(owner=self.request.user)\n        # Filter photos based on image hashes\n        photos = photo_query.filter(image_hash__in=image_hashes)\n        if not photos.exists():\n            return Response(data={\"error\": \"No photos found\"}, status=404)\n\n        # Optionally expand to include all photos from the same stacks\n        if include_stacked:\n            stack_ids = (\n                photos.exclude(stacks__isnull=True)\n                .values_list(\"stacks__id\", flat=True)\n                .distinct()\n            )\n            if stack_ids:\n                stacked_photos = photo_query.filter(stacks__id__in=stack_ids)\n                photos = (photos | stacked_photos).distinct()\n\n        # Calculate the total file size using aggregate\n        total_file_size = photos.aggregate(Sum(\"size\"))[\"size__sum\"] or 0\n        if free_storage < total_file_size:\n            return Response(data={\"status\": \"Insufficient Storage\"}, status=507)\n        file_uuid = uuid.uuid4()\n        filename = str(str(file_uuid) + str(self.request.user.id) + \".zip\")\n\n        job_id = create_download_job(\n            LongRunningJob.JOB_DOWNLOAD_PHOTOS,\n            user=self.request.user,\n            photos=list(photos),\n            filename=filename,\n        )\n        response = {\"job_id\": job_id, \"url\": file_uuid}\n\n        return Response(data=response, status=200)\n\n    def get(self, request):\n        job_id = request.GET[\"job_id\"]\n        print(job_id)\n        if job_id is None:\n            return Response(status=404)\n        try:\n            job = LongRunningJob.objects.get(job_id=job_id)\n            if job.finished:\n                return Response(data={\"status\": \"SUCCESS\"}, status=200)\n            elif job.failed:\n                return Response(\n                    data={\"status\": \"FAILURE\", \"result\": job.result}, status=500\n                )\n            else:\n                return Response(\n                    data={\"status\": \"PENDING\", \"progress\": job.result}, status=202\n                )\n        except BaseException as e:\n            logger.error(str(e))\n            return Response(status=404)\n\n\nclass DeleteZipView(APIView):\n    def delete(self, request, fname):\n        jwt = request.COOKIES.get(\"jwt\")\n        if jwt is not None:\n            try:\n                token = AccessToken(jwt)\n            except TokenError:\n                return HttpResponseForbidden()\n        else:\n            return HttpResponseForbidden()\n        filename = fname + str(token[\"user_id\"]) + \".zip\"\n        try:\n            delete_zip_file(filename)\n            return Response(status=200)\n        except BaseException as e:\n            logger.error(str(e))\n            return Response(status=404)\n"
  },
  {
    "path": "image_similarity/__init__.py",
    "content": ""
  },
  {
    "path": "image_similarity/main.py",
    "content": "import json\n\nfrom flask import Flask, jsonify, request\nfrom flask_restful import Api, Resource\nfrom gevent.pywsgi import WSGIServer\nfrom retrieval_index import RetrievalIndex\nfrom utils import logger\n\napp = Flask(__name__)\napi = Api(app)\n\nindex = RetrievalIndex()\n\n\nclass BuildIndex(Resource):\n    def post(self):\n        request_body = json.loads(request.data)\n\n        user_id = request_body[\"user_id\"]\n        image_hashes = request_body[\"image_hashes\"]\n        image_embeddings = request_body[\"image_embeddings\"]\n\n        index.build_index_for_user(user_id, image_hashes, image_embeddings)\n\n        # Return 0 if no index was created, otherwise return the actual size\n        index_size = index.indices[user_id].ntotal if user_id in index.indices else 0\n        return jsonify({\"status\": True, \"index_size\": index_size})\n\n    def delete(self):\n        user_id = json.loads(request.data)[\"user_id\"]\n        if user_id not in index.indices:\n            return jsonify({\"status\": True})\n        del index.indices[user_id]\n        del index.image_hashes[user_id]\n        return jsonify({\"status\": True})\n\n\nclass SearchIndex(Resource):\n    def post(self):\n        try:\n            request_body = json.loads(request.data)\n\n            user_id = request_body[\"user_id\"]\n            image_embedding = request_body[\"image_embedding\"]\n            if \"n\" in request_body.keys():\n                n = int(request_body[\"n\"])\n            else:\n                n = 100\n\n            if \"threshold\" in request_body.keys():\n                thres = float(request_body[\"threshold\"])\n            else:\n                thres = 27.0\n\n            res = index.search_similar(user_id, image_embedding, n, thres)\n\n            return jsonify({\"status\": True, \"result\": res})\n        except BaseException as e:\n            logger.error(str(e))\n            return jsonify({\"status\": False, \"result\": []}), 500\n\n\nclass Health(Resource):\n    def get(self):\n        return jsonify({\"status\": True})\n\n\napi.add_resource(BuildIndex, \"/build/\")\napi.add_resource(SearchIndex, \"/search/\")\napi.add_resource(Health, \"/health/\")\n\n\ndef start_server():\n    logger.info(\"Starting server\")\n    server = WSGIServer((\"0.0.0.0\", 8002), app)\n    server.serve_forever()\n\n\nif __name__ == \"__main__\":\n    start_server()\n"
  },
  {
    "path": "image_similarity/retrieval_index.py",
    "content": "import datetime\n\nimport faiss\nimport numpy as np\nfrom utils import logger\n\nembedding_size = 512\n\n\nclass RetrievalIndex:\n    def __init__(self):\n        self.indices = {}\n        self.image_hashes = {}\n\n    def build_index_for_user(self, user_id, image_hashes, image_embeddings):\n        logger.info(\n            f\"building index for user {user_id} - got {len(image_hashes)} photos to process\"\n        )\n        start = datetime.datetime.now()\n\n        # Check if we have any embeddings to process\n        if not image_embeddings or len(image_embeddings) == 0:\n            logger.warning(f\"No embeddings provided for user {user_id}\")\n            return\n\n        # Initialize or get existing index and hashes\n        if not self.indices.get(user_id):\n            self.indices[user_id] = faiss.IndexFlatIP(embedding_size)\n        if not self.image_hashes.get(user_id):\n            self.image_hashes[user_id] = []\n\n        # Convert embeddings to numpy array and ensure correct shape\n        # FAISS expects shape (n_vectors, embedding_size)\n        embeddings_array = np.array(image_embeddings, dtype=np.float32)\n\n        # Handle empty or invalid arrays\n        if embeddings_array.size == 0:\n            logger.warning(f\"Empty embeddings array for user {user_id}\")\n            return\n\n        if len(embeddings_array.shape) == 1:\n            # If we got a single vector, reshape it to (1, embedding_size)\n            embeddings_array = embeddings_array.reshape(1, -1)\n        elif len(embeddings_array.shape) == 2:\n            # If we got multiple vectors, ensure the second dimension is embedding_size\n            if embeddings_array.shape[1] != embedding_size:\n                logger.error(\n                    f\"Expected embedding size {embedding_size}, got {embeddings_array.shape[1]}\"\n                )\n                return\n        else:\n            logger.error(f\"Unexpected embedding shape: {embeddings_array.shape}\")\n            return\n\n        try:\n            self.indices[user_id].add(embeddings_array)\n            # Add hashes to the list\n            self.image_hashes[user_id].extend(image_hashes)\n        except Exception as e:\n            logger.error(\n                f\"Error adding embeddings to index for user {user_id}: {str(e)}\"\n            )\n            return\n\n        elapsed = (datetime.datetime.now() - start).total_seconds()\n        logger.info(\n            \"finished building index for user %d - took %.2f seconds\"\n            % (user_id, elapsed)\n        )\n\n    def search_similar(self, user_id, in_embedding, n=100, thres=27.0):\n        start = datetime.datetime.now()\n        dist, res_indices = self.indices[user_id].search(\n            np.array([in_embedding], dtype=np.float32), n\n        )\n        res = []\n        for distance, idx in sorted(zip(dist[0], res_indices[0]), reverse=True):\n            if distance >= thres:\n                res.append(self.image_hashes[user_id][idx])\n        elapsed = (datetime.datetime.now() - start).total_seconds()\n        logger.info(\n            \"searched for %d images for user %d - took %.2f seconds\"\n            % (n, user_id, elapsed)\n        )\n        return res\n"
  },
  {
    "path": "image_similarity/utils.py",
    "content": "import logging\nimport logging.handlers\nimport os\nimport os.path\n\nBASE_LOGS = os.environ.get(\"BASE_LOGS\", \"/logs/\")\n\nlogger = logging.getLogger(\"image_similarity\")\nformatter = logging.Formatter(\n    \"%(asctime)s : %(filename)s : %(funcName)s : %(lineno)s : %(levelname)s : %(message)s\"\n)\nfileMaxByte = 256 * 1024 * 200  # 100MB\n\nfileHandler = logging.handlers.RotatingFileHandler(\n    os.path.join(BASE_LOGS, \"image_similarity.log\"),\n    maxBytes=fileMaxByte,\n    backupCount=10,\n)\n\nfileHandler.setFormatter(formatter)\nlogger.addHandler(fileHandler)\nlogger.setLevel(logging.INFO)\n"
  },
  {
    "path": "librephotos/__init__.py",
    "content": ""
  },
  {
    "path": "librephotos/settings/__init__.py",
    "content": ""
  },
  {
    "path": "librephotos/settings/development.py",
    "content": "from .production import *  # noqa\n\nDEBUG = True\nMIDDLEWARE += [\"silk.middleware.SilkyMiddleware\"]  # noqa\nINSTALLED_APPS += [\"silk\"]  # noqa\nINSTALLED_APPS += [\"drf_spectacular\"]\nSPECTACULAR_SETTINGS = {\n    \"TITLE\": \"LibrePhotos\",\n    \"DESCRIPTION\": \"Your project description\",\n    \"VERSION\": \"1.0.0\",\n}\n"
  },
  {
    "path": "librephotos/settings/production.py",
    "content": "import datetime\nimport os\n\nBASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\nBASE_LOGS = os.environ.get(\"BASE_LOGS\", \"/logs/\")\nBASE_DATA = os.environ.get(\"BASE_DATA\", \"/\")\nPHOTOS = os.environ.get(\"PHOTOS\", os.path.join(BASE_DATA, \"data\"))\nSTATIC_URL = \"api/static/\"\nMEDIA_URL = \"/media/\"\nMEDIA_ROOT = os.path.join(BASE_DATA, \"protected_media\")\nSTATIC_ROOT = os.path.join(BASE_DIR, \"static\")\nDATA_ROOT = PHOTOS\nIM2TXT_ROOT = os.path.join(MEDIA_ROOT, \"data_models\", \"im2txt\")\n\nBLIP_ROOT = os.path.join(MEDIA_ROOT, \"data_models\", \"blip\")\nPLACES365_ROOT = os.path.join(MEDIA_ROOT, \"data_models\", \"places365\", \"model\")\nCLIP_ROOT = os.path.join(MEDIA_ROOT, \"data_models\", \"clip-embeddings\")\nLOGS_ROOT = BASE_LOGS\nDEMO_SITE = os.environ.get(\"DEMO_SITE\", \"False\") != \"False\"\n\nWSGI_APPLICATION = \"librephotos.wsgi.application\"\nAUTH_USER_MODEL = \"api.User\"\nROOT_URLCONF = \"librephotos.urls\"\nDEFAULT_AUTO_FIELD = \"django.db.models.AutoField\"\nDEBUG = False\n\nSECRET_KEY_FILENAME = os.path.join(BASE_LOGS, \"secret.key\")\nSECRET_KEY = \"\"\n\n# analyze files to detect embedded media (e.g. in motion photos)\nFEATURE_PROCESS_EMBEDDED_MEDIA = (\n    os.getenv(\"FEATURE_PROCESS_EMBEDDED_MEDIA\", \"True\") == \"True\"\n)\n\nif os.environ.get(\"SECRET_KEY\"):\n    SECRET_KEY = os.environ[\"SECRET_KEY\"]\n    print(\"use SECRET_KEY from env\")\n\nif not SECRET_KEY and os.path.exists(SECRET_KEY_FILENAME):\n    with open(SECRET_KEY_FILENAME) as f:\n        SECRET_KEY = f.read().strip()\n        print(\"use SECRET_KEY from file\")\n\nif not SECRET_KEY:\n    from django.core.management.utils import get_random_secret_key\n\n    with open(SECRET_KEY_FILENAME, \"w\") as f:\n        f.write(get_random_secret_key())\n        print(\"generate SECRET_KEY and save to file\")\n    with open(SECRET_KEY_FILENAME) as f:\n        SECRET_KEY = f.read().strip()\n        print(\"use SECRET_KEY from file\")\n\nALLOWED_HOSTS = [\"localhost\", os.environ.get(\"BACKEND_HOST\", \"backend\")]\n\nSIMPLE_JWT = {\n    \"ACCESS_TOKEN_LIFETIME\": datetime.timedelta(minutes=5),\n    \"REFRESH_TOKEN_LIFETIME\": datetime.timedelta(\n        days=int(os.environ.get(\"REFRESH_TOKEN_DAYS\", \"7\"))\n    ),\n}\n\nINSTALLED_APPS = [\n    \"django.contrib.admin\",\n    \"django.contrib.auth\",\n    \"django.contrib.contenttypes\",\n    \"django.contrib.sessions\",\n    \"django.contrib.messages\",\n    \"django.contrib.staticfiles\",\n    \"django.contrib.postgres\",\n    \"api\",\n    \"nextcloud\",\n    \"rest_framework\",\n    \"rest_framework_simplejwt.token_blacklist\",\n    \"corsheaders\",\n    \"chunked_upload\",\n    \"django_extensions\",\n    \"constance\",\n    \"constance.backends.database\",\n    \"django_q\",\n]\n\nQ_CLUSTER = {\n    \"name\": \"DjangORM\",\n    \"queue_limit\": 50,\n    \"recycle\": 50,\n    \"timeout\": 10000000,\n    \"retry\": 20000000,\n    \"orm\": \"default\",\n    \"max_rss\": 300000,\n    \"poll\": 1,\n}\n\nCONSTANCE_BACKEND = \"constance.backends.database.DatabaseBackend\"\nCONSTANCE_ADDITIONAL_FIELDS = {\n    \"map_api_provider\": [\n        \"django.forms.fields.ChoiceField\",\n        {\n            \"widget\": \"django.forms.Select\",\n            \"choices\": (\n                (\"mapbox\", \"Mapbox\"),\n                (\"maptiler\", \"MapTiler\"),\n                (\"nominatim\", \"Nominatim (OpenStreetMap)\"),\n                (\"opencage\", \"OpenCage\"),\n                (\"photon\", \"Photon\"),\n                (\"tomtom\", \"TomTom\"),\n            ),\n        },\n    ],\n    \"captioning_model\": [\n        \"django.forms.fields.ChoiceField\",\n        {\n            \"widget\": \"django.forms.Select\",\n            \"choices\": (\n                (\"none\", \"None\"),\n                (\"im2txt\", \"im2txt PyTorch Model\"),\n                (\"blip_base_capfilt_large\", \"BLIP Model\"),\n                (\"moondream\", \"Moondream Visual LLM\"),\n            ),\n        },\n    ],\n    \"llm_model\": [\n        \"django.forms.fields.ChoiceField\",\n        {\n            \"widget\": \"django.forms.Select\",\n            \"choices\": (\n                (\"none\", \"None\"),\n                (\"mistral-7b-instruct-v0.2.Q5_K_M\", \"Mistral 7B Instruct v0.2 Q5 K M\"),\n                (\"moondream\", \"Moondream Visual LLM\"),\n            ),\n        },\n    ],\n    \"tagging_model\": [\n        \"django.forms.fields.ChoiceField\",\n        {\n            \"widget\": \"django.forms.Select\",\n            \"choices\": (\n                (\"places365\", \"Places365 Scene Recognition\"),\n                (\"siglip2\", \"SigLIP 2 (Real-world photo tags)\"),\n            ),\n        },\n    ],\n}\nCONSTANCE_CONFIG = {\n    \"ALLOW_REGISTRATION\": (False, \"Publicly allow user registration\", bool),\n    \"ALLOW_UPLOAD\": (\n        os.environ.get(\"ALLOW_UPLOAD\", \"True\") not in (\"false\", \"False\", \"0\", \"f\"),\n        \"Allow uploading files\",\n        bool,\n    ),\n    \"SKIP_PATTERNS\": (\n        os.environ.get(\"SKIP_PATTERNS\", \"\"),\n        \"Comma delimited list of patterns to ignore (e.g. '@eaDir,#recycle' for synology devices)\",\n        str,\n    ),\n    \"MAP_API_PROVIDER\": (\n        os.environ.get(\"MAP_API_PROVIDER\", \"nominatim\"),\n        \"Map Provider\",\n        \"map_api_provider\",\n    ),\n    \"MAP_API_KEY\": (os.environ.get(\"MAPBOX_API_KEY\", \"\"), \"Map Box API Key\", str),\n    \"IMAGE_DIRS\": (\"/data\", \"Image dirs list (serialized json)\", str),\n    \"CAPTIONING_MODEL\": (\"im2txt\", \"Captioning model\", \"captioning_model\"),\n    \"LLM_MODEL\": (\"None\", \"Large Language Model\", \"llm_model\"),\n    \"TAGGING_MODEL\": (\"places365\", \"Tagging model\", \"tagging_model\"),\n}\n\nINTERNAL_IPS = (\"127.0.0.1\", \"localhost\")\n\nCORS_ALLOW_HEADERS = (\n    \"cache-control\",\n    \"accept\",\n    \"accept-encoding\",\n    \"allow-credentials\",\n    \"withcredentials\",\n    \"authorization\",\n    \"content-type\",\n    \"dnt\",\n    \"origin\",\n    \"user-agent\",\n    \"x-csrftoken\",\n    \"x-requested-with\",\n)\nCORS_ALLOW_ALL_ORIGINS = False\nCORS_ALLOW_CREDENTIALS = True\nCORS_ALLOWED_ORIGINS = [\"http://localhost:3000\"]\n\nREST_FRAMEWORK = {\n    \"DEFAULT_PERMISSION_CLASSES\": (\"rest_framework.permissions.IsAuthenticated\",),\n    \"DEFAULT_AUTHENTICATION_CLASSES\": (\n        \"rest_framework_simplejwt.authentication.JWTAuthentication\",\n        \"rest_framework.authentication.BasicAuthentication\",\n    ),\n    \"DEFAULT_SCHEMA_CLASS\": \"drf_spectacular.openapi.AutoSchema\",\n    \"DEFAULT_FILTER_BACKENDS\": (\"django_filters.rest_framework.DjangoFilterBackend\",),\n    \"DEFAULT_PAGINATION_CLASS\": \"rest_framework.pagination.LimitOffsetPagination\",\n    \"EXCEPTION_HANDLER\": \"api.views.views.custom_exception_handler\",\n    \"PAGE_SIZE\": 20000,\n}\n\nMIDDLEWARE = [\n    \"django.middleware.security.SecurityMiddleware\",\n    \"django.contrib.sessions.middleware.SessionMiddleware\",\n    \"corsheaders.middleware.CorsMiddleware\",\n    \"django.middleware.common.CommonMiddleware\",\n    \"django.middleware.csrf.CsrfViewMiddleware\",\n    \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n    \"django.contrib.messages.middleware.MessageMiddleware\",\n    \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n    \"api.middleware.FingerPrintMiddleware\",\n]\n\nTEMPLATES = [\n    {\n        \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n        \"DIRS\": [],\n        \"APP_DIRS\": True,\n        \"OPTIONS\": {\n            \"context_processors\": [\n                \"django.template.context_processors.debug\",\n                \"django.template.context_processors.request\",\n                \"django.contrib.auth.context_processors.auth\",\n                \"django.contrib.messages.context_processors.messages\",\n            ],\n        },\n    },\n]\n\nDATABASES = {\n    \"default\": {\n        \"ENGINE\": \"django.db.backends.postgresql\",\n        \"NAME\": os.environ.get(\"DB_NAME\", \"db\"),\n        \"USER\": os.environ.get(\"DB_USER\", \"docker\"),\n        \"PASSWORD\": os.environ.get(\"DB_PASS\", \"AaAa1234\"),\n        \"HOST\": os.environ.get(\"DB_HOST\", \"db\"),\n        \"PORT\": os.environ.get(\"DB_PORT\", \"5432\"),\n        # Using persistent connections instead of pooling due to Django 5.2 pooling bugs\n        # (type conversion issues with COUNT queries - see error with UUID returned as string)\n        \"CONN_MAX_AGE\": 600,\n        \"CONN_HEALTH_CHECKS\": True,\n    },\n}\n\nAUTH_PASSWORD_VALIDATORS = [\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n    },\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n    },\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n    },\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n    },\n]\n\n# Password Hashers - Argon2 is faster and more secure than PBKDF2\n# Existing passwords will continue to work (PBKDF2 fallback)\n# New passwords and password changes will use Argon2\nPASSWORD_HASHERS = [\n    \"django.contrib.auth.hashers.Argon2PasswordHasher\",\n    \"django.contrib.auth.hashers.PBKDF2PasswordHasher\",\n    \"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher\",\n]\n\nLANGUAGE_CODE = \"en-us\"\nTIME_ZONE = \"UTC\"\nUSE_I18N = True\nUSE_L10N = True\nUSE_TZ = True\n\nCSRF_TRUSTED_ORIGINS = [\n    \"http://localhost:3000\",\n]\nif os.environ.get(\"CSRF_TRUSTED_ORIGINS\"):\n    CSRF_TRUSTED_ORIGINS.append(os.environ.get(\"CSRF_TRUSTED_ORIGINS\"))\n\nLOGGING = {\n    \"version\": 1,\n    \"disable_existing_loggers\": False,\n    \"handlers\": {\n        \"console\": {\n            \"class\": \"logging.StreamHandler\",\n        },\n    },\n    \"loggers\": {\n        \"django\": {\n            \"handlers\": [\"console\"],\n            \"level\": \"INFO\",\n        },\n    },\n}\n\nCHUNKED_UPLOAD_PATH = \"\"\nCHUNKED_UPLOAD_TO = os.path.join(\"chunked_uploads\")\n\nDEFAULT_FAVORITE_MIN_RATING = os.environ.get(\"DEFAULT_FAVORITE_MIN_RATING\", 4)\nIMAGE_SIMILARITY_SERVER = \"http://localhost:8002\"\n"
  },
  {
    "path": "librephotos/settings/test.py",
    "content": "from .production import *  # noqa\n\nDEBUG = True\nLOGGING = {\n    \"version\": 1,\n    \"disable_existing_loggers\": True,\n    \"handlers\": {\n        \"null\": {\n            \"class\": \"logging.NullHandler\",\n        },\n    },\n    \"root\": {\n        \"handlers\": [\"null\"],\n        \"level\": \"CRITICAL\",\n    },\n}\n"
  },
  {
    "path": "librephotos/settings/test_sqlite.py",
    "content": "\"\"\"\nTest settings that use SQLite instead of PostgreSQL.\n\nUsage:\n    DJANGO_SETTINGS_MODULE=librephotos.settings.test_sqlite python manage.py test api.tests.test_migration_0099\n\"\"\"\n\nfrom .test import *  # noqa\n\nDATABASES = {\n    \"default\": {\n        \"ENGINE\": \"django.db.backends.sqlite3\",\n        \"NAME\": \":memory:\",\n    },\n}\n"
  },
  {
    "path": "librephotos/urls.py",
    "content": "\"\"\"librephotos URL Configuration\n\nThe `urlpatterns` list routes URLs to views. For more information please see:\n    https://docs.djangoproject.com/en/1.11/topics/http/urls/\n\nExamples:\nFunction views\n    1. Add an import:  from my_app import views\n    2. Add a URL to urlpatterns:  re_path(r'^$', views.home, name='home')\nClass-based views\n    1. Add an import:  from other_app.views import Home\n    2. Add a URL to urlpatterns:  re_path(r'^$', Home.as_view(), name='home')\nIncluding another URLconf\n    1. Import the include() function: from django.conf.urls import url, include\n    2. Add a URL to urlpatterns:  re_path(r'^blog/', include('blog.urls'))\n\n\"\"\"\n\nfrom django.conf import settings\nfrom django.conf.urls.static import static\nfrom django.contrib import admin\nfrom django.urls import include, re_path\nfrom django.http import HttpResponse\nfrom django.views.generic import TemplateView\nfrom rest_framework import routers\nfrom rest_framework_simplejwt.serializers import (\n    TokenObtainPairSerializer,\n    TokenRefreshSerializer,\n)\nfrom rest_framework_simplejwt.views import (\n    TokenBlacklistView,\n    TokenObtainPairView,\n    TokenRefreshView,\n)\n\nfrom api.views import (\n    album_auto,\n    album_folder,\n    albums,\n    dataviz,\n    duplicates,\n    faces,\n    geocode,\n    jobs,\n    photo_metadata,\n    photos,\n    public_albums,\n    search,\n    services,\n    sharing,\n    stacks,\n    timezone,\n    upload,\n    user,\n    views,\n)\nfrom nextcloud import views as nextcloud_views\nimport os\n\n\nclass CustomTokenObtainPairSerializer(TokenObtainPairSerializer):\n    @classmethod\n    def get_token(cls, user):\n        token = super(TokenObtainPairSerializer, cls).get_token(user)\n\n        token[\"name\"] = user.get_username()\n        token[\"is_admin\"] = user.is_superuser\n        token[\"first_name\"] = user.first_name\n        token[\"last_name\"] = user.last_name\n        token[\"scan_directory\"] = user.scan_directory\n        token[\"confidence\"] = user.confidence\n        token[\"semantic_search_topk\"] = user.semantic_search_topk\n        token[\"nextcloud_server_address\"] = user.nextcloud_server_address\n        token[\"nextcloud_username\"] = user.nextcloud_username\n\n        return token\n\n\nclass CustomTokenObtainPairView(TokenObtainPairView):\n    serializer_class = CustomTokenObtainPairSerializer\n\n    def post(self, request, *args, **kwargs):\n        response = super().post(request, *args, **kwargs)\n        response.set_cookie(\"jwt\", response.data[\"access\"])\n        response[\"Access-Control-Allow-Credentials\"] = \"true\"\n        return response\n\n\nclass CustomTokenRefreshView(TokenRefreshView):\n    serializer_class = TokenRefreshSerializer\n\n    def post(self, request, *args, **kwargs):\n        response = super().post(request, *args, **kwargs)\n        response.set_cookie(\"jwt\", response.data[\"access\"])\n        response[\"Access-Control-Allow-Credentials\"] = \"true\"\n        return response\n\n\nclass FrontendView(TemplateView):\n    \"\"\"\n    Serves the frontend index.html for all non-API routes (no-proxy compatibility)\n    \"\"\"\n\n    def get(self, request, *args, **kwargs):\n        try:\n            # The frontend_build is at /code/frontend_build, but BASE_DIR is /code/librephotos\n            # So we need to go up one level from BASE_DIR\n            frontend_path = os.path.join(\n                os.path.dirname(settings.BASE_DIR), \"frontend_build\", \"index.html\"\n            )\n            with open(frontend_path) as f:\n                return HttpResponse(f.read(), content_type=\"text/html\")\n        except FileNotFoundError:\n            return HttpResponse(\n                \"Frontend build not found. Please build the frontend first.\", status=500\n            )\n\n\nrouter = routers.DefaultRouter()\n# In SERVE_FRONTEND mode, disable DRF's API root at '/'\n# so the frontend catch-all can serve the SPA instead of the browsable API.\nrouter.include_root_view = not getattr(settings, \"SERVE_FRONTEND\", False)\n\nrouter.register(r\"api/user\", user.UserViewSet, basename=\"user\")\nrouter.register(r\"api/manage/user\", user.ManageUserViewSet, basename=\"manage_user\")\nrouter.register(r\"api/delete/user\", user.DeleteUserViewSet, basename=\"delete_user\")\n\nrouter.register(\n    r\"api/albums/auto/list\", album_auto.AlbumAutoListViewSet, basename=\"album_auto_list\"\n)\nrouter.register(\n    r\"api/albums/date/list\", albums.AlbumDateListViewSet, basename=\"album_date_list\"\n)\nrouter.register(\n    r\"api/albums/thing/list\", albums.AlbumThingListViewSet, basename=\"album_thing_list\"\n)\nrouter.register(\n    r\"api/albums/place/list\", albums.AlbumPlaceListViewSet, basename=\"album_place_list\"\n)\nrouter.register(\n    r\"api/albums/user/list\", albums.AlbumUserListViewSet, basename=\"album_user_list\"\n)\n\nrouter.register(\n    r\"api/albums/user/edit\", views.AlbumUserEditViewSet, basename=\"edit_album_user\"\n)\n\nrouter.register(\n    r\"api/albums/user/shared/tome\",\n    sharing.SharedToMeAlbumUserListViewSet,\n    basename=\"share_to_me_album_user\",\n)\nrouter.register(\n    r\"api/albums/user/shared/fromme\",\n    sharing.SharedFromMeAlbumUserListViewSet,\n    basename=\"share_from_me_album_user\",\n)\n\nrouter.register(r\"api/albums/auto\", album_auto.AlbumAutoViewSet, basename=\"album_auto\")\nrouter.register(\n    r\"api/folders\", album_folder.FolderNavigationViewSet, basename=\"folder_navigation\"\n)\nrouter.register(\n    r\"api/albums/person\", albums.AlbumPersonViewSet, basename=\"album_person\"\n)\nrouter.register(r\"api/albums/date\", albums.AlbumDateViewSet, basename=\"album_date\")\nrouter.register(r\"api/albums/thing\", albums.AlbumThingViewSet, basename=\"album_thing\")\nrouter.register(r\"api/albums/place\", albums.AlbumPlaceViewSet, basename=\"album_place\")\nrouter.register(r\"api/albums/user\", albums.AlbumUserViewSet, basename=\"album_user\")\n\nrouter.register(r\"api/persons\", albums.PersonViewSet, basename=\"persons\")\n\nrouter.register(\n    r\"api/photos/shared/tome\",\n    sharing.SharedToMePhotoSuperSimpleListViewSet,\n    basename=\"shared_to_me_photo\",\n)\nrouter.register(\n    r\"api/photos/shared/fromme\",\n    sharing.SharedFromMePhotoSuperSimpleListViewSet,\n    basename=\"shared_from_me_photo\",\n)\n\nrouter.register(\n    r\"api/photos/notimestamp\",\n    photos.NoTimestampPhotoViewSet,\n    basename=\"photos_no_timestamp\",\n)\n\nrouter.register(r\"api/photos/edit\", photos.PhotoEditViewSet, basename=\"photo_edit\")\n\nrouter.register(\n    r\"api/photos/recentlyadded\",\n    photos.RecentlyAddedPhotoListViewSet,\n    basename=\"recently_added_photo\",\n)\nrouter.register(\n    r\"api/photos/searchlist\", search.SearchListViewSet, basename=\"photo_search\"\n)\n\nrouter.register(r\"api/photos\", photos.PhotoViewSet, basename=\"photos\")\n\nrouter.register(\n    r\"api/faces/incomplete\",\n    faces.FaceIncompleteListViewSet,\n    basename=\"incomplete_faces\",\n)\n\nrouter.register(r\"api/faces\", faces.FaceListView, basename=\"faces\")\n\nrouter.register(r\"api/exists\", upload.UploadPhotoExists, basename=\"photo_exists\")\nrouter.register(r\"api/jobs\", jobs.LongRunningJobViewSet, basename=\"jobs\")\nrouter.register(r\"api/services\", services.ServiceViewSet, basename=\"service\")\n\nurlpatterns = [\n    re_path(r\"^\", include(router.urls)),\n    re_path(r\"^api/django-admin/\", admin.site.urls),\n    re_path(r\"^api/sitesettings\", views.SiteSettingsView.as_view()),\n    re_path(r\"^api/firsttimesetup\", user.IsFirstTimeSetupView.as_view()),\n    re_path(r\"^api/dirtree\", user.RootPathTreeView.as_view()),\n    re_path(r\"^api/labelfaces\", faces.SetFacePersonLabel.as_view()),\n    re_path(r\"^api/deletefaces\", faces.DeleteFaces.as_view()),\n    re_path(r\"^api/savemetadata\", photos.SaveMetadataView.as_view()),\n    re_path(r\"^api/photosedit/delete\", photos.DeletePhotos.as_view()),\n    re_path(r\"^api/photosedit/setdeleted\", photos.SetPhotosDeleted.as_view()),\n    re_path(r\"^api/photosedit/favorite\", photos.SetPhotosFavorite.as_view()),\n    re_path(r\"^api/photosedit/hide\", photos.SetPhotosHidden.as_view()),\n    re_path(r\"^api/photosedit/makepublic\", photos.SetPhotosPublic.as_view()),\n    re_path(r\"^api/photosedit/share\", photos.SetPhotosShared.as_view()),\n    re_path(r\"^api/photosedit/generateim2txt\", photos.GeneratePhotoCaption.as_view()),\n    re_path(r\"^api/photosedit/savecaption\", photos.SavePhotoCaption.as_view()),\n    re_path(r\"^api/useralbum/share\", views.SetUserAlbumShared.as_view()),\n    re_path(r\"^api/trainfaces\", faces.TrainFaceView.as_view()),\n    re_path(r\"^api/clusterfaces\", dataviz.ClusterFaceView.as_view()),\n    re_path(r\"^api/socialgraph\", dataviz.SocialGraphView.as_view()),\n    re_path(r\"^api/scanphotos\", views.ScanPhotosView.as_view()),\n    re_path(r\"^api/scanuploadedphotos\", views.FullScanPhotosView.as_view()),\n    re_path(r\"^api/fullscanphotos\", views.FullScanPhotosView.as_view()),\n    re_path(r\"^api/scanfaces\", faces.ScanFacesView.as_view()),\n    re_path(r\"^api/deletemissingphotos\", views.DeleteMissingPhotosView.as_view()),\n    re_path(r\"^api/autoalbumgen\", album_auto.AutoAlbumGenerateView.as_view()),\n    re_path(r\"^api/autoalbumtitlegen\", album_auto.RegenerateAutoAlbumTitles.as_view()),\n    # Photo Stacks - Organizational grouping (bursts, brackets, manual)\n    # NOTE: RAW+JPEG pairs and Live Photos now use Photo.files (file variants model)\n    re_path(r\"^api/stacks/detect/?$\", stacks.DetectStacksView.as_view()),\n    re_path(r\"^api/stacks/stats/?$\", stacks.PhotoStackStatsView.as_view()),\n    re_path(r\"^api/stacks/manual/?$\", stacks.CreateManualStackView.as_view()),\n    re_path(r\"^api/stacks/merge/?$\", stacks.MergeStacksView.as_view()),\n    re_path(\n        r\"^api/stacks/(?P<stack_id>[0-9a-f-]+)/add/?$\",\n        stacks.AddToStackView.as_view(),\n    ),\n    re_path(\n        r\"^api/stacks/(?P<stack_id>[0-9a-f-]+)/remove/?$\",\n        stacks.RemoveFromStackView.as_view(),\n    ),\n    re_path(\n        r\"^api/stacks/(?P<stack_id>[0-9a-f-]+)/delete/?$\",\n        stacks.PhotoStackDeleteView.as_view(),\n    ),\n    re_path(\n        r\"^api/stacks/(?P<stack_id>[0-9a-f-]+)/primary/?$\",\n        stacks.PhotoStackSetPrimaryView.as_view(),\n    ),\n    re_path(\n        r\"^api/stacks/(?P<stack_id>[0-9a-f-]+)/?$\",\n        stacks.PhotoStackDetailView.as_view(),\n    ),\n    re_path(r\"^api/stacks/?$\", stacks.PhotoStackListView.as_view()),\n    # Duplicates - Storage cleanup (exact copies, visual duplicates)\n    re_path(r\"^api/duplicates/detect\", duplicates.DetectDuplicatesView.as_view()),\n    re_path(r\"^api/duplicates/stats\", duplicates.DuplicateStatsView.as_view()),\n    re_path(\n        r\"^api/duplicates/(?P<duplicate_id>[0-9a-f-]+)/resolve\",\n        duplicates.DuplicateResolveView.as_view(),\n    ),\n    re_path(\n        r\"^api/duplicates/(?P<duplicate_id>[0-9a-f-]+)/dismiss\",\n        duplicates.DuplicateDismissView.as_view(),\n    ),\n    re_path(\n        r\"^api/duplicates/(?P<duplicate_id>[0-9a-f-]+)/revert\",\n        duplicates.DuplicateRevertView.as_view(),\n    ),\n    re_path(\n        r\"^api/duplicates/(?P<duplicate_id>[0-9a-f-]+)/delete\",\n        duplicates.DuplicateDeleteView.as_view(),\n    ),\n    re_path(\n        r\"^api/duplicates/(?P<duplicate_id>[0-9a-f-]+)\",\n        duplicates.DuplicateDetailView.as_view(),\n    ),\n    re_path(r\"^api/duplicates\", duplicates.DuplicateListView.as_view()),\n    # Photo Metadata - Structured metadata with edit history\n    re_path(\n        r\"^api/photos/(?P<photo_id>[0-9a-f-]+|[a-f0-9]{64})/metadata/history\",\n        photo_metadata.PhotoMetadataViewSet.as_view({\"get\": \"history\"}),\n    ),\n    re_path(\n        r\"^api/photos/(?P<photo_id>[0-9a-f-]+|[a-f0-9]{64})/metadata/revert-all\",\n        photo_metadata.PhotoMetadataViewSet.as_view({\"post\": \"revert_all\"}),\n    ),\n    re_path(\n        r\"^api/photos/(?P<photo_id>[0-9a-f-]+|[a-f0-9]{64})/metadata/revert/(?P<edit_id>[0-9a-f-]+)\",\n        photo_metadata.PhotoMetadataViewSet.as_view({\"post\": \"revert\"}),\n    ),\n    re_path(\n        r\"^api/photos/(?P<photo_id>[0-9a-f-]+|[a-f0-9]{64})/metadata\",\n        photo_metadata.PhotoMetadataViewSet.as_view({\"get\": \"retrieve\", \"patch\": \"partial_update\"}),\n    ),\n    re_path(r\"^api/photos/metadata/bulk\", photo_metadata.BulkMetadataView.as_view()),\n    # File Variants - Download specific file variants (RAW, JPEG, video, etc.)\n    re_path(\n        r\"^api/photos/(?P<image_hash>[a-f0-9]+)/file/(?P<file_hash>[a-f0-9]+)$\",\n        photos.FileVariantDownloadView.as_view(),\n        name=\"file_variant_download\",\n    ),\n    # Set main file for a photo (switch between variants)\n    re_path(\n        r\"^api/photos/(?P<image_hash>[a-f0-9]+)/main-file$\",\n        photos.SetMainFileView.as_view(),\n        name=\"set_main_file\",\n    ),\n    re_path(r\"^api/searchtermexamples\", views.SearchTermExamples.as_view()),\n    re_path(r\"^api/locationsunburst\", dataviz.LocationSunburst.as_view()),\n    re_path(r\"^api/locationtimeline\", dataviz.LocationTimeline.as_view()),\n    re_path(r\"^api/defaultrules\", user.DefaultRulesView.as_view()),\n    re_path(r\"^api/predefinedrules\", user.PredefinedRulesView.as_view()),\n    re_path(r\"^api/defaultburstrules\", user.DefaultBurstRulesView.as_view()),\n    re_path(r\"^api/predefinedburstrules\", user.PredefinedBurstRulesView.as_view()),\n    re_path(r\"^api/stats\", dataviz.StatsView.as_view()),\n    re_path(r\"^api/storagestats\", views.StorageStatsView.as_view()),\n    re_path(r\"^api/imagetag\", views.ImageTagView.as_view()),\n    re_path(r\"^api/serverstats\", dataviz.ServerStatsView.as_view()),\n    re_path(r\"^api/serverlogs\", dataviz.ServerLogsView.as_view()),\n    re_path(r\"^api/locclust\", dataviz.LocationClustersView.as_view()),\n    re_path(r\"^api/photomonthcounts\", dataviz.PhotoMonthCountsView.as_view()),\n    re_path(r\"^api/wordcloud\", dataviz.SearchTermWordCloudView.as_view()),\n    re_path(r\"^api/auth/token/obtain/$\", CustomTokenObtainPairView.as_view()),\n    re_path(r\"^api/auth/token/refresh/$\", CustomTokenRefreshView.as_view()),\n    re_path(r\"^api/auth/token/blacklist/\", TokenBlacklistView.as_view()),\n    re_path(\n        r\"^media/(?P<path>.*)/(?P<fname>.*)\",\n        views.UnifiedMediaAccessView.as_view(),\n        name=\"media\",\n    ),\n    re_path(\n        r\"^api/delete/zip/(?P<fname>.*)\",\n        views.DeleteZipView.as_view(),\n        name=\"delete-zip\",\n    ),\n    re_path(r\"^api/rqavailable/$\", jobs.QueueAvailabilityView.as_view()),\n    re_path(r\"^api/nextcloud/listdir\", nextcloud_views.ListDir.as_view()),\n    re_path(r\"^api/nextcloud/scanphotos\", nextcloud_views.ScanPhotosView.as_view()),\n    re_path(r\"^api/photos/download$\", views.ZipListPhotosView_V2.as_view()),\n    # Public album by slug\n    re_path(\n        r\"^api/public/albums/s/(?P<slug>[^/]+)/$\",\n        public_albums.PublicAlbumBySlug.as_view(),\n    ),\n    # Public photo detail in album\n    re_path(\n        r\"^api/public/albums/s/(?P<slug>[^/]+)/photos/(?P<photo_id>[^/]+)/$\",\n        public_albums.PublicPhotoDetailBySlug.as_view(),\n    ),\n    # Toggle album public flag\n    re_path(r\"^api/useralbum/makepublic\", public_albums.SetUserAlbumPublic.as_view()),\n    re_path(r\"^api/timezones\", timezone.TimeZoneView.as_view()),\n    re_path(r\"^api/geocode/search\", geocode.GeocodeSearchView.as_view()),\n    re_path(r\"api/upload/complete/\", upload.UploadPhotosChunkedComplete.as_view()),\n    re_path(r\"api/upload/\", upload.UploadPhotosChunked.as_view()),\n]\nurlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)\n\nif settings.DEBUG:\n    from drf_spectacular.views import (\n        SpectacularAPIView,\n        SpectacularRedocView,\n        SpectacularSwaggerView,\n    )\n\n    urlpatterns += [re_path(r\"^api/silk/\", include(\"silk.urls\", namespace=\"silk\"))]\n    urlpatterns += [\n        re_path(r\"^api/schema\", SpectacularAPIView.as_view(), name=\"schema\"),\n        re_path(r\"^api/swagger\", SpectacularSwaggerView.as_view(), name=\"swagger-ui\"),\n        re_path(r\"^api/redoc\", SpectacularRedocView.as_view(), name=\"redoc\"),\n        re_path(r\"^api/help$\", views.ApiHelpView.as_view()),\n    ]\n\n# Configure media and frontend serving for no-proxy (SERVE_FRONTEND) mode\nif getattr(settings, \"SERVE_FRONTEND\", False):\n    # Serve frontend assets and manifest directly\n    from django.views.static import serve as static_serve  # type: ignore\n\n    frontend_build_path = os.path.join(\n        os.path.dirname(settings.BASE_DIR), \"frontend_build\"\n    )\n    urlpatterns += [\n        re_path(\n            r\"^assets/(?P<path>.*)$\",\n            static_serve,\n            {\"document_root\": os.path.join(frontend_build_path, \"assets\")},\n        ),\n        re_path(\n            r\"^(?P<path>manifest\\.json)$\",\n            static_serve,\n            {\"document_root\": frontend_build_path},\n        ),\n        re_path(\n            r\"^(?P<path>favicon\\.ico)$\",\n            static_serve,\n            {\"document_root\": frontend_build_path},\n        ),\n        re_path(\n            r\"^(?P<path>logo-white\\.png)$\",\n            static_serve,\n            {\"document_root\": frontend_build_path},\n        ),\n        re_path(\n            r\"^(?P<path>logo\\.png)$\",\n            static_serve,\n            {\"document_root\": frontend_build_path},\n        ),\n        re_path(\n            r\"^(?P<path>unknown_user\\.jpg)$\",\n            static_serve,\n            {\"document_root\": frontend_build_path},\n        ),\n        re_path(\n            r\"^(?P<path>.*\\.(?:png|jpg|jpeg|gif|ico|svg))$\",\n            static_serve,\n            {\"document_root\": frontend_build_path},\n        ),\n    ]\n\n    # Catch-all pattern for frontend routes (must be last)\n    urlpatterns += [\n        re_path(\n            r\"^(?!api/)(?!media/)(?!static/)(?!assets/)(?!manifest\\.json).*$\",\n            FrontendView.as_view(),\n            name=\"frontend\",\n        ),\n    ]\n"
  },
  {
    "path": "librephotos/wsgi.py",
    "content": "import os\n\nfrom django.core.wsgi import get_wsgi_application\n\nenvironment = \"production\"\nif os.environ.get(\"DEBUG\", \"0\") == \"1\":\n    environment = \"development\"\n\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", f\"librephotos.settings.{environment}\")\n\napplication = get_wsgi_application()\n"
  },
  {
    "path": "manage.py",
    "content": "#!/usr/bin/env python\nimport os\nimport sys\n\nif __name__ == \"__main__\":\n    environment = \"production\"\n    if os.environ.get(\"DEBUG\", \"0\") == \"1\":\n        environment = \"development\"\n\n    try:\n        command = sys.argv[1]\n    except IndexError:\n        command = \"help\"\n\n    do_not_collect_coverage = os.environ.get(\"NO_COVERAGE\") is not None\n    running_tests = command == \"test\"\n    if running_tests:\n        environment = \"test\"\n    if running_tests and not do_not_collect_coverage:\n        from coverage import Coverage\n\n        cov = Coverage()\n        cov.erase()\n        cov.start()\n\n    from django.core.management import execute_from_command_line\n\n    os.environ.setdefault(\n        \"DJANGO_SETTINGS_MODULE\", f\"librephotos.settings.{environment}\"\n    )\n    execute_from_command_line(sys.argv)\n\n    if running_tests and not do_not_collect_coverage:\n        cov.stop()\n        cov.save()\n        cov.html_report()\n        covered = cov.report()\n"
  },
  {
    "path": "nextcloud/__init__.py",
    "content": ""
  },
  {
    "path": "nextcloud/admin.py",
    "content": ""
  },
  {
    "path": "nextcloud/apps.py",
    "content": "from django.apps import AppConfig\n\n\nclass NextcloudConfig(AppConfig):\n    name = \"nextcloud\"\n"
  },
  {
    "path": "nextcloud/directory_watcher.py",
    "content": "import os\nimport pathlib\n\nimport owncloud as nextcloud\nfrom django.conf import settings\n\nfrom api import util\nfrom api.directory_watcher import handle_new_image\nfrom api.image_similarity import build_image_similarity_index\nfrom api.models import LongRunningJob\n\n\ndef isValidNCMedia(file_obj):\n    file_attr = file_obj.attributes\n    filetype = file_attr.get(\"{DAV:}getcontenttype\", \"\")\n    try:\n        return (\n            \"jpeg\" in filetype\n            or \"png\" in filetype\n            or \"bmp\" in filetype\n            or \"gif\" in filetype\n            or \"heic\" in filetype\n            or \"heif\" in filetype\n        )\n    except Exception:\n        util.logger.exception(\"An image thrown an exception\")\n        return False\n\n\ndef collect_photos(nc, path, photos):\n    for x in nc.list(path):\n        if not x.is_dir() and isValidNCMedia(x):\n            photos.append(x.path)\n        elif x.is_dir():\n            collect_photos(nc, x.path, photos)\n\n\ndef scan_photos(user, job_id):\n    lrj = LongRunningJob.get_or_create_job(\n        user=user,\n        job_type=LongRunningJob.JOB_SCAN_PHOTOS,\n        job_id=job_id,\n    )\n\n    nc = nextcloud.Client(user.nextcloud_server_address)\n    nc.login(user.nextcloud_username, user.nextcloud_app_password)\n\n    photos = []\n\n    paths = []\n\n    collect_photos(nc, user.nextcloud_scan_directory, photos)\n\n    for photo in photos:\n        local_dir = os.path.join(\n            settings.DATA_ROOT,\n            \"nextcloud_media\",\n            user.username,\n            os.path.dirname(photo)[1:],\n        )\n        local_path = os.path.join(\n            settings.DATA_ROOT, \"nextcloud_media\", user.username, photo[1:]\n        )\n        paths.append(local_path)\n\n        if not os.path.exists(local_dir):\n            pathlib.Path(local_dir).mkdir(parents=True, exist_ok=True)\n\n        if not os.path.exists(local_path):\n            nc.get_file(photo, local_path)\n        util.logger.info(\"Downloaded photo from nextcloud to \" + local_path)\n\n    try:\n        paths.sort()\n\n        added_photo_count = 0\n        to_add_count = len(paths)\n        for idx, image_path in enumerate(paths):\n            util.logger.info(\"begin handling of photo %d/%d\" % (idx + 1, to_add_count))\n            handle_new_image(user, image_path, job_id)\n            lrj.update_progress(current=idx + 1, target=to_add_count)\n\n        util.logger.info(f\"Added {len(paths)} photos\")\n        build_image_similarity_index(user)\n\n        lrj.complete()\n    except Exception as e:\n        util.logger.exception(str(e))\n        lrj.fail(error=e)\n    return {\"new_photo_count\": added_photo_count, \"status\": True}\n"
  },
  {
    "path": "nextcloud/models.py",
    "content": ""
  },
  {
    "path": "nextcloud/tests.py",
    "content": "# from django.test import TestCase\n\n# Create your tests here.\n"
  },
  {
    "path": "nextcloud/views.py",
    "content": "import uuid\nfrom urllib.parse import urlparse\n\nimport owncloud as nextcloud\nfrom django_q.tasks import AsyncTask\nfrom drf_spectacular.utils import extend_schema\nfrom rest_framework.response import Response\nfrom rest_framework.views import APIView\n\nfrom api.util import logger\nfrom nextcloud.directory_watcher import scan_photos\n\n\nclass ListDir(APIView):\n    def get(self, request, format=None):\n        if not request.query_params.get(\"fpath\"):\n            return Response([])\n        path = request.query_params[\"fpath\"]\n\n        if not request.user.nextcloud_server_address or not valid_url(\n            request.user.nextcloud_server_address\n        ):\n            return Response([])\n\n        nc = nextcloud.Client(request.user.nextcloud_server_address)\n        nc.login(request.user.nextcloud_username, request.user.nextcloud_app_password)\n        try:\n            return Response(\n                [\n                    {\n                        \"absolute_path\": p.path,\n                        \"title\": p.path.split(\"/\")[-2],\n                        \"children\": [],\n                    }\n                    for p in nc.list(path)\n                    if p.is_dir()\n                ]\n            )\n        except nextcloud.HTTPResponseError:\n            return Response(status=400)\n\n\ndef valid_url(url):\n    try:\n        urlparse(url)\n        return True\n    except BaseException:\n        return False\n\n\nclass ScanPhotosView(APIView):\n    def post(self, request, format=None):\n        return self._scan_photos(request)\n\n    @extend_schema(\n        deprecated=True,\n        description=\"Use POST method instead\",\n    )\n    def get(self, request, format=None):\n        return self._scan_photos(request)\n\n    def _scan_photos(self, request):\n        try:\n            job_id = uuid.uuid4()\n            AsyncTask(scan_photos, request.user, job_id).run()\n            return Response({\"status\": True, \"job_id\": job_id})\n        except BaseException:\n            logger.exception(\"An Error occurred\")\n            return Response({\"status\": False})\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.ruff]\nline-length = 88\ntarget-version = \"py311\"\nextend-exclude = [\"migrations\", \"im2txt\", \"blip\", \"places365\"]\n\n[lint]\nselect = [\n  \"E\",  # Pyflakes (errors)\n  \"F\",  # Pyflakes\n  \"W\",  # pycodestyle warnings\n  \"I\",  # isort (import sorting)\n  \"N\",  # PEP8 naming\n  \"UP\", # Pyupgrade (modern syntax)\n  \"DJ\", # pylint-django equivalent\n  \"PL\", # pylint rules\n]\nignore = [\"E501\", \"E203\", \"E231\"]\n\n[tool.ruff.format]\nquote-style = \"double\"\nindent-style = \"space\"\nline-ending = \"lf\"\n"
  },
  {
    "path": "renovate.json",
    "content": "{\n  \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n  \"packageRules\": [\n    {\n      \"matchUpdateTypes\": [\"minor\", \"patch\", \"pin\", \"digest\"],\n      \"automerge\": true\n    }\n  ],\n  \"extends\": [\n    \"config:base\"\n  ]\n}\n"
  },
  {
    "path": "requirements.dev.txt",
    "content": "ipdb==0.13.13\nipython==9.6.0\nipython-genutils==0.2.0\nPygments==2.19.2\nprompt-toolkit==3.0.52\nnose==1.3.7\npre-commit==4.3.0\ncoverage==7.11.0\nFaker==37.12.0\nsetuptools==80.9.0\npyfakefs==5.9.3\npytest==8.4.2\nruff==0.14.8\n"
  },
  {
    "path": "requirements.mlval.txt",
    "content": "pycocotools==2.0.10\npycocoevalcap==1.2\nnltk==3.9.2\nmatplotlib==3.10.7\nonnx==1.19.0\nonnxscript"
  },
  {
    "path": "requirements.txt",
    "content": "Django==5.2.11\ndjango-constance==4.3.4\ndjango-cors-headers==4.9.0\ngit+https://github.com/derneuere/django-chunked-upload@master#egg=django-chunked-upload\ndjango-cryptography-5==2.0.3\ndjango-extensions==4.1\ndjango-filter==25.2\ndjango-bulk-update\ndjango-silk==5.4.3\ndjangorestframework==3.16.1\ndjangorestframework-simplejwt==5.5.1\ndrf-spectacular==0.29.0\nface-recognition==1.3.0\nfaiss-cpu==1.12.0\nFlask==3.1.2\nFlask-Cors==6.0.1\nFlask-RESTful==0.3.10\ngeopy==2.4.1\ngunicorn==23.0.0\nhdbscan==0.8.40\nnetworkx==3.4.2\nnltk==3.9.2\nmarkupsafe==3.0.3\nPillow==11.3.0\nImageHash==4.3.1\npsycopg==3.2.10\nhttps://github.com/owncloud/pyocclient/archive/master.zip\npytz==2025.2\ntzdata==2025.2\nPyExifTool==0.4.9\npyvips==3.0.0\nscikit-learn<1.7.3\nseaborn==0.13.2\nsentence_transformers==2.7.0\ntimezonefinder==6.6.3\ntqdm==4.67.1\ngevent==25.9.1\npython-magic==0.4.27\nWand==0.6.13\ndjango-q2==1.9.0\nsafetensors==0.6.2\npy-cpuinfo==9.0.0\npsutil==7.2.2\n\n# Dependencies for blip\ntimm==1.0.19\n# Dependencies for Moondream chat handler (llama-cpp-python multimodal)\ntransformers==4.56.2\n# Dependencies for mistral quantized and multimodal models like Moondream\nllama-cpp-python==0.3.16\nargon2-cffi==23.1.0\n# Dependencies for SigLIP 2 ONNX inference\nonnxruntime==1.21.1\n# SentencePiece tokenizer for SigLIP 2\nsentencepiece==0.2.0"
  },
  {
    "path": "service/__init__.py",
    "content": ""
  },
  {
    "path": "service/clip_embeddings/__init__.py",
    "content": ""
  },
  {
    "path": "service/clip_embeddings/main.py",
    "content": "import time\n\nimport gevent\nfrom flask import Flask, request\nfrom gevent.pywsgi import WSGIServer\nfrom semantic_search.semantic_search import SemanticSearch\n\napp = Flask(__name__)\n\n\ndef log(message):\n    print(f\"clip embeddings: {message}\")\n\n\nsemantic_search_instance = None\nlast_request_time = None\n\n\n@app.route(\"/clip-embeddings\", methods=[\"POST\"])\ndef create_clip_embeddings():\n    global last_request_time\n    # Update last request time\n    last_request_time = time.time()\n\n    try:\n        data = request.get_json()\n        imgs = data[\"imgs\"]\n        model = data[\"model\"]\n    except Exception as e:\n        print(str(e))\n        return \"\", 400\n\n    global semantic_search_instance\n\n    if semantic_search_instance is None:\n        semantic_search_instance = SemanticSearch()\n\n    imgs_emb, magnitudes = semantic_search_instance.calculate_clip_embeddings(\n        imgs, model\n    )\n    # Convert NumPy arrays to Python lists\n    imgs_emb_list = [enc.tolist() for enc in imgs_emb]\n    magnitudes = [float(m) for m in magnitudes]\n    return {\"imgs_emb\": imgs_emb_list, \"magnitudes\": magnitudes}, 201\n\n\n@app.route(\"/query-embeddings\", methods=[\"POST\"])\ndef calculate_query_embeddings():\n    global last_request_time\n    # Update last request time\n    last_request_time = time.time()\n\n    try:\n        data = request.get_json()\n        query = data[\"query\"]\n        model = data[\"model\"]\n    except Exception as e:\n        print(str(e))\n        return \"\", 400\n    global semantic_search_instance\n\n    if semantic_search_instance is None:\n        semantic_search_instance = SemanticSearch()\n\n    emb, magnitude = semantic_search_instance.calculate_query_embeddings(query, model)\n    return {\"emb\": emb, \"magnitude\": magnitude}, 201\n\n\n@app.route(\"/health\", methods=[\"GET\"])\ndef health():\n    return {\"last_request_time\": last_request_time}, 200\n\n\nif __name__ == \"__main__\":\n    log(\"service starting\")\n    server = WSGIServer((\"0.0.0.0\", 8006), app)\n    server_thread = gevent.spawn(server.serve_forever)\n    gevent.joinall([server_thread])\n"
  },
  {
    "path": "service/clip_embeddings/semantic_search/__init__.py",
    "content": ""
  },
  {
    "path": "service/clip_embeddings/semantic_search/semantic_search.py",
    "content": "import gc\n\nimport numpy as np\nimport PIL\nfrom sentence_transformers import SentenceTransformer\n\n\nclass SemanticSearch:\n    model = None\n    model_is_loaded = False\n\n    def load(self, model):\n        self.load_model(model)\n        self.model_is_loaded = True\n        pass\n\n    def unload(self):\n        del self.model\n        self.model = None\n        gc.collect()\n        self.model_is_loaded = False\n        pass\n\n    def load_model(self, model):\n        self.model = SentenceTransformer(model)\n\n    def calculate_clip_embeddings(self, img_paths, model):\n        import torch\n\n        if not self.model_is_loaded:\n            self.load(model)\n        imgs = []\n        if type(img_paths) is list:\n            for path in img_paths:\n                try:\n                    img = PIL.Image.open(path)\n                    imgs.append(img)\n                except PIL.UnidentifiedImageError:\n                    print(f\"Error loading image: {path}\")\n        else:\n            try:\n                img = PIL.Image.open(img_paths)\n                imgs.append(img)\n            except PIL.UnidentifiedImageError:\n                print(f\"Error loading image: {img_paths}\")\n\n        try:\n            imgs_emb = self.model.encode(imgs, batch_size=32, convert_to_tensor=True)\n            if torch.cuda.is_available():\n                if type(img_paths) is list:\n                    magnitudes = list(\n                        map(lambda x: np.linalg.norm(x.cpu().numpy()), imgs_emb)\n                    )\n\n                    return imgs_emb, magnitudes\n                else:\n                    img_emb = imgs_emb[0].cpu().numpy().tolist()\n                    magnitude = np.linalg.norm(img_emb)\n\n                    return img_emb, magnitude\n            else:\n                if type(img_paths) is list:\n                    magnitudes = map(np.linalg.norm, imgs_emb)\n                    return imgs_emb, magnitudes\n                else:\n                    img_emb = imgs_emb[0].tolist()\n                    magnitude = np.linalg.norm(img_emb)\n\n                return img_emb, magnitude\n        except Exception as e:\n            print(f\"Error in calculating clip embeddings: {e}\")\n            raise e\n\n    def calculate_query_embeddings(self, query, model):\n        if not self.model_is_loaded:\n            self.load(model)\n\n        query_emb = self.model.encode([query], convert_to_tensor=True)[0].tolist()\n        magnitude = np.linalg.norm(query_emb)\n\n        return query_emb, magnitude\n"
  },
  {
    "path": "service/exif/__init__.py",
    "content": ""
  },
  {
    "path": "service/exif/main.py",
    "content": "import exiftool\nimport gevent\nfrom flask import Flask, request\nfrom gevent.pywsgi import WSGIServer\n\nstatic_et = exiftool.ExifTool()\nstatic_struct_et = exiftool.ExifTool(common_args=[\"-struct\"])\n\napp = Flask(__name__)\n\n\ndef log(message):\n    print(f\"exif: {message}\")\n\n\n@app.route(\"/get-tags\", methods=[\"POST\"])\ndef get_tags():\n    try:\n        data = request.get_json()\n        files_by_reverse_priority = data[\"files_by_reverse_priority\"]\n        tags = data[\"tags\"]\n        struct = data[\"struct\"]\n    except Exception:\n        return \"\", 400\n\n    et = None\n    if struct:\n        et = static_struct_et\n    else:\n        et = static_et\n    if not et.running:\n        et.start()\n\n    values = []\n    try:\n        for tag in tags:\n            value = None\n            for file in files_by_reverse_priority:\n                retrieved_value = et.get_tag(tag, file)\n                if retrieved_value is not None:\n                    value = retrieved_value\n            values.append(value)\n    except Exception:\n        log(\"An error occurred\")\n\n    return {\"values\": values}, 201\n\n\n@app.route(\"/health\", methods=[\"GET\"])\ndef health():\n    return {\"status\": \"OK\"}, 200\n\n\nif __name__ == \"__main__\":\n    log(\"service starting\")\n    server = WSGIServer((\"0.0.0.0\", 8010), app)\n    server_thread = gevent.spawn(server.serve_forever)\n    gevent.joinall([server_thread])\n"
  },
  {
    "path": "service/face_recognition/__init__.py",
    "content": ""
  },
  {
    "path": "service/face_recognition/main.py",
    "content": "import time\n\nimport face_recognition\nimport gevent\nimport numpy as np\nimport PIL\nfrom flask import Flask, request\nfrom gevent.pywsgi import WSGIServer\n\napp = Flask(__name__)\n\nlast_request_time = None\n\n\ndef log(message):\n    print(f\"face_recognition: {message}\")\n\n\n@app.route(\"/face-encodings\", methods=[\"POST\"])\ndef create_face_encodings():\n    global last_request_time\n    # Update last request time\n    last_request_time = time.time()\n\n    try:\n        data = request.get_json()\n        source = data[\"source\"]\n        face_locations = data[\"face_locations\"]\n    except Exception:\n        return \"\", 400\n\n    image = np.array(PIL.Image.open(source))\n    face_encodings = face_recognition.face_encodings(\n        image,\n        known_face_locations=face_locations,\n    )\n    # Convert NumPy arrays to Python lists\n    face_encodings_list = [enc.tolist() for enc in face_encodings]\n    # Log number of face encodings\n    log(f\"created face_encodings={len(face_encodings_list)}\")\n    return {\"encodings\": face_encodings_list}, 201\n\n\n@app.route(\"/face-locations\", methods=[\"POST\"])\ndef create_face_locations():\n    global last_request_time\n    # Update last request time\n    last_request_time = time.time()\n\n    try:\n        data = request.get_json()\n        source = data[\"source\"]\n        model = data[\"model\"]\n    except Exception:\n        return \"\", 400\n\n    image = np.array(PIL.Image.open(source))\n    face_locations = face_recognition.face_locations(image, model=model)\n    log(f\"created face_location={face_locations}\")\n    return {\"face_locations\": face_locations}, 201\n\n\n@app.route(\"/health\", methods=[\"GET\"])\ndef health():\n    return {\"last_request_time\": last_request_time}, 200\n\n\nif __name__ == \"__main__\":\n    log(\"service starting\")\n    server = WSGIServer((\"0.0.0.0\", 8005), app)\n    server_thread = gevent.spawn(server.serve_forever)\n    gevent.joinall([server_thread])\n"
  },
  {
    "path": "service/image_captioning/__init__.py",
    "content": ""
  },
  {
    "path": "service/image_captioning/api/im2txt/README.md",
    "content": "# Image Captioning\nThe goal of image captioning is to convert a given input image into a natural language description. The encoder-decoder framework is widely used for this task. The image encoder is a convolutional neural network (CNN). In this tutorial, we used [resnet-152](https://arxiv.org/abs/1512.03385) model pretrained on the [ILSVRC-2012-CLS](http://www.image-net.org/challenges/LSVRC/2012/) image classification dataset. The decoder is a long short-term memory (LSTM) network. \n\n![alt text](png/model.png)\n\n#### Training phase\nFor the encoder part, the pretrained CNN extracts the feature vector from a given input image. The feature vector is linearly transformed to have the same dimension as the input dimension of the LSTM network. For the decoder part, source and target texts are predefined. For example, if the image description is **\"Giraffes standing next to each other\"**, the source sequence is a list containing **['\\<start\\>', 'Giraffes', 'standing', 'next', 'to', 'each', 'other']** and the target sequence is a list containing **['Giraffes', 'standing', 'next', 'to', 'each', 'other', '\\<end\\>']**. Using these source and target sequences and the feature vector, the LSTM decoder is trained as a language model conditioned on the feature vector.\n\n#### Test phase\nIn the test phase, the encoder part is almost same as the training phase. The only difference is that batchnorm layer uses moving average and variance instead of mini-batch statistics. This can be easily implemented using [encoder.eval()](https://github.com/yunjey/pytorch-tutorial/blob/master/tutorials/03-advanced/image_captioning/sample.py#L37). For the decoder part, there is a significant difference between the training phase and the test phase. In the test phase, the LSTM decoder can't see the image description. To deal with this problem, the LSTM decoder feeds back the previously generated word to the next input. This can be implemented using a [for-loop](https://github.com/yunjey/pytorch-tutorial/blob/master/tutorials/03-advanced/image_captioning/model.py#L48).\n\n\n\n## Usage \n\n\n#### 1. Clone the repositories\n```bash\n$ git clone https://github.com/pdollar/coco.git\n$ cd coco/PythonAPI/\n$ make\n$ python setup.py build\n$ python setup.py install\n$ cd ../../\n$ git clone https://github.com/yunjey/pytorch-tutorial.git\n$ cd pytorch-tutorial/tutorials/03-advanced/image_captioning/\n```\n\n#### 2. Download the dataset\n\n```bash\n$ pip install -r requirements.txt\n$ chmod +x download.sh\n$ ./download.sh\n```\n\n#### 3. Preprocessing\n\n```bash\n$ python build_vocab.py   \n$ python resize.py\n```\n\n#### 4. Train the model\n\n```bash\n$ python train.py    \n```\n\n#### 5. Test the model \n\n```bash\n$ python sample.py --image='png/example.png'\n```\n\n<br>\n\n## Pretrained model\nIf you do not want to train the model from scratch, you can use a pretrained model. You can download the pretrained model [here](https://www.dropbox.com/s/ne0ixz5d58ccbbz/pretrained_model.zip?dl=0) and the vocabulary file [here](https://www.dropbox.com/s/26adb7y9m98uisa/vocap.zip?dl=0). You should extract pretrained_model.zip to `./models/` and vocab.pkl to `./data/` using `unzip` command.\n"
  },
  {
    "path": "service/image_captioning/api/im2txt/blip/blip.py",
    "content": "\"\"\"* Copyright (c) 2022, salesforce.com, inc.\n* All rights reserved.\n* SPDX-License-Identifier: BSD-3-Clause\n* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause\n* By Junnan Li\n\"\"\"\n\nimport os\nfrom urllib.parse import urlparse\n\nimport torch\nfrom torch import nn\nfrom transformers import BertTokenizer\n\nfrom api.im2txt.blip.med import BertConfig, BertLMHeadModel, BertModel\nfrom api.im2txt.blip.vit import VisionTransformer, interpolate_pos_embed\n\n\nclass BLIP_Base(nn.Module):\n    def __init__(\n        self,\n        med_config=\"configs/med_config.json\",\n        image_size=224,\n        vit=\"base\",\n        vit_grad_ckpt=False,\n        vit_ckpt_layer=0,\n    ):\n        \"\"\"Args:\n        med_config (str): path for the mixture of encoder-decoder model's configuration file\n        image_size (int): input image size\n        vit (str): model size of vision transformer\n\n        \"\"\"\n        super().__init__()\n\n        self.visual_encoder, vision_width = create_vit(\n            vit, image_size, vit_grad_ckpt, vit_ckpt_layer\n        )\n        self.tokenizer = init_tokenizer()\n        med_config = BertConfig.from_json_file(med_config)\n        med_config.encoder_width = vision_width\n        self.text_encoder = BertModel(config=med_config, add_pooling_layer=False)\n\n    def forward(self, image, caption, mode):\n        assert mode in [\n            \"image\",\n            \"text\",\n            \"multimodal\",\n        ], \"mode parameter must be image, text, or multimodal\"\n        text = self.tokenizer(caption, return_tensors=\"pt\").to(image.device)\n\n        if mode == \"image\":\n            # return image features\n            image_embeds = self.visual_encoder(image)\n            return image_embeds\n\n        elif mode == \"text\":\n            # return text features\n            text_output = self.text_encoder(\n                text.input_ids,\n                attention_mask=text.attention_mask,\n                return_dict=True,\n                mode=\"text\",\n            )\n            return text_output.last_hidden_state\n\n        elif mode == \"multimodal\":\n            # return multimodel features\n            image_embeds = self.visual_encoder(image)\n            image_atts = torch.ones(image_embeds.size()[:-1], dtype=torch.long).to(\n                image.device\n            )\n\n            text.input_ids[:, 0] = self.tokenizer.enc_token_id\n            output = self.text_encoder(\n                text.input_ids,\n                attention_mask=text.attention_mask,\n                encoder_hidden_states=image_embeds,\n                encoder_attention_mask=image_atts,\n                return_dict=True,\n            )\n            return output.last_hidden_state\n\n\nclass BLIP_Decoder(nn.Module):\n    def __init__(\n        self,\n        med_config=\"configs/med_config.json\",\n        image_size=384,\n        vit=\"base\",\n        vit_grad_ckpt=False,\n        vit_ckpt_layer=0,\n        prompt=\"a picture of \",\n    ):\n        \"\"\"Args:\n        med_config (str): path for the mixture of encoder-decoder model's configuration file\n        image_size (int): input image size\n        vit (str): model size of vision transformer\n\n        \"\"\"\n        super().__init__()\n\n        self.visual_encoder, vision_width = create_vit(\n            vit, image_size, vit_grad_ckpt, vit_ckpt_layer\n        )\n        self.tokenizer = init_tokenizer()\n        med_config = BertConfig.from_json_file(med_config)\n        med_config.encoder_width = vision_width\n        self.text_decoder = BertLMHeadModel(config=med_config)\n\n        self.prompt = prompt\n        self.prompt_length = len(self.tokenizer(self.prompt).input_ids) - 1\n\n    def forward(self, image, caption):\n        image_embeds = self.visual_encoder(image)\n        image_atts = torch.ones(image_embeds.size()[:-1], dtype=torch.long).to(\n            image.device\n        )\n\n        text = self.tokenizer(\n            caption,\n            padding=\"longest\",\n            truncation=True,\n            max_length=40,\n            return_tensors=\"pt\",\n        ).to(image.device)\n\n        text.input_ids[:, 0] = self.tokenizer.bos_token_id\n\n        decoder_targets = text.input_ids.masked_fill(\n            text.input_ids == self.tokenizer.pad_token_id, -100\n        )\n        decoder_targets[:, : self.prompt_length] = -100\n\n        decoder_output = self.text_decoder(\n            text.input_ids,\n            attention_mask=text.attention_mask,\n            encoder_hidden_states=image_embeds,\n            encoder_attention_mask=image_atts,\n            labels=decoder_targets,\n            return_dict=True,\n        )\n        loss_lm = decoder_output.loss\n\n        return loss_lm\n\n    def generate(\n        self,\n        image,\n        sample=False,\n        num_beams=3,\n        max_length=30,\n        min_length=10,\n        top_p=0.9,\n        repetition_penalty=1.0,\n    ):\n        image_embeds = self.visual_encoder(image)\n\n        if not sample:\n            image_embeds = image_embeds.repeat_interleave(num_beams, dim=0)\n\n        image_atts = torch.ones(image_embeds.size()[:-1], dtype=torch.long).to(\n            image.device\n        )\n        model_kwargs = {\n            \"encoder_hidden_states\": image_embeds,\n            \"encoder_attention_mask\": image_atts,\n        }\n\n        prompt = [self.prompt] * image.size(0)\n        input_ids = self.tokenizer(prompt, return_tensors=\"pt\").input_ids.to(\n            image.device\n        )\n        input_ids[:, 0] = self.tokenizer.bos_token_id\n        input_ids = input_ids[:, :-1]\n\n        if sample:\n            # nucleus sampling\n            outputs = self.text_decoder.generate(\n                input_ids=input_ids,\n                max_length=max_length,\n                min_length=min_length,\n                do_sample=True,\n                top_p=top_p,\n                num_return_sequences=1,\n                eos_token_id=self.tokenizer.sep_token_id,\n                pad_token_id=self.tokenizer.pad_token_id,\n                repetition_penalty=1.1,\n                **model_kwargs,\n            )\n        else:\n            # beam search\n            outputs = self.text_decoder.generate(\n                input_ids=input_ids,\n                max_length=max_length,\n                min_length=min_length,\n                num_beams=num_beams,\n                eos_token_id=self.tokenizer.sep_token_id,\n                pad_token_id=self.tokenizer.pad_token_id,\n                repetition_penalty=repetition_penalty,\n                **model_kwargs,\n            )\n\n        captions = []\n        for output in outputs:\n            caption = self.tokenizer.decode(output, skip_special_tokens=True)\n            captions.append(caption[len(self.prompt) :])\n        return captions\n\n\ndef blip_decoder(pretrained=\"\", **kwargs):\n    model = BLIP_Decoder(**kwargs)\n    if pretrained:\n        model, msg = load_checkpoint(model, pretrained)\n        assert len(msg.missing_keys) == 0\n    return model\n\n\ndef blip_feature_extractor(pretrained=\"\", **kwargs):\n    model = BLIP_Base(**kwargs)\n    if pretrained:\n        model, msg = load_checkpoint(model, pretrained)\n        assert len(msg.missing_keys) == 0\n    return model\n\n\ndef init_tokenizer():\n    tokenizer = BertTokenizer.from_pretrained(\"bert-base-uncased\")\n    tokenizer.add_special_tokens({\"bos_token\": \"[DEC]\"})\n    tokenizer.add_special_tokens({\"additional_special_tokens\": [\"[ENC]\"]})\n    tokenizer.enc_token_id = tokenizer.additional_special_tokens_ids[0]\n    return tokenizer\n\n\ndef create_vit(vit, image_size, ckpt_layer=0, drop_path_rate=0):\n    assert vit in [\"base\", \"large\"], \"vit parameter must be base or large\"\n    if vit == \"base\":\n        vision_width = 768\n        visual_encoder = VisionTransformer(\n            img_size=image_size,\n            patch_size=16,\n            embed_dim=vision_width,\n            depth=12,\n            num_heads=12,\n            ckpt_layer=ckpt_layer,\n            drop_path_rate=0 or drop_path_rate,\n        )\n    elif vit == \"large\":\n        vision_width = 1024\n        visual_encoder = VisionTransformer(\n            img_size=image_size,\n            patch_size=16,\n            embed_dim=vision_width,\n            depth=24,\n            num_heads=16,\n            ckpt_layer=ckpt_layer,\n            drop_path_rate=0.1 or drop_path_rate,\n        )\n    return visual_encoder, vision_width\n\n\ndef is_url(url_or_filename):\n    parsed = urlparse(url_or_filename)\n    return parsed.scheme in (\"http\", \"https\")\n\n\ndef load_checkpoint(model, url_or_filename):\n    if os.path.isfile(url_or_filename):\n        checkpoint = torch.load(url_or_filename, map_location=\"cpu\")\n    else:\n        raise RuntimeError(\"checkpoint url or path is invalid\")\n\n    state_dict = checkpoint[\"model\"]\n\n    state_dict[\"visual_encoder.pos_embed\"] = interpolate_pos_embed(\n        state_dict[\"visual_encoder.pos_embed\"], model.visual_encoder\n    )\n    if \"visual_encoder_m.pos_embed\" in model.state_dict().keys():\n        state_dict[\"visual_encoder_m.pos_embed\"] = interpolate_pos_embed(\n            state_dict[\"visual_encoder_m.pos_embed\"], model.visual_encoder_m\n        )\n    for key in model.state_dict().keys():\n        if key in state_dict.keys():\n            if state_dict[key].shape != model.state_dict()[key].shape:\n                del state_dict[key]\n\n    msg = model.load_state_dict(state_dict, strict=False)\n    print(\"load checkpoint from %s\" % url_or_filename)\n    return model, msg\n"
  },
  {
    "path": "service/image_captioning/api/im2txt/blip/med.py",
    "content": "\"\"\"* Copyright (c) 2022, salesforce.com, inc.\n* All rights reserved.\n* SPDX-License-Identifier: BSD-3-Clause\n* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause\n* By Junnan Li\n* Based on huggingface code base\n* https://github.com/huggingface/transformers/blob/v4.15.0/src/transformers/models/bert\n\"\"\"\n\nimport math\n\nimport torch\nimport torch.utils.checkpoint\nfrom torch import Tensor, device, nn\nfrom torch.nn import CrossEntropyLoss\nfrom transformers.activations import ACT2FN\nfrom transformers.modeling_outputs import (\n    BaseModelOutputWithPastAndCrossAttentions,\n    BaseModelOutputWithPoolingAndCrossAttentions,\n    CausalLMOutputWithCrossAttentions,\n)\nfrom transformers.modeling_utils import PreTrainedModel\ntry:  # Compatibility with transformers<4.37 where helpers lived in modeling_utils\n    from transformers.modeling_utils import (\n        find_pruneable_heads_and_indices,\n        prune_linear_layer,\n    )\nexcept ImportError:  # pragma: no cover - fallback for transformers>=4.37\n    from transformers.models.bert.modeling_bert import (\n        find_pruneable_heads_and_indices,\n        prune_linear_layer,\n    )\ntry:  # Compatibility with transformers>=4.36 where apply_chunking_to_forward moved\n    from transformers.modeling_utils import apply_chunking_to_forward\nexcept ImportError:  # pragma: no cover - fallback for older/newer transformers versions\n    from transformers.pytorch_utils import apply_chunking_to_forward\nfrom transformers.models.bert.configuration_bert import BertConfig\nfrom transformers.utils import logging\n\nlogger = logging.get_logger(__name__)\n\n\nclass BertEmbeddings(nn.Module):\n    \"\"\"Construct the embeddings from word and position embeddings.\"\"\"\n\n    def __init__(self, config):\n        super().__init__()\n        self.word_embeddings = nn.Embedding(\n            config.vocab_size, config.hidden_size, padding_idx=config.pad_token_id\n        )\n        self.position_embeddings = nn.Embedding(\n            config.max_position_embeddings, config.hidden_size\n        )\n\n        # self.LayerNorm is not snake-cased to stick with TensorFlow model variable name and be able to load\n        # any TensorFlow checkpoint file\n        self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)\n        self.dropout = nn.Dropout(config.hidden_dropout_prob)\n\n        # position_ids (1, len position emb) is contiguous in memory and exported when serialized\n        self.register_buffer(\n            \"position_ids\", torch.arange(config.max_position_embeddings).expand((1, -1))\n        )\n        self.position_embedding_type = getattr(\n            config, \"position_embedding_type\", \"absolute\"\n        )\n\n        self.config = config\n\n    def forward(\n        self,\n        input_ids=None,\n        position_ids=None,\n        inputs_embeds=None,\n        past_key_values_length=0,\n    ):\n        if input_ids is not None:\n            input_shape = input_ids.size()\n        else:\n            input_shape = inputs_embeds.size()[:-1]\n\n        seq_length = input_shape[1]\n\n        if position_ids is None:\n            position_ids = self.position_ids[\n                :, past_key_values_length : seq_length + past_key_values_length\n            ]\n\n        if inputs_embeds is None:\n            inputs_embeds = self.word_embeddings(input_ids)\n\n        embeddings = inputs_embeds\n\n        if self.position_embedding_type == \"absolute\":\n            position_embeddings = self.position_embeddings(position_ids)\n            embeddings += position_embeddings\n        embeddings = self.LayerNorm(embeddings)\n        embeddings = self.dropout(embeddings)\n        return embeddings\n\n\nclass BertSelfAttention(nn.Module):\n    def __init__(self, config, is_cross_attention):\n        super().__init__()\n        self.config = config\n        if config.hidden_size % config.num_attention_heads != 0 and not hasattr(\n            config, \"embedding_size\"\n        ):\n            raise ValueError(\n                \"The hidden size (%d) is not a multiple of the number of attention \"\n                \"heads (%d)\" % (config.hidden_size, config.num_attention_heads)\n            )\n\n        self.num_attention_heads = config.num_attention_heads\n        self.attention_head_size = int(config.hidden_size / config.num_attention_heads)\n        self.all_head_size = self.num_attention_heads * self.attention_head_size\n\n        self.query = nn.Linear(config.hidden_size, self.all_head_size)\n        if is_cross_attention:\n            self.key = nn.Linear(config.encoder_width, self.all_head_size)\n            self.value = nn.Linear(config.encoder_width, self.all_head_size)\n        else:\n            self.key = nn.Linear(config.hidden_size, self.all_head_size)\n            self.value = nn.Linear(config.hidden_size, self.all_head_size)\n\n        self.dropout = nn.Dropout(config.attention_probs_dropout_prob)\n        self.position_embedding_type = getattr(\n            config, \"position_embedding_type\", \"absolute\"\n        )\n        if (\n            self.position_embedding_type == \"relative_key\"\n            or self.position_embedding_type == \"relative_key_query\"\n        ):\n            self.max_position_embeddings = config.max_position_embeddings\n            self.distance_embedding = nn.Embedding(\n                2 * config.max_position_embeddings - 1, self.attention_head_size\n            )\n        self.save_attention = False\n\n    def save_attn_gradients(self, attn_gradients):\n        self.attn_gradients = attn_gradients\n\n    def get_attn_gradients(self):\n        return self.attn_gradients\n\n    def save_attention_map(self, attention_map):\n        self.attention_map = attention_map\n\n    def get_attention_map(self):\n        return self.attention_map\n\n    def transpose_for_scores(self, x):\n        new_x_shape = x.size()[:-1] + (\n            self.num_attention_heads,\n            self.attention_head_size,\n        )\n        x = x.view(*new_x_shape)\n        return x.permute(0, 2, 1, 3)\n\n    def forward(\n        self,\n        hidden_states,\n        attention_mask=None,\n        head_mask=None,\n        encoder_hidden_states=None,\n        encoder_attention_mask=None,\n        past_key_value=None,\n        output_attentions=False,\n    ):\n        mixed_query_layer = self.query(hidden_states)\n\n        # If this is instantiated as a cross-attention module, the keys\n        # and values come from an encoder; the attention mask needs to be\n        # such that the encoder's padding tokens are not attended to.\n        is_cross_attention = encoder_hidden_states is not None\n\n        if is_cross_attention:\n            key_layer = self.transpose_for_scores(self.key(encoder_hidden_states))\n            value_layer = self.transpose_for_scores(self.value(encoder_hidden_states))\n            attention_mask = encoder_attention_mask\n        elif past_key_value is not None:\n            key_layer = self.transpose_for_scores(self.key(hidden_states))\n            value_layer = self.transpose_for_scores(self.value(hidden_states))\n            key_layer = torch.cat([past_key_value[0], key_layer], dim=2)\n            value_layer = torch.cat([past_key_value[1], value_layer], dim=2)\n        else:\n            key_layer = self.transpose_for_scores(self.key(hidden_states))\n            value_layer = self.transpose_for_scores(self.value(hidden_states))\n\n        query_layer = self.transpose_for_scores(mixed_query_layer)\n\n        past_key_value = (key_layer, value_layer)\n\n        # Take the dot product between \"query\" and \"key\" to get the raw attention scores.\n        attention_scores = torch.matmul(query_layer, key_layer.transpose(-1, -2))\n\n        if (\n            self.position_embedding_type == \"relative_key\"\n            or self.position_embedding_type == \"relative_key_query\"\n        ):\n            seq_length = hidden_states.size()[1]\n            position_ids_l = torch.arange(\n                seq_length, dtype=torch.long, device=hidden_states.device\n            ).view(-1, 1)\n            position_ids_r = torch.arange(\n                seq_length, dtype=torch.long, device=hidden_states.device\n            ).view(1, -1)\n            distance = position_ids_l - position_ids_r\n            positional_embedding = self.distance_embedding(\n                distance + self.max_position_embeddings - 1\n            )\n            positional_embedding = positional_embedding.to(\n                dtype=query_layer.dtype\n            )  # fp16 compatibility\n\n            if self.position_embedding_type == \"relative_key\":\n                relative_position_scores = torch.einsum(\n                    \"bhld,lrd->bhlr\", query_layer, positional_embedding\n                )\n                attention_scores = attention_scores + relative_position_scores\n            elif self.position_embedding_type == \"relative_key_query\":\n                relative_position_scores_query = torch.einsum(\n                    \"bhld,lrd->bhlr\", query_layer, positional_embedding\n                )\n                relative_position_scores_key = torch.einsum(\n                    \"bhrd,lrd->bhlr\", key_layer, positional_embedding\n                )\n                attention_scores = (\n                    attention_scores\n                    + relative_position_scores_query\n                    + relative_position_scores_key\n                )\n\n        attention_scores = attention_scores / math.sqrt(self.attention_head_size)\n        if attention_mask is not None:\n            # Apply the attention mask is (precomputed for all layers in BertModel forward() function)\n            attention_scores = attention_scores + attention_mask\n\n        # Normalize the attention scores to probabilities.\n        attention_probs = nn.Softmax(dim=-1)(attention_scores)\n\n        if is_cross_attention and self.save_attention:\n            self.save_attention_map(attention_probs)\n            attention_probs.register_hook(self.save_attn_gradients)\n\n        # This is actually dropping out entire tokens to attend to, which might\n        # seem a bit unusual, but is taken from the original Transformer paper.\n        attention_probs_dropped = self.dropout(attention_probs)\n\n        # Mask heads if we want to\n        if head_mask is not None:\n            attention_probs_dropped = attention_probs_dropped * head_mask\n\n        context_layer = torch.matmul(attention_probs_dropped, value_layer)\n\n        context_layer = context_layer.permute(0, 2, 1, 3).contiguous()\n        new_context_layer_shape = context_layer.size()[:-2] + (self.all_head_size,)\n        context_layer = context_layer.view(*new_context_layer_shape)\n\n        outputs = (\n            (context_layer, attention_probs) if output_attentions else (context_layer,)\n        )\n\n        outputs = outputs + (past_key_value,)\n        return outputs\n\n\nclass BertSelfOutput(nn.Module):\n    def __init__(self, config):\n        super().__init__()\n        self.dense = nn.Linear(config.hidden_size, config.hidden_size)\n        self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)\n        self.dropout = nn.Dropout(config.hidden_dropout_prob)\n\n    def forward(self, hidden_states, input_tensor):\n        hidden_states = self.dense(hidden_states)\n        hidden_states = self.dropout(hidden_states)\n        hidden_states = self.LayerNorm(hidden_states + input_tensor)\n        return hidden_states\n\n\nclass BertAttention(nn.Module):\n    def __init__(self, config, is_cross_attention=False):\n        super().__init__()\n        self.self = BertSelfAttention(config, is_cross_attention)\n        self.output = BertSelfOutput(config)\n        self.pruned_heads = set()\n\n    def prune_heads(self, heads):\n        if len(heads) == 0:\n            return\n        heads, index = find_pruneable_heads_and_indices(\n            heads,\n            self.self.num_attention_heads,\n            self.self.attention_head_size,\n            self.pruned_heads,\n        )\n\n        # Prune linear layers\n        self.self.query = prune_linear_layer(self.self.query, index)\n        self.self.key = prune_linear_layer(self.self.key, index)\n        self.self.value = prune_linear_layer(self.self.value, index)\n        self.output.dense = prune_linear_layer(self.output.dense, index, dim=1)\n\n        # Update hyper params and store pruned heads\n        self.self.num_attention_heads = self.self.num_attention_heads - len(heads)\n        self.self.all_head_size = (\n            self.self.attention_head_size * self.self.num_attention_heads\n        )\n        self.pruned_heads = self.pruned_heads.union(heads)\n\n    def forward(\n        self,\n        hidden_states,\n        attention_mask=None,\n        head_mask=None,\n        encoder_hidden_states=None,\n        encoder_attention_mask=None,\n        past_key_value=None,\n        output_attentions=False,\n    ):\n        self_outputs = self.self(\n            hidden_states,\n            attention_mask,\n            head_mask,\n            encoder_hidden_states,\n            encoder_attention_mask,\n            past_key_value,\n            output_attentions,\n        )\n        attention_output = self.output(self_outputs[0], hidden_states)\n        outputs = (attention_output,) + self_outputs[\n            1:\n        ]  # add attentions if we output them\n        return outputs\n\n\nclass BertIntermediate(nn.Module):\n    def __init__(self, config):\n        super().__init__()\n        self.dense = nn.Linear(config.hidden_size, config.intermediate_size)\n        if isinstance(config.hidden_act, str):\n            self.intermediate_act_fn = ACT2FN[config.hidden_act]\n        else:\n            self.intermediate_act_fn = config.hidden_act\n\n    def forward(self, hidden_states):\n        hidden_states = self.dense(hidden_states)\n        hidden_states = self.intermediate_act_fn(hidden_states)\n        return hidden_states\n\n\nclass BertOutput(nn.Module):\n    def __init__(self, config):\n        super().__init__()\n        self.dense = nn.Linear(config.intermediate_size, config.hidden_size)\n        self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)\n        self.dropout = nn.Dropout(config.hidden_dropout_prob)\n\n    def forward(self, hidden_states, input_tensor):\n        hidden_states = self.dense(hidden_states)\n        hidden_states = self.dropout(hidden_states)\n        hidden_states = self.LayerNorm(hidden_states + input_tensor)\n        return hidden_states\n\n\nclass BertLayer(nn.Module):\n    def __init__(self, config, layer_num):\n        super().__init__()\n        self.config = config\n        self.chunk_size_feed_forward = config.chunk_size_feed_forward\n        self.seq_len_dim = 1\n        self.attention = BertAttention(config)\n        self.layer_num = layer_num\n        if self.config.add_cross_attention:\n            self.crossattention = BertAttention(\n                config, is_cross_attention=self.config.add_cross_attention\n            )\n        self.intermediate = BertIntermediate(config)\n        self.output = BertOutput(config)\n\n    def forward(\n        self,\n        hidden_states,\n        attention_mask=None,\n        head_mask=None,\n        encoder_hidden_states=None,\n        encoder_attention_mask=None,\n        past_key_value=None,\n        output_attentions=False,\n        mode=None,\n    ):\n        # decoder uni-directional self-attention cached key/values tuple is at positions 1,2\n        self_attn_past_key_value = (\n            past_key_value[:2] if past_key_value is not None else None\n        )\n        self_attention_outputs = self.attention(\n            hidden_states,\n            attention_mask,\n            head_mask,\n            output_attentions=output_attentions,\n            past_key_value=self_attn_past_key_value,\n        )\n        attention_output = self_attention_outputs[0]\n\n        outputs = self_attention_outputs[1:-1]\n        present_key_value = self_attention_outputs[-1]\n\n        if mode == \"multimodal\":\n            assert encoder_hidden_states is not None, (\n                \"encoder_hidden_states must be given for cross-attention layers\"\n            )\n\n            cross_attention_outputs = self.crossattention(\n                attention_output,\n                attention_mask,\n                head_mask,\n                encoder_hidden_states,\n                encoder_attention_mask,\n                output_attentions=output_attentions,\n            )\n            attention_output = cross_attention_outputs[0]\n            outputs = (\n                outputs + cross_attention_outputs[1:-1]\n            )  # add cross attentions if we output attention weights\n        layer_output = apply_chunking_to_forward(\n            self.feed_forward_chunk,\n            self.chunk_size_feed_forward,\n            self.seq_len_dim,\n            attention_output,\n        )\n        outputs = (layer_output,) + outputs\n\n        outputs = outputs + (present_key_value,)\n\n        return outputs\n\n    def feed_forward_chunk(self, attention_output):\n        intermediate_output = self.intermediate(attention_output)\n        layer_output = self.output(intermediate_output, attention_output)\n        return layer_output\n\n\nclass BertEncoder(nn.Module):\n    def __init__(self, config):\n        super().__init__()\n        self.config = config\n        self.layer = nn.ModuleList(\n            [BertLayer(config, i) for i in range(config.num_hidden_layers)]\n        )\n        self.gradient_checkpointing = False\n\n    def forward(\n        self,\n        hidden_states,\n        attention_mask=None,\n        head_mask=None,\n        encoder_hidden_states=None,\n        encoder_attention_mask=None,\n        past_key_values=None,\n        use_cache=None,\n        output_attentions=False,\n        output_hidden_states=False,\n        return_dict=True,\n        mode=\"multimodal\",\n    ):\n        all_hidden_states = () if output_hidden_states else None\n        all_self_attentions = () if output_attentions else None\n        all_cross_attentions = (\n            () if output_attentions and self.config.add_cross_attention else None\n        )\n\n        next_decoder_cache = () if use_cache else None\n\n        for i in range(self.config.num_hidden_layers):\n            layer_module = self.layer[i]\n            if output_hidden_states:\n                all_hidden_states = all_hidden_states + (hidden_states,)\n\n            layer_head_mask = head_mask[i] if head_mask is not None else None\n            past_key_value = past_key_values[i] if past_key_values is not None else None\n\n            if self.gradient_checkpointing and self.training:\n                if use_cache:\n                    logger.warning(\n                        \"`use_cache=True` is incompatible with gradient checkpointing. Setting `use_cache=False`...\"\n                    )\n                    use_cache = False\n\n                def create_custom_forward(module):\n                    def custom_forward(*inputs):\n                        return module(*inputs, past_key_value, output_attentions)\n\n                    return custom_forward\n\n                layer_outputs = torch.utils.checkpoint.checkpoint(\n                    create_custom_forward(layer_module),\n                    hidden_states,\n                    attention_mask,\n                    layer_head_mask,\n                    encoder_hidden_states,\n                    encoder_attention_mask,\n                    mode=mode,\n                )\n            else:\n                layer_outputs = layer_module(\n                    hidden_states,\n                    attention_mask,\n                    layer_head_mask,\n                    encoder_hidden_states,\n                    encoder_attention_mask,\n                    past_key_value,\n                    output_attentions,\n                    mode=mode,\n                )\n\n            hidden_states = layer_outputs[0]\n            if use_cache:\n                next_decoder_cache += (layer_outputs[-1],)\n            if output_attentions:\n                all_self_attentions = all_self_attentions + (layer_outputs[1],)\n\n        if output_hidden_states:\n            all_hidden_states = all_hidden_states + (hidden_states,)\n\n        if not return_dict:\n            return tuple(\n                v\n                for v in [\n                    hidden_states,\n                    next_decoder_cache,\n                    all_hidden_states,\n                    all_self_attentions,\n                    all_cross_attentions,\n                ]\n                if v is not None\n            )\n        return BaseModelOutputWithPastAndCrossAttentions(\n            last_hidden_state=hidden_states,\n            past_key_values=next_decoder_cache,\n            hidden_states=all_hidden_states,\n            attentions=all_self_attentions,\n            cross_attentions=all_cross_attentions,\n        )\n\n\nclass BertPooler(nn.Module):\n    def __init__(self, config):\n        super().__init__()\n        self.dense = nn.Linear(config.hidden_size, config.hidden_size)\n        self.activation = nn.Tanh()\n\n    def forward(self, hidden_states):\n        # We \"pool\" the model by simply taking the hidden state corresponding\n        # to the first token.\n        first_token_tensor = hidden_states[:, 0]\n        pooled_output = self.dense(first_token_tensor)\n        pooled_output = self.activation(pooled_output)\n        return pooled_output\n\n\nclass BertPredictionHeadTransform(nn.Module):\n    def __init__(self, config):\n        super().__init__()\n        self.dense = nn.Linear(config.hidden_size, config.hidden_size)\n        if isinstance(config.hidden_act, str):\n            self.transform_act_fn = ACT2FN[config.hidden_act]\n        else:\n            self.transform_act_fn = config.hidden_act\n        self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)\n\n    def forward(self, hidden_states):\n        hidden_states = self.dense(hidden_states)\n        hidden_states = self.transform_act_fn(hidden_states)\n        hidden_states = self.LayerNorm(hidden_states)\n        return hidden_states\n\n\nclass BertLMPredictionHead(nn.Module):\n    def __init__(self, config):\n        super().__init__()\n        self.transform = BertPredictionHeadTransform(config)\n\n        # The output weights are the same as the input embeddings, but there is\n        # an output-only bias for each token.\n        self.decoder = nn.Linear(config.hidden_size, config.vocab_size, bias=False)\n\n        self.bias = nn.Parameter(torch.zeros(config.vocab_size))\n\n        # Need a link between the two variables so that the bias is correctly resized with `resize_token_embeddings`\n        self.decoder.bias = self.bias\n\n    def forward(self, hidden_states):\n        hidden_states = self.transform(hidden_states)\n        hidden_states = self.decoder(hidden_states)\n        return hidden_states\n\n\nclass BertOnlyMLMHead(nn.Module):\n    def __init__(self, config):\n        super().__init__()\n        self.predictions = BertLMPredictionHead(config)\n\n    def forward(self, sequence_output):\n        prediction_scores = self.predictions(sequence_output)\n        return prediction_scores\n\n\nclass BertPreTrainedModel(PreTrainedModel):\n    \"\"\"An abstract class to handle weights initialization and a simple interface for downloading and loading pretrained\n    models.\n    \"\"\"\n\n    config_class = BertConfig\n    base_model_prefix = \"bert\"\n    _keys_to_ignore_on_load_missing = [r\"position_ids\"]\n\n    def _init_weights(self, module):\n        \"\"\"Initialize the weights\"\"\"\n        if isinstance(module, (nn.Linear, nn.Embedding)):\n            # Slightly different from the TF version which uses truncated_normal for initialization\n            # cf https://github.com/pytorch/pytorch/pull/5617\n            module.weight.data.normal_(mean=0.0, std=self.config.initializer_range)\n        elif isinstance(module, nn.LayerNorm):\n            module.bias.data.zero_()\n            module.weight.data.fill_(1.0)\n        if isinstance(module, nn.Linear) and module.bias is not None:\n            module.bias.data.zero_()\n\n\nclass BertModel(BertPreTrainedModel):\n    \"\"\"The model can behave as an encoder (with only self-attention) as well as a decoder, in which case a layer of\n    cross-attention is added between the self-attention layers, following the architecture described in `Attention is\n    all you need <https://arxiv.org/abs/1706.03762>`__ by Ashish Vaswani, Noam Shazeer, Niki Parmar, Jakob Uszkoreit,\n    Llion Jones, Aidan N. Gomez, Lukasz Kaiser and Illia Polosukhin.\n    argument and :obj:`add_cross_attention` set to :obj:`True`; an :obj:`encoder_hidden_states` is then expected as an\n    input to the forward pass.\n    \"\"\"\n\n    def __init__(self, config, add_pooling_layer=True):\n        super().__init__(config)\n        self.config = config\n\n        self.embeddings = BertEmbeddings(config)\n\n        self.encoder = BertEncoder(config)\n\n        self.pooler = BertPooler(config) if add_pooling_layer else None\n\n        self.init_weights()\n\n    def get_input_embeddings(self):\n        return self.embeddings.word_embeddings\n\n    def set_input_embeddings(self, value):\n        self.embeddings.word_embeddings = value\n\n    def _prune_heads(self, heads_to_prune):\n        \"\"\"Prunes heads of the model. heads_to_prune: dict of {layer_num: list of heads to prune in this layer} See base\n        class PreTrainedModel\n        \"\"\"\n        for layer, heads in heads_to_prune.items():\n            self.encoder.layer[layer].attention.prune_heads(heads)\n\n    def get_extended_attention_mask(\n        self,\n        attention_mask: Tensor,\n        input_shape: tuple[int],\n        device: device,\n        is_decoder: bool,\n    ) -> Tensor:\n        \"\"\"Makes broadcastable attention and causal masks so that future and masked tokens are ignored.\n\n        Arguments:\n            attention_mask (:obj:`torch.Tensor`):\n                Mask with ones indicating tokens to attend to, zeros for tokens to ignore.\n            input_shape (:obj:`Tuple[int]`):\n                The shape of the input to the model.\n            device: (:obj:`torch.device`):\n                The device of the input to the model.\n\n        Returns:\n            :obj:`torch.Tensor` The extended attention mask, with a the same dtype as :obj:`attention_mask.dtype`.\n\n        \"\"\"\n        # We can provide a self-attention mask of dimensions [batch_size, from_seq_length, to_seq_length]\n        # ourselves in which case we just need to make it broadcastable to all heads.\n        if attention_mask.dim() == 3:\n            extended_attention_mask = attention_mask[:, None, :, :]\n        elif attention_mask.dim() == 2:\n            # Provided a padding mask of dimensions [batch_size, seq_length]\n            # - if the model is a decoder, apply a causal mask in addition to the padding mask\n            # - if the model is an encoder, make the mask broadcastable to [batch_size, num_heads, seq_length, seq_length]\n            if is_decoder:\n                batch_size, seq_length = input_shape\n\n                seq_ids = torch.arange(seq_length, device=device)\n                causal_mask = (\n                    seq_ids[None, None, :].repeat(batch_size, seq_length, 1)\n                    <= seq_ids[None, :, None]\n                )\n                # in case past_key_values are used we need to add a prefix ones mask to the causal mask\n                # causal and attention masks must have same type with pytorch version < 1.3\n                causal_mask = causal_mask.to(attention_mask.dtype)\n\n                if causal_mask.shape[1] < attention_mask.shape[1]:\n                    prefix_seq_len = attention_mask.shape[1] - causal_mask.shape[1]\n                    causal_mask = torch.cat(\n                        [\n                            torch.ones(\n                                (batch_size, seq_length, prefix_seq_len),\n                                device=device,\n                                dtype=causal_mask.dtype,\n                            ),\n                            causal_mask,\n                        ],\n                        axis=-1,\n                    )\n\n                extended_attention_mask = (\n                    causal_mask[:, None, :, :] * attention_mask[:, None, None, :]\n                )\n            else:\n                extended_attention_mask = attention_mask[:, None, None, :]\n        else:\n            raise ValueError(\n                f\"Wrong shape for input_ids (shape {input_shape}) or attention_mask (shape {attention_mask.shape})\"\n            )\n\n        # Since attention_mask is 1.0 for positions we want to attend and 0.0 for\n        # masked positions, this operation will create a tensor which is 0.0 for\n        # positions we want to attend and -10000.0 for masked positions.\n        # Since we are adding it to the raw scores before the softmax, this is\n        # effectively the same as removing these entirely.\n        extended_attention_mask = extended_attention_mask.to(\n            dtype=self.dtype\n        )  # fp16 compatibility\n        extended_attention_mask = (1.0 - extended_attention_mask) * -10000.0\n        return extended_attention_mask\n\n    def forward(\n        self,\n        input_ids=None,\n        attention_mask=None,\n        position_ids=None,\n        head_mask=None,\n        inputs_embeds=None,\n        encoder_embeds=None,\n        encoder_hidden_states=None,\n        encoder_attention_mask=None,\n        past_key_values=None,\n        use_cache=None,\n        output_attentions=None,\n        output_hidden_states=None,\n        return_dict=None,\n        is_decoder=False,\n        mode=\"multimodal\",\n    ):\n        r\"\"\"encoder_hidden_states  (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length, hidden_size)`, `optional`):\n            Sequence of hidden-states at the output of the last layer of the encoder. Used in the cross-attention if\n            the model is configured as a decoder.\n        encoder_attention_mask (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length)`, `optional`):\n            Mask to avoid performing attention on the padding token indices of the encoder input. This mask is used in\n            the cross-attention if the model is configured as a decoder. Mask values selected in ``[0, 1]``:\n            - 1 for tokens that are **not masked**,\n            - 0 for tokens that are **masked**.\n        past_key_values (:obj:`tuple(tuple(torch.FloatTensor))` of length :obj:`config.n_layers` with each tuple having 4 tensors of shape :obj:`(batch_size, num_heads, sequence_length - 1, embed_size_per_head)`):\n            Contains precomputed key and value hidden states of the attention blocks. Can be used to speed up decoding.\n            If :obj:`past_key_values` are used, the user can optionally input only the last :obj:`decoder_input_ids`\n            (those that don't have their past key value states given to this model) of shape :obj:`(batch_size, 1)`\n            instead of all :obj:`decoder_input_ids` of shape :obj:`(batch_size, sequence_length)`.\n        use_cache (:obj:`bool`, `optional`):\n            If set to :obj:`True`, :obj:`past_key_values` key value states are returned and can be used to speed up\n            decoding (see :obj:`past_key_values`).\n        \"\"\"\n        output_attentions = (\n            output_attentions\n            if output_attentions is not None\n            else self.config.output_attentions\n        )\n        output_hidden_states = (\n            output_hidden_states\n            if output_hidden_states is not None\n            else self.config.output_hidden_states\n        )\n        return_dict = (\n            return_dict if return_dict is not None else self.config.use_return_dict\n        )\n\n        if is_decoder:\n            use_cache = use_cache if use_cache is not None else self.config.use_cache\n        else:\n            use_cache = False\n\n        if input_ids is not None and inputs_embeds is not None:\n            raise ValueError(\n                \"You cannot specify both input_ids and inputs_embeds at the same time\"\n            )\n        elif input_ids is not None:\n            input_shape = input_ids.size()\n            batch_size, seq_length = input_shape\n            device = input_ids.device\n        elif inputs_embeds is not None:\n            input_shape = inputs_embeds.size()[:-1]\n            batch_size, seq_length = input_shape\n            device = inputs_embeds.device\n        elif encoder_embeds is not None:\n            input_shape = encoder_embeds.size()[:-1]\n            batch_size, seq_length = input_shape\n            device = encoder_embeds.device\n        else:\n            raise ValueError(\n                \"You have to specify either input_ids or inputs_embeds or encoder_embeds\"\n            )\n\n        # past_key_values_length\n        past_key_values_length = (\n            past_key_values[0][0].shape[2] if past_key_values is not None else 0\n        )\n\n        if attention_mask is None:\n            attention_mask = torch.ones(\n                ((batch_size, seq_length + past_key_values_length)), device=device\n            )\n\n        # We can provide a self-attention mask of dimensions [batch_size, from_seq_length, to_seq_length]\n        # ourselves in which case we just need to make it broadcastable to all heads.\n        extended_attention_mask: torch.Tensor = self.get_extended_attention_mask(\n            attention_mask, input_shape, device, is_decoder\n        )\n\n        # If a 2D or 3D attention mask is provided for the cross-attention\n        # we need to make broadcastable to [batch_size, num_heads, seq_length, seq_length]\n        if encoder_hidden_states is not None:\n            if type(encoder_hidden_states) == list:\n                encoder_batch_size, encoder_sequence_length, _ = encoder_hidden_states[\n                    0\n                ].size()\n            else:\n                (\n                    encoder_batch_size,\n                    encoder_sequence_length,\n                    _,\n                ) = encoder_hidden_states.size()\n            encoder_hidden_shape = (encoder_batch_size, encoder_sequence_length)\n\n            if type(encoder_attention_mask) == list:\n                encoder_extended_attention_mask = [\n                    self.invert_attention_mask(mask) for mask in encoder_attention_mask\n                ]\n            elif encoder_attention_mask is None:\n                encoder_attention_mask = torch.ones(encoder_hidden_shape, device=device)\n                encoder_extended_attention_mask = self.invert_attention_mask(\n                    encoder_attention_mask\n                )\n            else:\n                encoder_extended_attention_mask = self.invert_attention_mask(\n                    encoder_attention_mask\n                )\n        else:\n            encoder_extended_attention_mask = None\n\n        # Prepare head mask if needed\n        # 1.0 in head_mask indicate we keep the head\n        # attention_probs has shape bsz x n_heads x N x N\n        # input head_mask has shape [num_heads] or [num_hidden_layers x num_heads]\n        # and head_mask is converted to shape [num_hidden_layers x batch x num_heads x seq_length x seq_length]\n        head_mask = self.get_head_mask(head_mask, self.config.num_hidden_layers)\n\n        if encoder_embeds is None:\n            embedding_output = self.embeddings(\n                input_ids=input_ids,\n                position_ids=position_ids,\n                inputs_embeds=inputs_embeds,\n                past_key_values_length=past_key_values_length,\n            )\n        else:\n            embedding_output = encoder_embeds\n\n        encoder_outputs = self.encoder(\n            embedding_output,\n            attention_mask=extended_attention_mask,\n            head_mask=head_mask,\n            encoder_hidden_states=encoder_hidden_states,\n            encoder_attention_mask=encoder_extended_attention_mask,\n            past_key_values=past_key_values,\n            use_cache=use_cache,\n            output_attentions=output_attentions,\n            output_hidden_states=output_hidden_states,\n            return_dict=return_dict,\n            mode=mode,\n        )\n        sequence_output = encoder_outputs[0]\n        pooled_output = (\n            self.pooler(sequence_output) if self.pooler is not None else None\n        )\n\n        if not return_dict:\n            return (sequence_output, pooled_output) + encoder_outputs[1:]\n\n        return BaseModelOutputWithPoolingAndCrossAttentions(\n            last_hidden_state=sequence_output,\n            pooler_output=pooled_output,\n            past_key_values=encoder_outputs.past_key_values,\n            hidden_states=encoder_outputs.hidden_states,\n            attentions=encoder_outputs.attentions,\n            cross_attentions=encoder_outputs.cross_attentions,\n        )\n\n\nclass BertLMHeadModel(BertPreTrainedModel):\n    _keys_to_ignore_on_load_unexpected = [r\"pooler\"]\n    _keys_to_ignore_on_load_missing = [r\"position_ids\", r\"predictions.decoder.bias\"]\n\n    def __init__(self, config):\n        super().__init__(config)\n\n        self.bert = BertModel(config, add_pooling_layer=False)\n        self.cls = BertOnlyMLMHead(config)\n\n        self.init_weights()\n\n    def get_output_embeddings(self):\n        return self.cls.predictions.decoder\n\n    def set_output_embeddings(self, new_embeddings):\n        self.cls.predictions.decoder = new_embeddings\n\n    def forward(\n        self,\n        input_ids=None,\n        attention_mask=None,\n        position_ids=None,\n        head_mask=None,\n        inputs_embeds=None,\n        encoder_hidden_states=None,\n        encoder_attention_mask=None,\n        labels=None,\n        past_key_values=None,\n        use_cache=None,\n        output_attentions=None,\n        output_hidden_states=None,\n        return_dict=None,\n        return_logits=False,\n        is_decoder=True,\n        reduction=\"mean\",\n        mode=\"multimodal\",\n    ):\n        r\"\"\"encoder_hidden_states  (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length, hidden_size)`, `optional`):\n            Sequence of hidden-states at the output of the last layer of the encoder. Used in the cross-attention if\n            the model is configured as a decoder.\n        encoder_attention_mask (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length)`, `optional`):\n            Mask to avoid performing attention on the padding token indices of the encoder input. This mask is used in\n            the cross-attention if the model is configured as a decoder. Mask values selected in ``[0, 1]``:\n            - 1 for tokens that are **not masked**,\n            - 0 for tokens that are **masked**.\n        labels (:obj:`torch.LongTensor` of shape :obj:`(batch_size, sequence_length)`, `optional`):\n            Labels for computing the left-to-right language modeling loss (next word prediction). Indices should be in\n            ``[-100, 0, ..., config.vocab_size]`` (see ``input_ids`` docstring) Tokens with indices set to ``-100`` are\n            ignored (masked), the loss is only computed for the tokens with labels n ``[0, ..., config.vocab_size]``\n        past_key_values (:obj:`tuple(tuple(torch.FloatTensor))` of length :obj:`config.n_layers` with each tuple having 4 tensors of shape :obj:`(batch_size, num_heads, sequence_length - 1, embed_size_per_head)`):\n            Contains precomputed key and value hidden states of the attention blocks. Can be used to speed up decoding.\n            If :obj:`past_key_values` are used, the user can optionally input only the last :obj:`decoder_input_ids`\n            (those that don't have their past key value states given to this model) of shape :obj:`(batch_size, 1)`\n            instead of all :obj:`decoder_input_ids` of shape :obj:`(batch_size, sequence_length)`.\n        use_cache (:obj:`bool`, `optional`):\n            If set to :obj:`True`, :obj:`past_key_values` key value states are returned and can be used to speed up\n            decoding (see :obj:`past_key_values`).\n\n        Returns:\n        Example::\n            >>> from transformers import BertTokenizer, BertLMHeadModel, BertConfig\n            >>> import torch\n            >>> tokenizer = BertTokenizer.from_pretrained('bert-base-cased')\n            >>> config = BertConfig.from_pretrained(\"bert-base-cased\")\n            >>> model = BertLMHeadModel.from_pretrained('bert-base-cased', config=config)\n            >>> inputs = tokenizer(\"Hello, my dog is cute\", return_tensors=\"pt\")\n            >>> outputs = model(**inputs)\n            >>> prediction_logits = outputs.logits\n\n        \"\"\"\n        return_dict = (\n            return_dict if return_dict is not None else self.config.use_return_dict\n        )\n        if labels is not None:\n            use_cache = False\n\n        outputs = self.bert(\n            input_ids,\n            attention_mask=attention_mask,\n            position_ids=position_ids,\n            head_mask=head_mask,\n            inputs_embeds=inputs_embeds,\n            encoder_hidden_states=encoder_hidden_states,\n            encoder_attention_mask=encoder_attention_mask,\n            past_key_values=past_key_values,\n            use_cache=use_cache,\n            output_attentions=output_attentions,\n            output_hidden_states=output_hidden_states,\n            return_dict=return_dict,\n            is_decoder=is_decoder,\n            mode=mode,\n        )\n\n        sequence_output = outputs[0]\n        prediction_scores = self.cls(sequence_output)\n\n        if return_logits:\n            return prediction_scores[:, :-1, :].contiguous()\n\n        lm_loss = None\n        if labels is not None:\n            # we are doing next-token prediction; shift prediction scores and input ids by one\n            shifted_prediction_scores = prediction_scores[:, :-1, :].contiguous()\n            labels = labels[:, 1:].contiguous()\n            loss_fct = CrossEntropyLoss(reduction=reduction, label_smoothing=0.1)\n            lm_loss = loss_fct(\n                shifted_prediction_scores.view(-1, self.config.vocab_size),\n                labels.view(-1),\n            )\n            if reduction == \"none\":\n                lm_loss = lm_loss.view(prediction_scores.size(0), -1).sum(1)\n\n        if not return_dict:\n            output = (prediction_scores,) + outputs[2:]\n            return ((lm_loss,) + output) if lm_loss is not None else output\n\n        return CausalLMOutputWithCrossAttentions(\n            loss=lm_loss,\n            logits=prediction_scores,\n            past_key_values=outputs.past_key_values,\n            hidden_states=outputs.hidden_states,\n            attentions=outputs.attentions,\n            cross_attentions=outputs.cross_attentions,\n        )\n\n    def prepare_inputs_for_generation(\n        self, input_ids, past=None, attention_mask=None, **model_kwargs\n    ):\n        input_shape = input_ids.shape\n        # if model is used as a decoder in encoder-decoder model, the decoder attention mask is created on the fly\n        if attention_mask is None:\n            attention_mask = input_ids.new_ones(input_shape)\n\n        # cut decoder_input_ids if past is used\n        if past is not None:\n            input_ids = input_ids[:, -1:]\n\n        return {\n            \"input_ids\": input_ids,\n            \"attention_mask\": attention_mask,\n            \"past_key_values\": past,\n            \"encoder_hidden_states\": model_kwargs.get(\"encoder_hidden_states\", None),\n            \"encoder_attention_mask\": model_kwargs.get(\"encoder_attention_mask\", None),\n            \"is_decoder\": True,\n        }\n\n    def _reorder_cache(self, past, beam_idx):\n        reordered_past = ()\n        for layer_past in past:\n            reordered_past += (\n                tuple(\n                    past_state.index_select(0, beam_idx) for past_state in layer_past\n                ),\n            )\n        return reordered_past\n"
  },
  {
    "path": "service/image_captioning/api/im2txt/blip/vit.py",
    "content": "\"\"\"* Copyright (c) 2022, salesforce.com, inc.\n* All rights reserved.\n* SPDX-License-Identifier: BSD-3-Clause\n* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause\n* By Junnan Li\n* Based on timm code base\n* https://github.com/rwightman/pytorch-image-models/tree/master/timm\n\"\"\"\n\nfrom functools import partial\n\nimport torch\nfrom timm.models.helpers import adapt_input_conv\nfrom timm.models.layers import DropPath, trunc_normal_\nfrom timm.models.vision_transformer import PatchEmbed, resize_pos_embed\nfrom torch import nn\n\n\nclass Mlp(nn.Module):\n    \"\"\"MLP as used in Vision Transformer, MLP-Mixer and related networks\"\"\"\n\n    def __init__(\n        self,\n        in_features,\n        hidden_features=None,\n        out_features=None,\n        act_layer=nn.GELU,\n        drop=0.0,\n    ):\n        super().__init__()\n        out_features = out_features or in_features\n        hidden_features = hidden_features or in_features\n        self.fc1 = nn.Linear(in_features, hidden_features)\n        self.act = act_layer()\n        self.fc2 = nn.Linear(hidden_features, out_features)\n        self.drop = nn.Dropout(drop)\n\n    def forward(self, x):\n        x = self.fc1(x)\n        x = self.act(x)\n        x = self.drop(x)\n        x = self.fc2(x)\n        x = self.drop(x)\n        return x\n\n\nclass Attention(nn.Module):\n    def __init__(\n        self,\n        dim,\n        num_heads=8,\n        qkv_bias=False,\n        qk_scale=None,\n        attn_drop=0.0,\n        proj_drop=0.0,\n    ):\n        super().__init__()\n        self.num_heads = num_heads\n        head_dim = dim // num_heads\n        # NOTE scale factor was wrong in my original version, can set manually to be compat with prev weights\n        self.scale = qk_scale or head_dim**-0.5\n        self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)\n        self.attn_drop = nn.Dropout(attn_drop)\n        self.proj = nn.Linear(dim, dim)\n        self.proj_drop = nn.Dropout(proj_drop)\n        self.attn_gradients = None\n        self.attention_map = None\n\n    def save_attn_gradients(self, attn_gradients):\n        self.attn_gradients = attn_gradients\n\n    def get_attn_gradients(self):\n        return self.attn_gradients\n\n    def save_attention_map(self, attention_map):\n        self.attention_map = attention_map\n\n    def get_attention_map(self):\n        return self.attention_map\n\n    def forward(self, x, register_hook=False):\n        B, N, C = x.shape\n        qkv = (\n            self.qkv(x)\n            .reshape(B, N, 3, self.num_heads, C // self.num_heads)\n            .permute(2, 0, 3, 1, 4)\n        )\n        q, k, v = (\n            qkv[0],\n            qkv[1],\n            qkv[2],\n        )  # make torchscript happy (cannot use tensor as tuple)\n\n        attn = (q @ k.transpose(-2, -1)) * self.scale\n        attn = attn.softmax(dim=-1)\n        attn = self.attn_drop(attn)\n\n        if register_hook:\n            self.save_attention_map(attn)\n            attn.register_hook(self.save_attn_gradients)\n\n        x = (attn @ v).transpose(1, 2).reshape(B, N, C)\n        x = self.proj(x)\n        x = self.proj_drop(x)\n        return x\n\n\nclass Block(nn.Module):\n    def __init__(\n        self,\n        dim,\n        num_heads,\n        mlp_ratio=4.0,\n        qkv_bias=False,\n        qk_scale=None,\n        drop=0.0,\n        attn_drop=0.0,\n        drop_path=0.0,\n        act_layer=nn.GELU,\n        norm_layer=nn.LayerNorm,\n    ):\n        super().__init__()\n        self.norm1 = norm_layer(dim)\n        self.attn = Attention(\n            dim,\n            num_heads=num_heads,\n            qkv_bias=qkv_bias,\n            qk_scale=qk_scale,\n            attn_drop=attn_drop,\n            proj_drop=drop,\n        )\n        # NOTE: drop path for stochastic depth, we shall see if this is better than dropout here\n        self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity()\n        self.norm2 = norm_layer(dim)\n        mlp_hidden_dim = int(dim * mlp_ratio)\n        self.mlp = Mlp(\n            in_features=dim,\n            hidden_features=mlp_hidden_dim,\n            act_layer=act_layer,\n            drop=drop,\n        )\n\n    def forward(self, x, register_hook=False):\n        x = x + self.drop_path(self.attn(self.norm1(x), register_hook=register_hook))\n        x = x + self.drop_path(self.mlp(self.norm2(x)))\n        return x\n\n\nclass VisionTransformer(nn.Module):\n    \"\"\"Vision Transformer\n    A PyTorch impl of : `An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale`  -\n        https://arxiv.org/abs/2010.11929\n    \"\"\"\n\n    def __init__(\n        self,\n        img_size=224,\n        patch_size=16,\n        in_chans=3,\n        num_classes=1000,\n        embed_dim=768,\n        depth=12,\n        num_heads=12,\n        mlp_ratio=4.0,\n        qkv_bias=True,\n        qk_scale=None,\n        representation_size=None,\n        drop_rate=0.0,\n        attn_drop_rate=0.0,\n        drop_path_rate=0.0,\n        norm_layer=None,\n        ckpt_layer=0,\n    ):\n        \"\"\"Args:\n        img_size (int, tuple): input image size\n        patch_size (int, tuple): patch size\n        in_chans (int): number of input channels\n        num_classes (int): number of classes for classification head\n        embed_dim (int): embedding dimension\n        depth (int): depth of transformer\n        num_heads (int): number of attention heads\n        mlp_ratio (int): ratio of mlp hidden dim to embedding dim\n        qkv_bias (bool): enable bias for qkv if True\n        qk_scale (float): override default qk scale of head_dim ** -0.5 if set\n        representation_size (Optional[int]): enable and set representation layer (pre-logits) to this value if set\n        drop_rate (float): dropout rate\n        attn_drop_rate (float): attention dropout rate\n        drop_path_rate (float): stochastic depth rate\n        norm_layer: (nn.Module): normalization layer\n\n        \"\"\"\n        super().__init__()\n        self.num_features = self.embed_dim = (\n            embed_dim  # num_features for consistency with other models\n        )\n        norm_layer = norm_layer or partial(nn.LayerNorm, eps=1e-6)\n\n        self.patch_embed = PatchEmbed(\n            img_size=img_size,\n            patch_size=patch_size,\n            in_chans=in_chans,\n            embed_dim=embed_dim,\n        )\n\n        num_patches = self.patch_embed.num_patches\n\n        self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))\n        self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim))\n        self.pos_drop = nn.Dropout(p=drop_rate)\n\n        dpr = [\n            x.item() for x in torch.linspace(0, drop_path_rate, depth)\n        ]  # stochastic depth decay rule\n        self.blocks = nn.ModuleList(\n            [\n                Block(\n                    dim=embed_dim,\n                    num_heads=num_heads,\n                    mlp_ratio=mlp_ratio,\n                    qkv_bias=qkv_bias,\n                    qk_scale=qk_scale,\n                    drop=drop_rate,\n                    attn_drop=attn_drop_rate,\n                    drop_path=dpr[i],\n                    norm_layer=norm_layer,\n                )\n                for i in range(depth)\n            ]\n        )\n        self.norm = norm_layer(embed_dim)\n\n        trunc_normal_(self.pos_embed, std=0.02)\n        trunc_normal_(self.cls_token, std=0.02)\n        self.apply(self._init_weights)\n\n    def _init_weights(self, m):\n        if isinstance(m, nn.Linear):\n            trunc_normal_(m.weight, std=0.02)\n            if isinstance(m, nn.Linear) and m.bias is not None:\n                nn.init.constant_(m.bias, 0)\n        elif isinstance(m, nn.LayerNorm):\n            nn.init.constant_(m.bias, 0)\n            nn.init.constant_(m.weight, 1.0)\n\n    @torch.jit.ignore\n    def no_weight_decay(self):\n        return {\"pos_embed\", \"cls_token\"}\n\n    def forward(self, x, register_blk=-1):\n        B = x.shape[0]\n        x = self.patch_embed(x)\n\n        cls_tokens = self.cls_token.expand(\n            B, -1, -1\n        )  # stole cls_tokens impl from Phil Wang, thanks\n        x = torch.cat((cls_tokens, x), dim=1)\n\n        x = x + self.pos_embed[:, : x.size(1), :]\n        x = self.pos_drop(x)\n\n        for i, blk in enumerate(self.blocks):\n            x = blk(x, register_blk == i)\n        x = self.norm(x)\n\n        return x\n\n    @torch.jit.ignore()\n    def load_pretrained(self, checkpoint_path, prefix=\"\"):\n        _load_weights(self, checkpoint_path, prefix)\n\n\n@torch.no_grad()\ndef _load_weights(model: VisionTransformer, checkpoint_path: str, prefix: str = \"\"):\n    \"\"\"Load weights from .npz checkpoints for official Google Brain Flax implementation\"\"\"\n    import numpy as np\n\n    def _n2p(w, t=True):\n        if w.ndim == 4 and w.shape[0] == w.shape[1] == w.shape[2] == 1:\n            w = w.flatten()\n        if t:\n            if w.ndim == 4:\n                w = w.transpose([3, 2, 0, 1])\n            elif w.ndim == 3:\n                w = w.transpose([2, 0, 1])\n            elif w.ndim == 2:\n                w = w.transpose([1, 0])\n        return torch.from_numpy(w)\n\n    w = np.load(checkpoint_path)\n    if not prefix and \"opt/target/embedding/kernel\" in w:\n        prefix = \"opt/target/\"\n\n    if hasattr(model.patch_embed, \"backbone\"):\n        # hybrid\n        backbone = model.patch_embed.backbone\n        stem_only = not hasattr(backbone, \"stem\")\n        stem = backbone if stem_only else backbone.stem\n        stem.conv.weight.copy_(\n            adapt_input_conv(\n                stem.conv.weight.shape[1], _n2p(w[f\"{prefix}conv_root/kernel\"])\n            )\n        )\n        stem.norm.weight.copy_(_n2p(w[f\"{prefix}gn_root/scale\"]))\n        stem.norm.bias.copy_(_n2p(w[f\"{prefix}gn_root/bias\"]))\n        if not stem_only:\n            for i, stage in enumerate(backbone.stages):\n                for j, block in enumerate(stage.blocks):\n                    bp = f\"{prefix}block{i + 1}/unit{j + 1}/\"\n                    for r in range(3):\n                        getattr(block, f\"conv{r + 1}\").weight.copy_(\n                            _n2p(w[f\"{bp}conv{r + 1}/kernel\"])\n                        )\n                        getattr(block, f\"norm{r + 1}\").weight.copy_(\n                            _n2p(w[f\"{bp}gn{r + 1}/scale\"])\n                        )\n                        getattr(block, f\"norm{r + 1}\").bias.copy_(\n                            _n2p(w[f\"{bp}gn{r + 1}/bias\"])\n                        )\n                    if block.downsample is not None:\n                        block.downsample.conv.weight.copy_(\n                            _n2p(w[f\"{bp}conv_proj/kernel\"])\n                        )\n                        block.downsample.norm.weight.copy_(\n                            _n2p(w[f\"{bp}gn_proj/scale\"])\n                        )\n                        block.downsample.norm.bias.copy_(_n2p(w[f\"{bp}gn_proj/bias\"]))\n        embed_conv_w = _n2p(w[f\"{prefix}embedding/kernel\"])\n    else:\n        embed_conv_w = adapt_input_conv(\n            model.patch_embed.proj.weight.shape[1], _n2p(w[f\"{prefix}embedding/kernel\"])\n        )\n    model.patch_embed.proj.weight.copy_(embed_conv_w)\n    model.patch_embed.proj.bias.copy_(_n2p(w[f\"{prefix}embedding/bias\"]))\n    model.cls_token.copy_(_n2p(w[f\"{prefix}cls\"], t=False))\n    pos_embed_w = _n2p(w[f\"{prefix}Transformer/posembed_input/pos_embedding\"], t=False)\n    if pos_embed_w.shape != model.pos_embed.shape:\n        pos_embed_w = resize_pos_embed(  # resize pos embedding when different size from pretrained weights\n            pos_embed_w,\n            model.pos_embed,\n            getattr(model, \"num_tokens\", 1),\n            model.patch_embed.grid_size,\n        )\n    model.pos_embed.copy_(pos_embed_w)\n    model.norm.weight.copy_(_n2p(w[f\"{prefix}Transformer/encoder_norm/scale\"]))\n    model.norm.bias.copy_(_n2p(w[f\"{prefix}Transformer/encoder_norm/bias\"]))\n    for i, block in enumerate(model.blocks.children()):\n        block_prefix = f\"{prefix}Transformer/encoderblock_{i}/\"\n        mha_prefix = block_prefix + \"MultiHeadDotProductAttention_1/\"\n        block.norm1.weight.copy_(_n2p(w[f\"{block_prefix}LayerNorm_0/scale\"]))\n        block.norm1.bias.copy_(_n2p(w[f\"{block_prefix}LayerNorm_0/bias\"]))\n        block.attn.qkv.weight.copy_(\n            torch.cat(\n                [\n                    _n2p(w[f\"{mha_prefix}{n}/kernel\"], t=False).flatten(1).T\n                    for n in (\"query\", \"key\", \"value\")\n                ]\n            )\n        )\n        block.attn.qkv.bias.copy_(\n            torch.cat(\n                [\n                    _n2p(w[f\"{mha_prefix}{n}/bias\"], t=False).reshape(-1)\n                    for n in (\"query\", \"key\", \"value\")\n                ]\n            )\n        )\n        block.attn.proj.weight.copy_(_n2p(w[f\"{mha_prefix}out/kernel\"]).flatten(1))\n        block.attn.proj.bias.copy_(_n2p(w[f\"{mha_prefix}out/bias\"]))\n        for r in range(2):\n            getattr(block.mlp, f\"fc{r + 1}\").weight.copy_(\n                _n2p(w[f\"{block_prefix}MlpBlock_3/Dense_{r}/kernel\"])\n            )\n            getattr(block.mlp, f\"fc{r + 1}\").bias.copy_(\n                _n2p(w[f\"{block_prefix}MlpBlock_3/Dense_{r}/bias\"])\n            )\n        block.norm2.weight.copy_(_n2p(w[f\"{block_prefix}LayerNorm_2/scale\"]))\n        block.norm2.bias.copy_(_n2p(w[f\"{block_prefix}LayerNorm_2/bias\"]))\n\n\ndef interpolate_pos_embed(pos_embed_checkpoint, visual_encoder):\n    # interpolate position embedding\n    embedding_size = pos_embed_checkpoint.shape[-1]\n    num_patches = visual_encoder.patch_embed.num_patches\n    num_extra_tokens = visual_encoder.pos_embed.shape[-2] - num_patches\n    # height (== width) for the checkpoint position embedding\n    orig_size = int((pos_embed_checkpoint.shape[-2] - num_extra_tokens) ** 0.5)\n    # height (== width) for the new position embedding\n    new_size = int(num_patches**0.5)\n\n    if orig_size != new_size:\n        # class_token and dist_token are kept unchanged\n        extra_tokens = pos_embed_checkpoint[:, :num_extra_tokens]\n        # only the position tokens are interpolated\n        pos_tokens = pos_embed_checkpoint[:, num_extra_tokens:]\n        pos_tokens = pos_tokens.reshape(\n            -1, orig_size, orig_size, embedding_size\n        ).permute(0, 3, 1, 2)\n        pos_tokens = torch.nn.functional.interpolate(\n            pos_tokens, size=(new_size, new_size), mode=\"bicubic\", align_corners=False\n        )\n        pos_tokens = pos_tokens.permute(0, 2, 3, 1).flatten(1, 2)\n        new_pos_embed = torch.cat((extra_tokens, pos_tokens), dim=1)\n        print(\"reshape position embedding from %d to %d\" % (orig_size**2, new_size**2))\n\n        return new_pos_embed\n    else:\n        return pos_embed_checkpoint\n"
  },
  {
    "path": "service/image_captioning/api/im2txt/build_vocab.py",
    "content": "import pickle\nfrom collections import Counter\n\nfrom tqdm import tqdm\n\ncaption_path = \"api/im2txt/data/annotations/captions_train2014.json\"\nvocab_path = \"api/im2txt/data/vocab.pkl\"\nthreshold = 4\n\n\nclass Vocabulary:\n    \"\"\"Simple vocabulary wrapper.\"\"\"\n\n    def __init__(self):\n        self.word2idx = {}\n        self.idx2word = {}\n        self.idx = 0\n\n    def add_word(self, word):\n        if word not in self.word2idx:\n            self.word2idx[word] = self.idx\n            self.idx2word[self.idx] = word\n            self.idx += 1\n\n    def __call__(self, word):\n        if word not in self.word2idx:\n            return self.word2idx[\"<unk>\"]\n        return self.word2idx[word]\n\n    def __len__(self):\n        return len(self.word2idx)\n\n\ndef build_vocab(json, threshold):\n    import nltk\n    from pycocotools.coco import COCO\n\n    \"\"\"Build a simple vocabulary wrapper.\"\"\"\n    coco = COCO(json)\n    counter = Counter()\n    ids = coco.anns.keys()\n    for i, id in tqdm(enumerate(ids)):\n        caption = str(coco.anns[id][\"caption\"])\n        tokens = nltk.tokenize.word_tokenize(caption.lower())\n        counter.update(tokens)\n\n    #         if (i+1) % 1000 == 0:\n    #             print(\"[{}/{}] Tokenized the captions.\".format(i+1, len(ids)))\n\n    # If the word frequency is less than 'threshold', then the word is discarded.\n    words = [word for word, cnt in counter.items() if cnt >= threshold]\n\n    # Create a vocab wrapper and add some special tokens.\n    vocab = Vocabulary()\n    vocab.add_word(\"<pad>\")\n    vocab.add_word(\"<start>\")\n    vocab.add_word(\"<end>\")\n    vocab.add_word(\"<unk>\")\n\n    # Add the words to the vocabulary.\n    for i, word in enumerate(words):\n        vocab.add_word(word)\n    return vocab\n\n\ndef main():\n    vocab = build_vocab(json=caption_path, threshold=threshold)\n    with open(vocab_path, \"wb\") as f:\n        pickle.dump(vocab, f)\n    print(f\"Total vocabulary size: {len(vocab)}\")\n    print(f\"Saved the vocabulary wrapper to '{vocab_path}'\")\n"
  },
  {
    "path": "service/image_captioning/api/im2txt/data_loader.py",
    "content": "import os\n\nimport torch\nfrom PIL import Image\nfrom torch.utils import data\n\n\nclass CocoDataset(data.Dataset):\n    \"\"\"COCO Custom Dataset compatible with torch.utils.data.DataLoader.\"\"\"\n\n    def __init__(self, root, json, vocab, transform=None):\n        \"\"\"Set the path for images, captions and vocabulary wrapper.\n\n        Args:\n            root: image directory.\n            json: coco annotation file path.\n            vocab: vocabulary wrapper.\n            transform: image transformer.\n\n        \"\"\"\n        self.root = root\n\n        from pycocotools.coco import COCO\n\n        self.coco = COCO(json)\n        self.ids = list(self.coco.anns.keys())\n        self.vocab = vocab\n        self.transform = transform\n\n    def __getitem__(self, index):\n        \"\"\"Returns one data pair (image and caption).\"\"\"\n        coco = self.coco\n        vocab = self.vocab\n        ann_id = self.ids[index]\n        caption = coco.anns[ann_id][\"caption\"]\n        img_id = coco.anns[ann_id][\"image_id\"]\n        path = coco.loadImgs(img_id)[0][\"file_name\"]\n\n        image = Image.open(os.path.join(self.root, path)).convert(\"RGB\")\n        if self.transform is not None:\n            image = self.transform(image)\n\n        # Convert caption (string) to word ids.\n\n        import nltk\n\n        tokens = nltk.tokenize.word_tokenize(str(caption).lower())\n        caption = []\n        caption.append(vocab(\"<start>\"))\n        caption.extend([vocab(token) for token in tokens])\n        caption.append(vocab(\"<end>\"))\n        target = torch.Tensor(caption)\n        return image, target\n\n    def __len__(self):\n        return len(self.ids)\n\n\ndef collate_fn(data):\n    \"\"\"Creates mini-batch tensors from the list of tuples (image, caption).\n\n    We should build custom collate_fn rather than using default collate_fn,\n    because merging caption (including padding) is not supported in default.\n\n    Args:\n        data: list of tuple (image, caption).\n            - image: torch tensor of shape (3, 256, 256).\n            - caption: torch tensor of shape (?); variable length.\n\n    Returns:\n        images: torch tensor of shape (batch_size, 3, 256, 256).\n        targets: torch tensor of shape (batch_size, padded_length).\n        lengths: list; valid length for each padded caption.\n\n    \"\"\"\n    # Sort a data list by caption length (descending order).\n    data.sort(key=lambda x: len(x[1]), reverse=True)\n    images, captions = zip(*data)\n\n    # Merge images (from tuple of 3D tensor to 4D tensor).\n    images = torch.stack(images, 0)\n\n    # Merge captions (from tuple of 1D tensor to 2D tensor).\n    lengths = [len(cap) for cap in captions]\n    targets = torch.zeros(len(captions), max(lengths)).long()\n    for i, cap in enumerate(captions):\n        end = lengths[i]\n        targets[i, :end] = cap[:end]\n    return images, targets, lengths\n\n\ndef get_loader(root, json, vocab, transform, batch_size, shuffle, num_workers):\n    \"\"\"Returns torch.utils.data.DataLoader for custom coco dataset.\"\"\"\n    # COCO caption dataset\n    coco = CocoDataset(root=root, json=json, vocab=vocab, transform=transform)\n\n    # Data loader for COCO dataset\n    # This will return (images, captions, lengths) for each iteration.\n    # images: a tensor of shape (batch_size, 3, 224, 224).\n    # captions: a tensor of shape (batch_size, padded_length).\n    # lengths: a list indicating valid length for each caption. length is (batch_size).\n    data_loader = torch.utils.data.DataLoader(\n        dataset=coco,\n        batch_size=batch_size,\n        shuffle=shuffle,\n        num_workers=num_workers,\n        collate_fn=collate_fn,\n    )\n    return data_loader\n"
  },
  {
    "path": "service/image_captioning/api/im2txt/model.py",
    "content": "import torch\nfrom torch import nn\nfrom torchvision import models\n\n\nclass EncoderCNN(nn.Module):\n    def __init__(self, embed_size):\n        \"\"\"Load the pretrained ResNet-152 and replace top fc layer.\"\"\"\n        super(EncoderCNN, self).__init__()\n        resnet = models.resnet152(pretrained=True)\n        modules = list(resnet.children())[:-1]  # delete the last fc layer.\n        self.resnet = nn.Sequential(*modules)\n        self.linear = nn.Linear(resnet.fc.in_features, embed_size)\n        self.bn = nn.BatchNorm1d(embed_size, momentum=0.01)\n\n    def forward(self, images):\n        \"\"\"Extract feature vectors from input images.\"\"\"\n        with torch.no_grad():\n            features = self.resnet(images)\n        features = features.reshape(features.size(0), -1)\n        features = self.bn(self.linear(features))\n        return features\n\n\nclass DecoderRNN(nn.Module):\n    def __init__(\n        self, embed_size, hidden_size, vocab_size, num_layers, max_seq_length=20\n    ):\n        \"\"\"Set the hyper-parameters and build the layers.\"\"\"\n        super(DecoderRNN, self).__init__()\n        self.embed = nn.Embedding(vocab_size, embed_size)\n        self.lstm = nn.LSTM(embed_size, hidden_size, num_layers, batch_first=True)\n        self.linear = nn.Linear(hidden_size, vocab_size)\n        self.max_seg_length = max_seq_length\n\n    def forward(self, features):\n        \"\"\"Generate captions for given image features using greedy search.\"\"\"\n        sampled_ids = []\n        states = None\n        inputs = features.unsqueeze(1)\n        for i in range(self.max_seg_length):\n            hiddens, states = self.lstm(\n                inputs, states\n            )  # hiddens: (batch_size, 1, hidden_size)\n            outputs = self.linear(\n                hiddens.squeeze(1)\n            )  # outputs:  (batch_size, vocab_size)\n            _, predicted = outputs.max(1)  # predicted: (batch_size)\n            sampled_ids.append(predicted)\n            inputs = self.embed(predicted)  # inputs: (batch_size, embed_size)\n            inputs = inputs.unsqueeze(1)  # inputs: (batch_size, 1, embed_size)\n        sampled_ids = torch.stack(\n            sampled_ids, 1\n        )  # sampled_ids: (batch_size, max_seq_length)\n        return sampled_ids\n"
  },
  {
    "path": "service/image_captioning/api/im2txt/resize.py",
    "content": "import os\n\nfrom PIL import Image\n\nimage_dir = \"api/im2txt/data/train2014/\"\noutput_dir = \"api/im2txt/data/resized2014/\"\nimage_size = 256\n\n\ndef resize_image(image, size):\n    \"\"\"Resize an image to the given size.\"\"\"\n    return image.resize(size, Image.ANTIALIAS)\n\n\ndef resize_images(image_dir, output_dir, size):\n    \"\"\"Resize the images in 'image_dir' and save into 'output_dir'.\"\"\"\n    if not os.path.exists(output_dir):\n        os.makedirs(output_dir)\n\n    images = os.listdir(image_dir)\n    num_images = len(images)\n    for i, image in enumerate(images):\n        with open(os.path.join(image_dir, image), \"r+b\") as f:\n            with Image.open(f) as img:\n                img = resize_image(img, size)\n                img.save(os.path.join(output_dir, image), img.format)\n        if (i + 1) % 100 == 0:\n            print(\n                f\"[{i + 1}/{num_images}] Resized the images and saved into '{output_dir}'.\"\n            )\n\n\ndef main():\n    resize_images(image_dir, output_dir, [image_size, image_size])\n"
  },
  {
    "path": "service/image_captioning/api/im2txt/sample.py",
    "content": "import os\nimport pickle\n\n\nimport torch\nfrom PIL import Image\nfrom torchvision import transforms\nfrom torchvision.transforms.functional import InterpolationMode\n\nfrom api.im2txt.blip.blip import blip_decoder\nfrom api.im2txt.model import DecoderRNN, EncoderCNN\n\nblip_image_size = 384\n\nembed_size = 256\nhidden_size = 512\nnum_layers = 1\n\nim2txt_models_path = \"/protected_media/data_models/im2txt\"\n\nblip_models_path = \"/protected_media/data_models/blip\"\n\nencoder_path = os.path.join(im2txt_models_path, \"models\", \"encoder-10-1000.ckpt\")\ndecoder_path = os.path.join(im2txt_models_path, \"models\", \"decoder-10-1000.ckpt\")\nvocab_path = os.path.join(im2txt_models_path, \"data\", \"vocab.pkl\")\n\n\n\nblip_model_url = os.path.join(blip_models_path, \"model_base_capfilt_large.pth\")\nblip_config_url = os.path.join(blip_models_path, \"med_config.json\")\n\n\nclass Im2txt:\n    def __init__(\n        self,\n        device=torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\"),\n        blip=False,\n    ):\n        self._instance = self\n        self.encoder = None\n        self.decoder = None\n        self.vocab = None\n        self.device = device\n        self.blip = blip\n        self.model = None\n\n    def load_image(self, image_path, transform=None):\n        image = Image.open(image_path)\n        # Check if the image has 3 channels (RGB)\n        if image.mode != \"RGB\":\n            # Handle grayscale or other modes here (e.g., convert to RGB)\n            image = image.convert(\"RGB\")\n\n        if transform is not None:\n            image = transform(image).unsqueeze(0)\n\n        return image\n\n    def load_models(self, onnx=False):\n        if self.encoder is not None or self.model is not None:\n            return\n\n        if self.blip:\n            self.model = blip_decoder(\n                pretrained=blip_model_url,\n                image_size=blip_image_size,\n                vit=\"base\",\n                med_config=blip_config_url,\n            )\n            self.model.eval()\n            self.model.to(self.device)\n            return\n\n        with open(vocab_path, \"rb\") as f:\n            self.vocab = pickle.load(f)\n\n        # Build models\n        self.encoder = EncoderCNN(\n            embed_size\n        ).eval()  # eval mode (batchnorm uses moving mean/variance)\n        self.decoder = DecoderRNN(\n            embed_size, hidden_size, len(self.vocab), num_layers\n        )\n        self.encoder = self.encoder.to(self.device)\n        self.decoder = self.decoder.to(self.device)\n\n        # Load the trained model parameters\n        self.encoder.load_state_dict(\n            torch.load(encoder_path, map_location=self.device)\n        )\n        self.decoder.load_state_dict(\n            torch.load(decoder_path, map_location=self.device)\n        )\n\n        # self.encoder = torch.compile(self.encoder)\n        # self.decoder = torch.compile(self.decoder)\n\n    def unload_models(self):\n        del self.encoder\n        del self.decoder\n        del self.model\n        self.encoder = None\n        self.decoder = None\n        self.model = None\n\n    def generate_caption(\n        self,\n        image_path,\n        onnx=False,\n    ):\n        self.load_models(onnx=onnx)\n\n        transform = transforms.Compose(\n            [\n                transforms.Resize((224, 224), interpolation=InterpolationMode.BICUBIC),\n                transforms.ToTensor(),\n                transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),\n            ]\n        )\n\n        blip_transform = transforms.Compose(\n            [\n                transforms.Resize(\n                    (blip_image_size, blip_image_size),\n                    interpolation=InterpolationMode.BICUBIC,\n                ),\n                transforms.ToTensor(),\n                transforms.Normalize(\n                    (0.48145466, 0.4578275, 0.40821073),\n                    (0.26862954, 0.26130258, 0.27577711),\n                ),\n            ]\n        )\n\n        if self.blip:\n            image = self.load_image(image_path, blip_transform).to(self.device)\n            with torch.no_grad():\n                caption_blip = self.model.generate(\n                    image, sample=True, num_beams=3, max_length=50, min_length=10\n                )\n                return caption_blip[0]\n\n        # Prepare an image\n        image = self.load_image(image_path, transform)\n        image_tensor = image.to(self.device)\n        feature = self.encoder(image_tensor)\n        sampled_ids = self.decoder.forward(feature)\n        sampled_ids = (\n            sampled_ids[0].cpu().numpy()\n        )  # (1, max_seq_length) -> (max_seq_length)\n\n        # Convert word_ids to words\n        sampled_caption = []\n        for word_id in sampled_ids:\n            word = self.vocab.idx2word[word_id]\n            sampled_caption.append(word)\n            if word == \"<end>\":\n                break\n        sentence = \" \".join(sampled_caption)\n\n        return sentence\n\n\n"
  },
  {
    "path": "service/image_captioning/api/im2txt/train.py",
    "content": "import os\nimport pickle\n\nimport numpy as np\nimport torch\nfrom torch import nn\nfrom torch.nn.utils.rnn import pack_padded_sequence\nfrom torchvision import transforms\n\nfrom api.im2txt.data_loader import get_loader\nfrom api.im2txt.model import DecoderRNN, EncoderCNN\n\nmodel_path = \"api/im2txt/models/\"\ncrop_size = 224\nvocab_path = \"api/im2txt/data/vocab.pkl\"\nimage_dir = \"api/im2txt/data/resized2014/\"\ncaption_path = \"api/im2txt/data/annotations/captions_train2014.json\"\nlog_step = 10\nsave_step = 1000\nembed_size = 256\nhidden_size = 512\nnum_layers = 1\nnum_epochs = 5\nbatch_size = 128\nnum_workers = 2\nlearning_rate = 0.001\n\n\n# Device configuration\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\n\ndef main():\n    # Create model directory\n    if not os.path.exists(model_path):\n        os.makedirs(model_path)\n\n    # Image preprocessing, normalization for the pretrained resnet\n    transform = transforms.Compose(\n        [\n            transforms.RandomCrop(crop_size),\n            transforms.RandomHorizontalFlip(),\n            transforms.ToTensor(),\n            transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),\n        ]\n    )\n\n    # Load vocabulary wrapper\n    with open(vocab_path, \"rb\") as f:\n        vocab = pickle.load(f)\n\n    # Build data loader\n    data_loader = get_loader(\n        image_dir,\n        caption_path,\n        vocab,\n        transform,\n        batch_size,\n        shuffle=True,\n        num_workers=num_workers,\n    )\n\n    # Build the models\n    encoder = EncoderCNN(embed_size).to(device)\n    decoder = DecoderRNN(embed_size, hidden_size, len(vocab), num_layers).to(device)\n\n    # Loss and optimizer\n    criterion = nn.CrossEntropyLoss()\n    params = (\n        list(decoder.parameters())\n        + list(encoder.linear.parameters())\n        + list(encoder.bn.parameters())\n    )\n    optimizer = torch.optim.Adam(params, lr=learning_rate)\n\n    # Train the models\n    total_step = len(data_loader)\n    for epoch in range(num_epochs):\n        for i, (images, captions, lengths) in enumerate(data_loader):\n            # Set mini-batch dataset\n            images = images.to(device)\n            captions = captions.to(device)\n            targets = pack_padded_sequence(captions, lengths, batch_first=True)[0]\n\n            # Forward, backward and optimize\n            features = encoder(images)\n            outputs = decoder(features, captions, lengths)\n            loss = criterion(outputs, targets)\n            decoder.zero_grad()\n            encoder.zero_grad()\n            loss.backward()\n            optimizer.step()\n\n            # Print log info\n            if i % log_step == 0:\n                print(\n                    f\"Epoch [{epoch}/{num_epochs}], Step [{i}/{total_step}], Loss: {loss.item():.4f}, Perplexity: {np.exp(loss.item()):5.4f}\"\n                )\n\n            # Save the model checkpoints\n            if (i + 1) % save_step == 0:\n                torch.save(\n                    decoder.state_dict(),\n                    os.path.join(model_path, f\"decoder-{epoch + 1}-{i + 1}.ckpt\"),\n                )\n                torch.save(\n                    encoder.state_dict(),\n                    os.path.join(model_path, f\"encoder-{epoch + 1}-{i + 1}.ckpt\"),\n                )\n"
  },
  {
    "path": "service/image_captioning/main.py",
    "content": "import time\n\nimport gevent\nfrom flask import Flask, request\nfrom gevent.pywsgi import WSGIServer\n\nfrom api.im2txt.sample import Im2txt\n\napp = Flask(__name__)\n\nim2txt_instance = None\nlast_request_time = None\n\n\ndef log(message):\n    print(f\"image_captioning: {message}\")\n\n\n@app.route(\"/generate-caption\", methods=[\"POST\"])\ndef generate_caption():\n    global last_request_time\n    # Update last request time\n    last_request_time = time.time()\n\n    try:\n        data = request.get_json()\n        image_path = data[\"image_path\"]\n        onnx = data[\"onnx\"]\n        blip = data[\"blip\"]\n    except Exception as e:\n        print(str(e))\n        return \"\", 400\n\n    global im2txt_instance\n\n    if im2txt_instance is None:\n        im2txt_instance = Im2txt(blip=blip)\n\n    return {\n        \"caption\": im2txt_instance.generate_caption(image_path=image_path, onnx=onnx)\n    }, 201\n\n\n@app.route(\"/unload-model\", methods=[\"GET\"])\ndef unload_model():\n    global im2txt_instance\n    im2txt_instance.unload_models()\n    im2txt_instance = None\n    return \"\", 200\n\n\n@app.route(\"/health\", methods=[\"GET\"])\ndef health():\n    return {\"last_request_time\": last_request_time}, 200\n\n\nif __name__ == \"__main__\":\n    log(\"service starting\")\n    server = WSGIServer((\"0.0.0.0\", 8007), app)\n    server_thread = gevent.spawn(server.serve_forever)\n    gevent.joinall([server_thread])\n"
  },
  {
    "path": "service/llm/__init__.py",
    "content": ""
  },
  {
    "path": "service/llm/main.py",
    "content": "\"\"\"\nLLM Service for LibrePhotos\n\nThis service provides Large Language Model capabilities for image captioning and analysis.\n\nNote: CPU compatibility is checked by the main LibrePhotos application before starting this service.\nServices with incompatible CPUs will not be started to prevent performance issues.\n\nUsage:\n- Normal operation: python main.py (started by LibrePhotos service manager)\n\"\"\"\n\nimport gevent\nimport time\nfrom pathlib import Path\nfrom flask import Flask, request\nfrom gevent.pywsgi import WSGIServer\nfrom llama_cpp import Llama\n\napp = Flask(__name__)\n\n# Global model instance and last request time for health monitoring\nllm_model = None\ncurrent_model_path = None\nlast_request_time = None\n\n\ndef log(message):\n    print(f\"llm: {message}\")\n\n\ndef load_model(model_path, multimodal=False):\n    \"\"\"Load a model with optional multimodal support\"\"\"\n    global llm_model, current_model_path\n\n    if llm_model is None or current_model_path != model_path:\n        try:\n            log(f\"Loading model from {model_path}, multimodal: {multimodal}\")\n            if multimodal:\n                # For Moondream, we need to use the chat handler approach\n                from llama_cpp.llama_chat_format import MoondreamChatHandler\n\n                # Path to the mmproj file for Moondream\n                mmproj_path = \"/protected_media/data_models/moondream2-mmproj-f16.gguf\"\n\n                if not Path(mmproj_path).exists():\n                    raise Exception(f\"Moondream mmproj file not found at {mmproj_path}\")\n\n                log(f\"Loading Moondream chat handler with mmproj: {mmproj_path}\")\n                chat_handler = MoondreamChatHandler(clip_model_path=mmproj_path)\n\n                llm_model = Llama(\n                    model_path=model_path,\n                    chat_handler=chat_handler,\n                    n_ctx=2048,  # Increase context window for image processing\n                    verbose=False,\n                )\n            else:\n                # For text-only models\n                llm_model = Llama(model_path=model_path, verbose=False)\n\n            current_model_path = model_path\n            log(\"Model loaded successfully\")\n        except Exception as e:\n            log(f\"Error loading model: {str(e)}\")\n            raise\n\n\n@app.route(\"/generate\", methods=[\"POST\"])\ndef generate():\n    \"\"\"Unified endpoint for text and multimodal generation\"\"\"\n    global last_request_time\n    last_request_time = time.time()\n\n    try:\n        data = request.get_json()\n        image_data = data.get(\"image_data\")  # Now expects base64 data URI directly\n        prompt = data[\"prompt\"]\n        max_tokens = data.get(\"max_tokens\", 128)\n        model_path = data.get(\n            \"model_path\", \"/protected_media/data_models/moondream2-text-model-f16.gguf\"\n        )\n    except Exception as e:\n        log(f\"Error parsing request: {str(e)}\")\n        return \"\", 400\n\n    try:\n        if image_data:\n            # Multimodal prompt with image using Moondream\n            load_model(model_path, multimodal=True)\n\n            response = llm_model.create_chat_completion(\n                messages=[\n                    {\n                        \"role\": \"user\",\n                        \"content\": [\n                            {\"type\": \"text\", \"text\": prompt},\n                            {\"type\": \"image_url\", \"image_url\": {\"url\": image_data}},\n                        ],\n                    }\n                ],\n                max_tokens=max_tokens,\n                temperature=0.1,\n            )\n\n            response_text = response[\"choices\"][0][\"message\"][\"content\"]\n        else:\n            # Text-only prompt\n            load_model(model_path, multimodal=False)\n\n            output = llm_model(\n                prompt,\n                max_tokens=max_tokens,\n                stop=[\"Q:\", \"\\n\"],\n                echo=False,\n            )\n            response_text = (\n                output[\"choices\"][0][\"text\"] if \"choices\" in output else str(output)\n            )\n\n        log(\"Generated response\")\n        return {\"response\": response_text}, 201\n\n    except Exception as e:\n        log(f\"Error generating response: {str(e)}\")\n        return {\"error\": str(e)}, 500\n\n\n@app.route(\"/health\", methods=[\"GET\"])\ndef health():\n    return {\"status\": \"OK\", \"last_request_time\": last_request_time}, 200\n\n\nif __name__ == \"__main__\":\n    log(\"LLM service with multimodal support starting\")\n    log(\n        \"Note: CPU compatibility is verified by LibrePhotos service manager before startup\"\n    )\n\n    server = WSGIServer((\"0.0.0.0\", 8008), app)\n    server_thread = gevent.spawn(server.serve_forever)\n    gevent.joinall([server_thread])\n"
  },
  {
    "path": "service/tags/__init__.py",
    "content": ""
  },
  {
    "path": "service/tags/main.py",
    "content": "import time\n\nimport gevent\nfrom flask import Flask, request\nfrom gevent.pywsgi import WSGIServer\nfrom places365.places365 import Places365\nfrom siglip2.siglip2 import SigLIP2\n\napp = Flask(__name__)\n\nplaces365_instance = None\nsiglip2_instance = None\nlast_request_time = None\n\n\ndef log(message):\n    print(f\"tags: {message}\")\n\n\n@app.route(\"/generate-tags\", methods=[\"POST\"])\ndef generate_tags():\n    global last_request_time\n    last_request_time = time.time()\n\n    try:\n        data = request.get_json()\n        image_path = data[\"image_path\"]\n        confidence = data.get(\"confidence\", 0.4)\n        tagging_model = data.get(\"tagging_model\", \"places365\")\n    except Exception as e:\n        print(str(e))\n        return \"\", 400\n\n    if tagging_model == \"siglip2\":\n        global siglip2_instance\n        if siglip2_instance is None:\n            siglip2_instance = SigLIP2()\n        # SigLIP 2 uses cosine similarity (range -1 to 1), not probability scores.\n        # Always return the top 10 most relevant tags above a minimum threshold.\n        result = siglip2_instance.predict(image_path, threshold=0.05, max_tags=10)\n        return {\"tags\": result}, 201\n    else:\n        global places365_instance\n        if places365_instance is None:\n            places365_instance = Places365()\n        result = places365_instance.inference_places365(image_path, confidence)\n        return {\"tags\": result}, 201\n\n\n@app.route(\"/health\", methods=[\"GET\"])\ndef health():\n    return {\"last_request_time\": last_request_time}, 200\n\n\nif __name__ == \"__main__\":\n    log(\"service starting\")\n    server = WSGIServer((\"0.0.0.0\", 8011), app)\n    server_thread = gevent.spawn(server.serve_forever)\n    gevent.joinall([server_thread])\n"
  },
  {
    "path": "service/tags/places365/__init__.py",
    "content": ""
  },
  {
    "path": "service/tags/places365/places365.py",
    "content": "# PlacesCNN to predict the scene category, attribute, and class activation map in a single pass\n# by Bolei Zhou, sep 2, 2017\n# last modified date: Dec. 27, 2017, migrating everything to python36 and latest pytorch and torchvision\nimport os\n\nimport numpy as np\nimport torch\nfrom PIL import Image\nfrom places365 import wideresnet\nfrom torch.autograd import Variable as V\nfrom torch.nn import functional as F\nfrom torchvision import transforms as trn\n\n# import warnings\n\ntorch.nn.Module.dump_patches = True\ndir_places365_model = os.path.join(\n    \"/\", \"protected_media\", \"data_models\", \"places365\", \"model\"\n)\n\n\nclass Places365:\n    labels_and_model_are_load = False\n\n    def unload(self):\n        del self.model\n        del self.classes\n        del self.W_attribute\n        del self.labels_IO\n        del self.labels_attribute\n        del self.labels_and_model_are_load\n        self.model = None\n        self.classes = None\n        self.W_attribute = None\n        self.labels_IO = None\n        self.labels_attribute = None\n        self.labels_and_model_are_load = False\n\n    def load(self):\n        self.load_model()\n        self.load_labels()\n        self.labels_and_model_are_load = True\n\n    def load_model(self):\n        # this model has a last conv feature map as 14x14\n        def hook_feature(module, input, output):\n            self.features_blobs.append(np.squeeze(output.data.cpu().numpy()))\n\n        model_file = os.path.join(dir_places365_model, \"wideresnet18_places365.pth.tar\")\n        self.model = wideresnet.resnet18(num_classes=365)\n        checkpoint = torch.load(model_file, map_location=lambda storage, loc: storage)\n        state_dict = {\n            str.replace(k, \"module.\", \"\"): v\n            for k, v in checkpoint[\"state_dict\"].items()\n        }\n        self.model.load_state_dict(state_dict)\n        self.model.eval()\n        # hook the feature extractor\n        features_names = [\n            \"layer4\",\n            \"avgpool\",\n        ]  # this is the last conv layer of the resnet\n        for name in features_names:\n            self.model._modules.get(name).register_forward_hook(hook_feature)\n\n    def load_labels(self):\n        # prepare all the labels\n        # scene category relevant\n        file_path_category = os.path.join(\n            dir_places365_model, \"categories_places365.txt\"\n        )\n        self.classes = list()\n        with open(file_path_category) as class_file:\n            for line in class_file:\n                self.classes.append(line.strip().split(\" \")[0][3:])\n        self.classes = tuple(self.classes)\n\n        # indoor and outdoor relevant\n        file_path_IO = os.path.join(dir_places365_model, \"IO_places365.txt\")\n        with open(file_path_IO) as f:\n            lines = f.readlines()\n            self.labels_IO = []\n            for line in lines:\n                items = line.rstrip().split()\n                self.labels_IO.append(int(items[-1]) - 1)  # 0 is indoor, 1 is outdoor\n        self.labels_IO = np.array(self.labels_IO)\n\n        # scene attribute relevant\n        file_path_attribute = os.path.join(\n            dir_places365_model, \"labels_sunattribute.txt\"\n        )\n        with open(file_path_attribute) as f:\n            lines = f.readlines()\n            self.labels_attribute = [item.rstrip() for item in lines]\n\n        file_path_W = os.path.join(\n            dir_places365_model, \"W_sceneattribute_wideresnet18.npy\"\n        )\n        self.W_attribute = np.load(file_path_W)\n        self.labels_are_load = True\n\n    def returnTF(self):\n        # load the image transformer\n        tf = trn.Compose(\n            [\n                trn.Resize((224, 224)),\n                trn.ToTensor(),\n                trn.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),\n            ]\n        )\n        return tf\n\n    def remove_nonspace_separators(self, text):\n        return \" \".join(\" \".join(\" \".join(text.split(\"_\")).split(\"/\")).split(\"-\"))\n\n    def inference_places365(self, img_path, confidence):\n        \"\"\"@param img_path: path to the image to generate labels from\n        @param confidence: minimum confidence before an category is selected\n        @return: {'environment': 'indoor'/'outdoor', 'categories': [...], 'attributes': [...]}\n        \"\"\"\n        try:\n            if not self.labels_and_model_are_load:\n                self.load()\n\n            # load the model\n            self.features_blobs = []\n\n            # load the transformer\n            tf = self.returnTF()  # image transformer\n\n            # get the softmax weight\n            params = list(self.model.parameters())\n            weight_softmax = params[-2].data.numpy()\n            weight_softmax[weight_softmax < 0] = 0\n\n            # load the test image\n            # img_url = 'http://places2.csail.mit.edu/imgs/3.jpg'\n            # os.system('wget %s -q -O test.jpg' % img_url)\n            img = Image.open(img_path)\n            # Normalize the image for processing\n            input_img = V(tf(img).unsqueeze(0))\n\n            # forward pass\n            logit = self.model.forward(input_img)\n            h_x = F.softmax(logit, 1).data.squeeze()\n            probs, idx = h_x.sort(0, True)\n            probs = probs.numpy()\n            idx = idx.numpy()\n\n            res = {}\n\n            # output the IO prediction\n            # labels_IO[idx[:10]] returns a list of 0's and 1's: 0 -> inside, 1 -> outside\n            # Determine the mean to reach a consensus\n            io_image = np.mean(self.labels_IO[idx[:10]])\n            if io_image < 0.5:\n                res[\"environment\"] = \"indoor\"\n            else:\n                res[\"environment\"] = \"outdoor\"\n\n            # output the prediction of scene category\n            # idx[i] returns a index number for which class it corresponds to\n            # classes[idx[i]], thus returns the class name\n            # idx is sorted together with probs, with highest probabilities first\n            res[\"categories\"] = []\n            for i in range(0, 5):\n                if probs[i] > confidence:\n                    res[\"categories\"].append(\n                        self.remove_nonspace_separators(self.classes[idx[i]])\n                    )\n                else:\n                    break\n            # TODO Should be replaced with more meaningful tags in the future\n            # output the scene attributes\n            # This is something I don't quiet grasp yet\n            # Probs is not usable here anymore, we're not processing our input_image\n            # Take the dot product of out W_attribute model and the feature blobs\n            # And sort it along the -1 axis\n            # This results in idx_a, with the last elements the index numbers of attributes, we have the most confidence in\n            # Can't seem to get any confidence values, also all the attributes it detect are not really meaningful i.m.o.\n            responses_attribute = self.W_attribute.dot(self.features_blobs[1])\n            idx_a = np.argsort(responses_attribute)\n            res[\"attributes\"] = []\n            for i in range(-1, -10, -1):\n                res[\"attributes\"].append(\n                    self.remove_nonspace_separators(self.labels_attribute[idx_a[i]])\n                )\n\n            return res\n        except Exception as e:\n            print(\"tags: {}\".format(\"Error in Places365 inference\"))\n            raise e\n"
  },
  {
    "path": "service/tags/places365/wideresnet.py",
    "content": "import math\nimport os\n\nfrom torch import nn\n\nmodel_path = os.path.join(\n    \"/\", \"protected_media\", \"data_models\", \"resnet18-5c106cde.pth\"\n)\n\n\ndef conv3x3(in_planes, out_planes, stride=1):\n    \"\"\"3x3 convolution with padding\"\"\"\n    return nn.Conv2d(\n        in_planes, out_planes, kernel_size=3, stride=stride, padding=1, bias=False\n    )\n\n\nclass BasicBlock(nn.Module):\n    expansion = 1\n\n    def __init__(self, inplanes, planes, stride=1, downsample=None):\n        super(BasicBlock, self).__init__()\n        self.conv1 = conv3x3(inplanes, planes, stride)\n        self.bn1 = nn.BatchNorm2d(planes)\n        self.relu = nn.ReLU(inplace=True)\n        self.conv2 = conv3x3(planes, planes)\n        self.bn2 = nn.BatchNorm2d(planes)\n        self.downsample = downsample\n        self.stride = stride\n\n    def forward(self, x):\n        residual = x\n\n        out = self.conv1(x)\n        out = self.bn1(out)\n        out = self.relu(out)\n\n        out = self.conv2(out)\n        out = self.bn2(out)\n\n        if self.downsample is not None:\n            residual = self.downsample(x)\n\n        out += residual\n        out = self.relu(out)\n\n        return out\n\n\nclass ResNet(nn.Module):\n    def __init__(self, block, layers, num_classes=1000):\n        self.inplanes = 64\n        super(ResNet, self).__init__()\n        self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)\n        self.bn1 = nn.BatchNorm2d(64)\n        self.relu = nn.ReLU(inplace=True)\n        # self.maxpool = nn.MaxPool2d(kernel_size=3, stride=1, padding=1) # previous stride is 2\n        self.layer1 = self._make_layer(block, 64, layers[0])\n        self.layer2 = self._make_layer(block, 128, layers[1], stride=2)\n        self.layer3 = self._make_layer(block, 256, layers[2], stride=2)\n        self.layer4 = self._make_layer(block, 512, layers[3], stride=2)\n        self.avgpool = nn.AvgPool2d(14)\n        self.fc = nn.Linear(512 * block.expansion, num_classes)\n\n        for m in self.modules():\n            if isinstance(m, nn.Conv2d):\n                n = m.kernel_size[0] * m.kernel_size[1] * m.out_channels\n                m.weight.data.normal_(0, math.sqrt(2.0 / n))\n            elif isinstance(m, nn.BatchNorm2d):\n                m.weight.data.fill_(1)\n                m.bias.data.zero_()\n\n    def _make_layer(self, block, planes, blocks, stride=1):\n        downsample = None\n        if stride != 1 or self.inplanes != planes * block.expansion:\n            downsample = nn.Sequential(\n                nn.Conv2d(\n                    self.inplanes,\n                    planes * block.expansion,\n                    kernel_size=1,\n                    stride=stride,\n                    bias=False,\n                ),\n                nn.BatchNorm2d(planes * block.expansion),\n            )\n\n        layers = []\n        layers.append(block(self.inplanes, planes, stride, downsample))\n        self.inplanes = planes * block.expansion\n        for i in range(1, blocks):\n            layers.append(block(self.inplanes, planes))\n\n        return nn.Sequential(*layers)\n\n    def forward(self, x):\n        x = self.conv1(x)\n        x = self.bn1(x)\n        x = self.relu(x)\n        # x = self.maxpool(x)\n\n        x = self.layer1(x)\n        x = self.layer2(x)\n        x = self.layer3(x)\n        x = self.layer4(x)\n\n        x = self.avgpool(x)\n        x = x.view(x.size(0), -1)\n        x = self.fc(x)\n\n        return x\n\n\ndef resnet18(pretrained=False, **kwargs):\n    \"\"\"Constructs a ResNet-18 model.\n\n    Args:\n        pretrained (bool): If True, returns a model pre-trained on ImageNet\n\n    \"\"\"\n    model = ResNet(BasicBlock, [2, 2, 2, 2], **kwargs)\n    if pretrained:\n        model.load_state_dict(\n            model_path,\n            strict=False,\n        )\n    return model\n"
  },
  {
    "path": "service/tags/siglip2/__init__.py",
    "content": "\n"
  },
  {
    "path": "service/tags/siglip2/siglip2.py",
    "content": "import os\n\nimport numpy as np\nimport onnxruntime as ort\nimport sentencepiece as spm\nfrom PIL import Image\n\nSIGLIP2_MODEL_DIR = os.path.join(\"/\", \"protected_media\", \"data_models\", \"siglip2\")\nSIGLIP2_VISION_PATH = os.path.join(SIGLIP2_MODEL_DIR, \"vision_model.onnx\")\nSIGLIP2_TEXT_PATH = os.path.join(SIGLIP2_MODEL_DIR, \"text_model.onnx\")\nSIGLIP2_TOKENIZER_PATH = os.path.join(SIGLIP2_MODEL_DIR, \"tokenizer.model\")\nSIGLIP2_EMBEDDINGS_CACHE = os.path.join(SIGLIP2_MODEL_DIR, \"tag_embeddings.npy\")\n\nTAGS_FILE = os.path.join(os.path.dirname(__file__), \"tags.txt\")\n\nTARGET_SIZE = 384\nIMAGE_MEAN = np.array([0.5, 0.5, 0.5], dtype=np.float32)\nIMAGE_STD = np.array([0.5, 0.5, 0.5], dtype=np.float32)\n\nMAX_TOKEN_LENGTH = 64\nPAD_TOKEN_ID = 0\nEOS_TOKEN_ID = 1\n\n\ndef _pool_embeddings(raw_output, attention_mask=None):\n    \"\"\"Pool model output to (batch, hidden_dim).\n\n    If the output is already 2-D (batch, hidden_dim), return it directly.\n    If it is 3-D (batch, seq_len, hidden_dim), pool by taking the last\n    non-padding token per sequence (EOS-token pooling used by SigLIP).\n    Falls back to taking the last sequence position if no attention mask.\n    \"\"\"\n    if raw_output.ndim == 2:\n        return raw_output\n\n    # 3-D: (batch, seq_len, hidden_dim)\n    if attention_mask is not None:\n        # EOS token is the last attended position\n        eos_indices = attention_mask.sum(axis=1) - 1  # (batch,)\n        batch_idx = np.arange(raw_output.shape[0])\n        pooled = raw_output[batch_idx, eos_indices]  # (batch, hidden_dim)\n    else:\n        # Fallback: take position 0 (CLS-style pooling for vision)\n        pooled = raw_output[:, 0, :]\n\n    return pooled\n\n\ndef _l2_normalize(embeddings):\n    \"\"\"L2-normalize along the last axis.\"\"\"\n    norms = np.linalg.norm(embeddings, axis=-1, keepdims=True)\n    norms = np.maximum(norms, 1e-8)\n    return embeddings / norms\n\n\nclass SigLIP2:\n    def __init__(self):\n        self.vision_session = None\n        self.tokenizer = None\n        self.tags = None\n        self.tag_embeddings = None\n        self.is_loaded = False\n\n    def load(self):\n        \"\"\"Load the vision model, tokenizer, tag list, and pre-computed embeddings.\"\"\"\n        self.vision_session = ort.InferenceSession(\n            SIGLIP2_VISION_PATH,\n            providers=[\"CPUExecutionProvider\"],\n        )\n\n        with open(TAGS_FILE, \"r\") as f:\n            self.tags = [line.strip() for line in f if line.strip()]\n\n        if os.path.exists(SIGLIP2_EMBEDDINGS_CACHE):\n            self.tag_embeddings = np.load(SIGLIP2_EMBEDDINGS_CACHE)\n            # Invalidate cache if tag count changed or embedding dimension is wrong\n            needs_rebuild = False\n            if self.tag_embeddings.ndim != 2:\n                print(f\"siglip2: cache has wrong shape {self.tag_embeddings.shape}, rebuilding...\")\n                needs_rebuild = True\n            elif self.tag_embeddings.shape[0] != len(self.tags):\n                print(\n                    f\"siglip2: cache has {self.tag_embeddings.shape[0]} tags \"\n                    f\"but tags.txt has {len(self.tags)}, rebuilding...\"\n                )\n                needs_rebuild = True\n            elif self.tag_embeddings.shape[1] < 128:\n                print(\n                    f\"siglip2: cache has dim={self.tag_embeddings.shape[1]} \"\n                    f\"(likely stale from a failed build), rebuilding...\"\n                )\n                needs_rebuild = True\n\n            if needs_rebuild:\n                os.remove(SIGLIP2_EMBEDDINGS_CACHE)\n                self._build_tag_embeddings()\n            else:\n                print(\n                    f\"siglip2: loaded cached tag embeddings \"\n                    f\"({self.tag_embeddings.shape[0]} tags, dim={self.tag_embeddings.shape[1]})\"\n                )\n        else:\n            self._build_tag_embeddings()\n\n        self.is_loaded = True\n\n    def unload(self):\n        del self.vision_session\n        del self.tag_embeddings\n        del self.tokenizer\n        self.vision_session = None\n        self.tag_embeddings = None\n        self.tokenizer = None\n        self.tags = None\n        self.is_loaded = False\n\n    def _load_tokenizer(self):\n        if self.tokenizer is None:\n            self.tokenizer = spm.SentencePieceProcessor()\n            self.tokenizer.Load(SIGLIP2_TOKENIZER_PATH)\n\n    def _tokenize(self, texts, max_length=MAX_TOKEN_LENGTH):\n        \"\"\"Tokenize a list of texts using SentencePiece, returning input_ids and attention_mask.\"\"\"\n        self._load_tokenizer()\n\n        batch_input_ids = []\n        batch_attention_mask = []\n\n        for text in texts:\n            token_ids = self.tokenizer.Encode(text)\n            # Truncate (leave room for EOS)\n            token_ids = token_ids[: max_length - 1]\n            # Append EOS\n            token_ids.append(EOS_TOKEN_ID)\n\n            attention_mask = [1] * len(token_ids)\n\n            # Pad\n            pad_length = max_length - len(token_ids)\n            token_ids.extend([PAD_TOKEN_ID] * pad_length)\n            attention_mask.extend([0] * pad_length)\n\n            batch_input_ids.append(token_ids)\n            batch_attention_mask.append(attention_mask)\n\n        return (\n            np.array(batch_input_ids, dtype=np.int64),\n            np.array(batch_attention_mask, dtype=np.int64),\n        )\n\n    def _build_tag_embeddings(self):\n        \"\"\"Encode all tags with the text model and cache the embeddings.\"\"\"\n        print(\"siglip2: building tag embeddings (first run, this may take a minute)...\")\n\n        text_session = ort.InferenceSession(\n            SIGLIP2_TEXT_PATH,\n            providers=[\"CPUExecutionProvider\"],\n        )\n\n        text_input_names = [inp.name for inp in text_session.get_inputs()]\n        text_output_names = [out.name for out in text_session.get_outputs()]\n\n        print(f\"siglip2: text model inputs: {text_input_names}\")\n        print(f\"siglip2: text model outputs: {text_output_names}\")\n\n        # Use prompt template for better zero-shot performance\n        prompted_tags = [f\"a photo of {tag}\" for tag in self.tags]\n\n        all_embeddings = []\n        batch_size = 32\n\n        for i in range(0, len(prompted_tags), batch_size):\n            batch_texts = prompted_tags[i : i + batch_size]\n            input_ids, attention_mask = self._tokenize(batch_texts)\n\n            feed = {text_input_names[0]: input_ids}\n            if len(text_input_names) > 1:\n                feed[text_input_names[1]] = attention_mask\n\n            # Run all outputs so we can pick the best one\n            raw_outputs = text_session.run(None, feed)\n\n            # Find the output that gives us pooled embeddings (batch, hidden_dim)\n            # Prefer a 2-D output; if all are 3-D, pool the first one via EOS token\n            embeddings = None\n            for idx, out in enumerate(raw_outputs):\n                if out.ndim == 2 and out.shape[0] == len(batch_texts):\n                    embeddings = out\n                    break\n\n            if embeddings is None:\n                # All outputs are 3-D; pool the first one using EOS token position\n                embeddings = _pool_embeddings(raw_outputs[0], attention_mask)\n\n            if i == 0:\n                print(f\"siglip2: text embedding shape per batch: {embeddings.shape}\")\n\n            embeddings = _l2_normalize(embeddings)\n            all_embeddings.append(embeddings)\n\n            if (i // batch_size) % 5 == 0:\n                print(\n                    f\"siglip2: encoded {min(i + batch_size, len(prompted_tags))}\"\n                    f\"/{len(prompted_tags)} tags\"\n                )\n\n        del text_session\n\n        self.tag_embeddings = np.concatenate(all_embeddings, axis=0)\n\n        os.makedirs(os.path.dirname(SIGLIP2_EMBEDDINGS_CACHE), exist_ok=True)\n        np.save(SIGLIP2_EMBEDDINGS_CACHE, self.tag_embeddings)\n        print(\n            f\"siglip2: cached {self.tag_embeddings.shape[0]} tag embeddings \"\n            f\"(dim={self.tag_embeddings.shape[1]}) to {SIGLIP2_EMBEDDINGS_CACHE}\"\n        )\n\n    def prepare_image(self, image):\n        \"\"\"Resize, rescale, and normalize an image for SigLIP 2.\"\"\"\n        image = image.convert(\"RGB\")\n        image = image.resize((TARGET_SIZE, TARGET_SIZE), Image.BICUBIC)\n\n        arr = np.array(image, dtype=np.float32) / 255.0\n        arr = (arr - IMAGE_MEAN) / IMAGE_STD\n        # HWC -> CHW\n        arr = arr.transpose(2, 0, 1)\n        # Add batch dimension\n        return arr[np.newaxis, :]\n\n    def predict(self, image_path, threshold=0.05, max_tags=10):\n        \"\"\"Run inference and return the top tags by cosine similarity.\n\n        Args:\n            image_path: Path to the image file.\n            threshold: Minimum cosine similarity to include a tag.\n            max_tags: Maximum number of tags to return.\n\n        Returns:\n            dict with \"tags\" key containing a list of predicted tag strings.\n        \"\"\"\n        if not self.is_loaded:\n            self.load()\n\n        image = Image.open(image_path)\n        pixel_values = self.prepare_image(image)\n\n        vision_input_name = self.vision_session.get_inputs()[0].name\n\n        # Run all outputs\n        raw_outputs = self.vision_session.run(None, {vision_input_name: pixel_values})\n\n        # Find the pooled image embedding (2-D preferred, else pool from 3-D)\n        image_embeds = None\n        for out in raw_outputs:\n            if out.ndim == 2 and out.shape[0] == 1:\n                image_embeds = out\n                break\n\n        if image_embeds is None:\n            # 3-D output: pool via CLS token (position 0)\n            image_embeds = _pool_embeddings(raw_outputs[0], attention_mask=None)\n\n        image_embeds = _l2_normalize(image_embeds)\n\n        # Compute cosine similarity: (1, dim) @ (n_tags, dim).T -> (1, n_tags)\n        similarities = image_embeds @ self.tag_embeddings.T\n        scores = similarities[0]\n\n        # Get top tags sorted by score, filtered by threshold, capped at max_tags\n        ranked_indices = np.argsort(scores)[::-1]\n        predicted_tags = []\n        for idx in ranked_indices:\n            if scores[idx] < threshold:\n                break\n            predicted_tags.append(self.tags[idx])\n            if len(predicted_tags) >= max_tags:\n                break\n\n        return {\"tags\": predicted_tags}\n"
  },
  {
    "path": "service/tags/siglip2/tags.txt",
    "content": "person\nman\nwoman\nchild\nbaby\ntoddler\nteenager\nelderly person\ncouple\nfamily\ngroup of people\ncrowd\nportrait\nselfie\nface\nsmile\nlaughing\nglasses\nsunglasses\nhat\nbeard\nmustache\ntattoo\nwalking\nrunning\njogging\nswimming\ndancing\ncooking\neating\ndrinking\nreading\nwriting\nsleeping\nworking\nplaying\nsitting\nstanding\njumping\nclimbing\nhiking\ncycling\nsurfing\nskiing\nsnowboarding\nskating\nfishing\ncamping\ngardening\npainting\ndrawing\nsinging\nshopping\ntraveling\ndriving\ntalking\nhugging\nkissing\nwaving\npointing\nclapping\ncelebrating\npraying\nmeditating\nexercising\nstretching\nyoga\nsoccer\nbasketball\ntennis\nbaseball\nfootball\nvolleyball\ngolf\nboxing\nmartial arts\ngymnastics\nweightlifting\ncricket\nrugby\nhockey\nbadminton\ntable tennis\nwrestling\narchery\nfencing\ncat\ndog\npuppy\nkitten\nbird\nfish\nhorse\ncow\npig\nsheep\ngoat\nchicken\nduck\nrabbit\ndeer\nbear\nfox\nwolf\nlion\ntiger\nelephant\ngiraffe\nzebra\nmonkey\ngorilla\npanda\nsnake\nlizard\nturtle\nfrog\nbutterfly\nbee\ndragonfly\nspider\nsquirrel\nhamster\nparrot\nowl\neagle\nhawk\npenguin\ndolphin\nwhale\nshark\noctopus\ncrab\njellyfish\ninsect\nant\nladybug\nsnail\nhedgehog\nraccoon\nswan\nflamingo\npeacock\ncoral\nseal\nfood\nfruit\napple\nbanana\norange\nstrawberry\ngrape\nwatermelon\npineapple\ncherry\nlemon\npeach\nmango\navocado\ncoconut\nvegetable\ntomato\ncarrot\nbroccoli\ncorn\npotato\nonion\npepper\nmushroom\nlettuce\ncucumber\nbread\ncake\npie\ncookie\npizza\nburger\nsandwich\npasta\nnoodles\nrice\nsushi\nsoup\nsalad\nsteak\nchicken meat\nseafood\nshrimp\ncheese\negg\npancake\nwaffle\ndonut\nchocolate\ncandy\nice cream\ncoffee\ntea\nbeer\nwine\ncocktail\njuice\nwater bottle\nbreakfast\nlunch\ndinner\ndessert\nbarbecue\npicnic\ncar\ntruck\nbus\nvan\nmotorcycle\nbicycle\nscooter\ntrain\nsubway\ntram\nairplane\nhelicopter\nboat\nsailboat\nyacht\nship\ncanoe\nkayak\ntaxi\nambulance\nfire truck\npolice car\ntractor\nconstruction vehicle\nhot air balloon\nskateboard\nstroller\nwheelchair\nhouse\napartment building\nskyscraper\noffice building\ncastle\nchurch\ncathedral\ntemple\nmosque\ntower\nlighthouse\nbarn\ncabin\ncottage\npalace\nstadium\nbridge\narch\nmonument\nstatue\nfountain\nwindmill\nfactory\nwarehouse\ngreenhouse\ngazebo\ntent\nruins\nkitchen\nbedroom\nbathroom\nliving room\ndining room\noffice\nclassroom\nlibrary\nmuseum\ngallery\ntheater\ncinema\nhospital\nrestaurant\ncafe\nbar\npub\nbakery\nsupermarket\nmall\ngym\nspa\nhotel room\nlobby\nhallway\nstaircase\nbalcony\nrooftop\ngarden\npark\nforest\njungle\nmountain\nhill\nvalley\ncliff\ncanyon\ncave\nisland\nvolcano\nglacier\ndesert\nbeach\nocean\nsea\nriver\nlake\npond\nstream\nwaterfall\nswamp\nmarsh\nfield\nmeadow\ngrassland\nprairie\nfarmland\nvineyard\norchard\ncity\ntown\nvillage\nstreet\nroad\nhighway\nalley\nsidewalk\ncrossroad\nintersection\nparking lot\nplayground\ncourtyard\npier\ndock\nharbor\nmarina\nboardwalk\npromenade\ntrail\npath\ntree\npalm tree\npine tree\nflower\nrose\nsunflower\ntulip\ndaisy\nlily\norchid\nlavender\ngrass\nbush\nhedge\nmoss\nfern\ncactus\nbamboo\nvine\nleaf\npetal\nbranch\ntrunk\nroot\nmushroom in nature\ncoral reef\nseaweed\nsky\ncloud\nsun\nmoon\nstar\nrainbow\naurora\nsunset\nsunrise\ndawn\ndusk\ngolden hour\ntwilight\nsunny\ncloudy\nrainy\nsnowy\nfoggy\nmisty\nstormy\nwindy\novercast\nhazy\nfrost\nice\nthunder\nlightning\nrain\nsnow\nhail\nspring\nsummer\nautumn\nwinter\nphone\nsmartphone\nlaptop\ncomputer\ntablet\nkeyboard\nmonitor\ncamera\ntelevision\nspeaker\nheadphones\nmicrophone\nclock\nwatch\numbrella\nbag\nbackpack\npurse\nwallet\nsuitcase\nbasket\nbox\nbottle\njar\ncup\nmug\ndrinking glass\nplate\nbowl\nfork\nknife\nspoon\nchopsticks\npan\npot\nbook\nnewspaper\nmagazine\npen\npencil\nscissors\nkey\nlock\nflashlight\ncandle\nlamp\nlantern\nmirror\nframe\npainting on wall\nmap\nglobe\ntelescope\nbinoculars\ncompass\nguitar\npiano\nviolin\ndrums\nflute\ntrumpet\nsaxophone\nharmonica\nbell\nchair\ntable\ndesk\nsofa\ncouch\nbed\nbench\nstool\nshelf\nbookshelf\ncabinet\ndrawer\nwardrobe\nrug\ncarpet\ncurtain\npillow\nblanket\ntowel\nshirt\ndress\nsuit\njacket\ncoat\nsweater\nhoodie\njeans\nshorts\nskirt\nuniform\ncostume\nscarf\nglove\nboot\nsneaker\nsandal\nhigh heel\nring\nnecklace\nbracelet\nearring\ncrown\nmask\nhelmet\nribbon\nbow tie\nnecktie\nfireworks\nballoon\nconfetti\nbanner\nflag\ngift\npresent\nwrapped gift\nbirthday cake\ncandles\nchristmas tree\nornament\nwreath\npumpkin\njack-o-lantern\neaster egg\nwedding dress\nwedding ring\nbouquet\nchampagne\ntrophy\nmedal\ncertificate\ndiploma\nsign\nbillboard\ntraffic light\nstop sign\nstreet sign\nneon sign\ngraffiti\nmural\nposter\nbanner sign\ntattoo art\nsculpture\nwood\nmetal\nglass\nstone\nbrick\nconcrete\nmarble\nceramic\nleather\nfabric\npaper\nsand\nrock\npebble\ncrystal\ngold\nsilver\nlace\nwool\ndenim\nsilk\nwater\nfire\nsmoke\nsteam\nflame\nspark\nbubble\nwave\nfoam\nicicle\nsnowflake\nshadow\nreflection\nsilhouette\nbokeh\nlens flare\nlight beam\ndarkness\nsymmetry\npattern\ntexture\nstripe\npolka dot\nplaid\ngeometric\nabstract\ncolorful\nblack and white\nvintage\nretro\nrustic\nmodern\nminimalist\ncozy\ndramatic\npeaceful\nromantic\nfestive\nspooky\nscary\nsoothing\nstressful\nwarm\ncold\nnatural\nman-made\naged\nglossy\nrusty\ndirty\nsterile\nmoist\ndry\ncluttered\nopen area\nenclosed area\nboating\nbiking\nsunbathing\ntouring\ndiving\nbathing\ncleaning\nsocializing\nstudying\ntraining\nfarming\nconstructing\ncompeting\nspectating\ndigging\ngaming\nsports\nvegetation\nfoliage\nshrubbery\nleaves\nflowers\nasphalt\npavement\ntiles\nplastic\nvinyl\ndirt\nrunning water\nstill water\nnatural light\nindoor lighting\nairfield\nairplane cabin\nairport terminal\nalcove\namphitheater\namusement arcade\namusement park\naquarium\naqueduct\narchaeological site\nart gallery\nart studio\nassembly line\nattic\nauditorium\nbadlands\nballroom\nbamboo forest\nbanquet hall\nbaseball field\nbasketball court\nbazaar\nbeauty salon\nbeer garden\nboat deck\nboathouse\nbookstore\nbotanical garden\nbowling alley\nboxing ring\nbuilding facade\nburial chamber\ncampsite\ncampus\ncandy store\ncar interior\ncarousel\ncatacomb\ncemetery\nchalet\nclothing store\ncoast\ncockpit\ncoffee shop\ncomputer room\nconference room\nconstruction site\ncorn field\ncorridor\ncourthouse\ncreek\ncrevasse\ncrosswalk\ndam\ndelicatessen\ndepartment store\ndesert road\ndiner\ndiscotheque\ndoorway\ndorm room\ndowntown\ndressing room\ndriveway\ndrugstore\nelevator\nembassy\nengine room\nentrance hall\nescalator\nexcavation\nfabric store\nfast food restaurant\nfire escape\nfire station\nfishpond\nflea market\nflorist shop\nfood court\nfootball field\nforest path\nformal garden\ngarage\ngas station\ngift shop\ngolf course\ngrotto\ngymnasium\nhangar\nhardware store\nhayfield\nheliport\nhome office\nhome theater\nhospital room\nhot spring\nhunting lodge\nice cream parlor\niceberg\nigloo\nindustrial area\ninn\njail cell\njapanese garden\njewelry shop\njunkyard\nkindergarten\nlagoon\nlandfill\nlanding deck\nlaundromat\nlawn\nlecture room\nloading dock\nlocker room\nmansion\nmarket\nmausoleum\nmoat\nmotel\nmountain path\nmovie theater\nmusic studio\nnatural history museum\nnursery\nnursing home\noil rig\noperating room\norchestra pit\npagoda\npantry\nparking garage\npasture\npatio\npavilion\npet shop\npharmacy\nphone booth\npicnic area\npizzeria\nplayroom\nplaza\nporch\nracecourse\nraceway\nraft\nrailroad track\nrainforest\nreception\nrecreation room\nrepair shop\nresidential neighborhood\nrestaurant kitchen\nrestaurant patio\nrice paddy\nrock arch\nroof garden\nrope bridge\nrunway\nsandbox\nsauna\nschoolhouse\nscience museum\nserver room\nshed\nshoe shop\nshopfront\nshopping mall\nshower\nski resort\nski slope\nslum\nsnowfield\nsoccer field\nstable\nstage\nstorage room\nsubway station\nsushi bar\nswimming hole\nswimming pool\nsynagogue\ntelevision studio\nthrone room\ntopiary garden\ntoy shop\ntrain interior\ntrain station\ntree farm\ntree house\ntrench\ntundra\nunderwater\nutility room\nvegetable garden\nveterinary office\nviaduct\nvolleyball court\nwaiting room\nwater park\nwater tower\nwatering hole\nwheat field\nwind farm\nyard\nyouth hostel\nzen garden\nchristmas\nbirthday\nhalloween\neaster\nthanksgiving\nnew year\nnew year's eve\nvalentine's day\nwedding\ngraduation\nanniversary\nengagement\nbaby shower\nbaptism\ncommunion\nfuneral\nmemorial service\nhanukkah\ndiwali\neid\ncarnival\nmardi gras\nst. patrick's day\nindependence day\nfourth of july\nmother's day\nfather's day\nchinese new year\nlunar new year\noktoberfest\nprom\nhomecoming\nretirement party\nhousewarming\nreunion\nconcert\nfestival\nparade\ncostume party\npool party\ngarden party\nsurprise party\nbridal shower\nbachelor party\nbachelorette party\ngender reveal\nfirst day of school\naward ceremony\ncharity event\ngala\nrecital\nschool play\ntalent show\nscience fair\nsports day\nmarathon\ntournament\nquinceañera\nbar mitzvah\nbat mitzvah\n"
  },
  {
    "path": "service/thumbnail/__init__.py",
    "content": ""
  },
  {
    "path": "service/thumbnail/main.py",
    "content": "import gevent\nfrom flask import Flask, request\nfrom gevent.pywsgi import WSGIServer\nfrom wand.image import Image\n\napp = Flask(__name__)\n\n\ndef log(message):\n    print(f\"thumbnail: {message}\")\n\n\n@app.route(\"/\", methods=[\"POST\"])\ndef create_thumbnail():\n    try:\n        data = request.get_json()\n        source = data[\"source\"]\n        destination = data[\"destination\"]\n        height = data[\"height\"]\n    except Exception:\n        return \"\", 400\n    log(f\"creating for source={source} height={height}\")\n    with Image(filename=source) as img:\n        with img.clone() as thumbnail:\n            thumbnail.format = \"webp\"\n            thumbnail.transform(resize=f\"x{height}\")\n            thumbnail.compression_quality = 95\n            thumbnail.auto_orient()\n            thumbnail.save(filename=destination)\n    log(f\"created at location={destination}\")\n    return {\"thumbnail\": destination}, 201\n\n\n@app.route(\"/health\", methods=[\"GET\"])\ndef health():\n    return {\"status\": \"OK\"}, 200\n\n\nif __name__ == \"__main__\":\n    log(\"service starting\")\n    server = WSGIServer((\"0.0.0.0\", 8003), app)\n    server_thread = gevent.spawn(server.serve_forever)\n    gevent.joinall([server_thread])\n"
  },
  {
    "path": "service/thumbnail/test/.gitignore",
    "content": "samples/*\n!samples/.gitkeep\n!samples/README.md\n"
  },
  {
    "path": "service/thumbnail/test/__init__.py",
    "content": ""
  },
  {
    "path": "service/thumbnail/test/samples/.gitkeep",
    "content": ""
  },
  {
    "path": "service/thumbnail/test/samples/README.md",
    "content": "place any *image* files in this directory that you want to use as test data\n"
  },
  {
    "path": "service/thumbnail/test/test_thumbnail_worker.py",
    "content": "import os\n\nfrom pytest import fixture\n\nfrom service.thumbnail.main import app\n\nHTTP_BAD_REQUEST = 400\nHTTP_CREATED = 201\n\n\n@fixture()\ndef client():\n    return app.test_client()\n\n\ndef test_must_fail_when_passing_empty_string(client):\n    response = client.post(\"/\", data=\"\")\n    assert response.status_code == HTTP_BAD_REQUEST\n\n\ndef test_must_fail_when_passing_invalid_json(client):\n    response = client.post(\"/\", data=\"invalid json\")\n    assert response.status_code == HTTP_BAD_REQUEST\n\n\ndef test_must_fail_when_passing_incomplete_json(client):\n    invalid_payloads = [\n        {\"source\": \"foo\"},\n        {\"destination\": \"/tmp/result.webp\"},\n        {\"height\": 100},\n        {\"source\": \"foo\", \"destination\": \"/tmp/result.webp\"},\n        {\"destination\": \"/tmp/result.webp\", \"height\": 100},\n        {\"height\": 100, \"source\": \"foo\"},\n    ]\n    for payload in invalid_payloads:\n        response = client.post(\"/\", json=payload)\n        assert response.status_code == HTTP_BAD_REQUEST\n\n\ndef test_should_create_thumbnail(client):\n    samples_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), \"samples\")\n    samples = [f for f in os.listdir(samples_dir) if f not in [\".gitkeep\", \"README.md\"]]\n    thumbnail_path = \"/tmp/result.webp\"\n    for sample in samples:\n        if os.path.exists(thumbnail_path):\n            os.remove(thumbnail_path)\n        source = os.path.join(samples_dir, sample)\n        json = {\"source\": source, \"destination\": thumbnail_path, \"height\": 100}\n        response = client.post(\"/\", json=json)\n        assert response.status_code == HTTP_CREATED\n"
  }
]