Showing preview only (2,457K chars total). Download the full file or copy to clipboard to get everything.
Repository: LibrePhotos/librephotos
Branch: dev
Commit: 460655fada5b
Files: 423
Total size: 2.3 MB
Directory structure:
gitextract_0h3h3ffo/
├── .coveragerc
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── enhancement-request.md
│ └── workflows/
│ ├── docker-publish.yml
│ └── pre-commit.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CLAUDE.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── api/
│ ├── __init__.py
│ ├── admin.py
│ ├── all_tasks.py
│ ├── api_util.py
│ ├── apps.py
│ ├── autoalbum.py
│ ├── background_tasks.py
│ ├── batch_jobs.py
│ ├── burst_detection_rules.py
│ ├── cluster_manager.py
│ ├── date_time_extractor.py
│ ├── directory_watcher/
│ │ ├── __init__.py
│ │ ├── file_grouping.py
│ │ ├── file_handlers.py
│ │ ├── processing_jobs.py
│ │ ├── repair_jobs.py
│ │ ├── scan_jobs.py
│ │ └── utils.py
│ ├── drf_optimize.py
│ ├── duplicate_detection.py
│ ├── face_classify.py
│ ├── face_extractor.py
│ ├── face_recognition.py
│ ├── feature/
│ │ ├── __init__.py
│ │ ├── embedded_media.py
│ │ └── tests/
│ │ ├── __init__.py
│ │ └── test_embedded_media.py
│ ├── filters.py
│ ├── geocode/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── geocode.py
│ │ └── parsers/
│ │ ├── __init__.py
│ │ ├── mapbox.py
│ │ ├── nominatim.py
│ │ ├── opencage.py
│ │ └── tomtom.py
│ ├── image_captioning.py
│ ├── image_similarity.py
│ ├── llm.py
│ ├── management/
│ │ ├── __init__.py
│ │ └── commands/
│ │ ├── build_similarity_index.py
│ │ ├── clear_cache.py
│ │ ├── createadmin.py
│ │ ├── createuser.py
│ │ ├── save_metadata.py
│ │ ├── scan.py
│ │ ├── start_cleaning_service.py
│ │ ├── start_job_cleanup_service.py
│ │ └── start_service.py
│ ├── metadata/
│ │ ├── __init__.py
│ │ ├── face_regions.py
│ │ ├── reader.py
│ │ ├── tags.py
│ │ └── writer.py
│ ├── middleware.py
│ ├── migrations/
│ │ ├── 0001_initial.py
│ │ ├── 0002_add_confidence.py
│ │ ├── 0003_remove_unused_thumbs.py
│ │ ├── 0004_fix_album_thing_constraint.py
│ │ ├── 0005_add_video_to_photo.py
│ │ ├── 0006_migrate_to_boolean_field.py
│ │ ├── 0007_migrate_to_json_field.py
│ │ ├── 0008_remove_image_path.py
│ │ ├── 0009_add_aspect_ratio.py
│ │ ├── 0009_add_clip_embedding_field.py
│ │ ├── 0010_merge_20210725_1547.py
│ │ ├── 0011_a_add_rating.py
│ │ ├── 0011_b_migrate_favorited_to_rating.py
│ │ ├── 0011_c_remove_favorited.py
│ │ ├── 0012_add_favorite_min_rating.py
│ │ ├── 0013_add_image_scale_and_misc.py
│ │ ├── 0014_add_save_metadata_to_disk.py
│ │ ├── 0015_add_dominant_color.py
│ │ ├── 0016_add_transcode_videos.py
│ │ ├── 0017_add_cover_photo.py
│ │ ├── 0018_user_config_datetime_rules.py
│ │ ├── 0019_change_config_datetime_rules.py
│ │ ├── 0020_add_default_timezone.py
│ │ ├── 0021_remove_photo_image.py
│ │ ├── 0022_photo_video_length.py
│ │ ├── 0023_photo_deleted.py
│ │ ├── 0024_photo_timestamp.py
│ │ ├── 0025_add_cover_photo.py
│ │ ├── 0026_add_cluster_info.py
│ │ ├── 0027_rename_unknown_person.py
│ │ ├── 0028_add_metadata_fields.py
│ │ ├── 0029_change_to_text_field.py
│ │ ├── 0030_user_confidence_person.py
│ │ ├── 0031_remove_account.py
│ │ ├── 0032_always_have_owner.py
│ │ ├── 0033_add_post_delete_person.py
│ │ ├── 0034_allow_deleting_person.py
│ │ ├── 0035_add_files_model.py
│ │ ├── 0036_handle_missing_files.py
│ │ ├── 0037_migrate_to_files.py
│ │ ├── 0038_add_main_file.py
│ │ ├── 0039_remove_photo_image_paths.py
│ │ ├── 0040_add_user_public_sharing_flag.py
│ │ ├── 0041_apply_user_enum_for_person.py
│ │ ├── 0042_alter_albumuser_cover_photo_alter_photo_main_file.py
│ │ ├── 0043_alter_photo_size.py
│ │ ├── 0044_alter_cluster_person_alter_person_cluster_owner.py
│ │ ├── 0045_alter_face_cluster.py
│ │ ├── 0046_add_embedded_media.py
│ │ ├── 0047_alter_file_embedded_media.py
│ │ ├── 0048_fix_null_height.py
│ │ ├── 0049_fix_metadata_files_as_main_files.py
│ │ ├── 0050_person_face_count.py
│ │ ├── 0051_set_person_defaults.py
│ │ ├── 0052_alter_person_name.py
│ │ ├── 0053_user_confidence_unknown_face_and_more.py
│ │ ├── 0054_user_cluster_selection_epsilon_user_min_samples.py
│ │ ├── 0055_alter_longrunningjob_job_type.py
│ │ ├── 0056_user_llm_settings_alter_longrunningjob_job_type.py
│ │ ├── 0057_remove_face_image_path_and_more.py
│ │ ├── 0058_alter_user_avatar_alter_user_nextcloud_app_password_and_more.py
│ │ ├── 0059_person_cover_face.py
│ │ ├── 0060_apply_default_face_cover.py
│ │ ├── 0061_alter_person_name.py
│ │ ├── 0062_albumthing_cover_photos.py
│ │ ├── 0063_apply_default_album_things_cover.py
│ │ ├── 0064_albumthing_photo_count.py
│ │ ├── 0065_apply_default_photo_count.py
│ │ ├── 0066_photo_last_modified_alter_longrunningjob_job_type.py
│ │ ├── 0067_alter_longrunningjob_job_type.py
│ │ ├── 0068_remove_longrunningjob_result_and_more.py
│ │ ├── 0069_rename_to_in_trashcan.py
│ │ ├── 0070_photo_removed.py
│ │ ├── 0071_rename_person_label_probability_face_cluster_probability_and_more.py
│ │ ├── 0072_alter_face_person.py
│ │ ├── 0073_remove_unknown_person.py
│ │ ├── 0074_migrate_cluster_person.py
│ │ ├── 0075_alter_face_cluster_person.py
│ │ ├── 0076_alter_file_path_alter_longrunningjob_job_type_and_more.py
│ │ ├── 0077_alter_albumdate_title.py
│ │ ├── 0078_create_photo_thumbnail.py
│ │ ├── 0079_alter_albumauto_title.py
│ │ ├── 0080_create_photo_caption.py
│ │ ├── 0081_remove_caption_fields_from_photo.py
│ │ ├── 0082_create_photo_search.py
│ │ ├── 0083_remove_search_fields.py
│ │ ├── 0084_convert_arrayfield_to_json.py
│ │ ├── 0085_albumuser_public_expires_at_albumuser_public_slug.py
│ │ ├── 0086_remove_albumuser_public_and_more.py
│ │ ├── 0087_add_folder_album.py
│ │ ├── 0088_remove_folder_album.py
│ │ ├── 0089_add_text_alignment.py
│ │ ├── 0090_add_header_size.py
│ │ ├── 0091_alter_user_scan_directory.py
│ │ ├── 0092_add_skip_raw_files_field.py
│ │ ├── 0093_migrate_photon_to_nominatim.py
│ │ ├── 0094_add_slideshow_interval.py
│ │ ├── 0095_photo_perceptual_hash_alter_longrunningjob_job_type_and_more.py
│ │ ├── 0096_add_progress_step_and_result_to_longrunningjob.py
│ │ ├── 0097_add_duplicate_detection_settings_to_user.py
│ │ ├── 0098_add_photo_stack.py
│ │ ├── 0099_photo_uuid_primary_key.py
│ │ ├── 0100_metadataedit_metadatafile_photometadata_stackreview_and_more.py
│ │ ├── 0101_populate_photo_metadata.py
│ │ ├── 0102_photo_stacks_manytomany.py
│ │ ├── 0103_remove_photo_metadata_fields.py
│ │ ├── 0104_remove_photostack_potential_savings_and_more.py
│ │ ├── 0105_alter_photo_image_hash.py
│ │ ├── 0106_alter_longrunningjob_options.py
│ │ ├── 0107_add_burst_detection_rules.py
│ │ ├── 0108_add_stack_raw_jpeg_field.py
│ │ ├── 0109_migrate_skip_raw_to_stack_raw_jpeg.py
│ │ ├── 0110_fix_file_embedded_media_self_reference.py
│ │ ├── 0111_alter_file_embedded_media.py
│ │ ├── 0112_convert_file_stacks_to_variants.py
│ │ ├── 0113_alter_photostack_stack_type.py
│ │ ├── 0114_add_file_path_unique.py
│ │ ├── 0115_cleanup_duplicate_photos.py
│ │ ├── 0116_cleanup_duplicate_groups_removed_photos.py
│ │ ├── 0117_delete_removed_photos.py
│ │ ├── 0118_alter_longrunningjob_job_type.py
│ │ ├── 0119_add_public_sharing_options.py
│ │ ├── 0120_rename_thumbnails_uuid_to_hash.py
│ │ ├── 0121_add_default_tagging_model.py
│ │ ├── 0121_user_save_face_tags_to_disk.py
│ │ └── __init__.py
│ ├── ml_models.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── album_auto.py
│ │ ├── album_date.py
│ │ ├── album_place.py
│ │ ├── album_thing.py
│ │ ├── album_user.py
│ │ ├── album_user_share.py
│ │ ├── cluster.py
│ │ ├── duplicate.py
│ │ ├── face.py
│ │ ├── file.py
│ │ ├── long_running_job.py
│ │ ├── person.py
│ │ ├── photo.py
│ │ ├── photo_caption.py
│ │ ├── photo_metadata.py
│ │ ├── photo_search.py
│ │ ├── photo_stack.py
│ │ ├── stack_review.py
│ │ ├── thumbnail.py
│ │ └── user.py
│ ├── nextcloud.py
│ ├── perceptual_hash.py
│ ├── permissions.py
│ ├── schemas/
│ │ └── site_settings.py
│ ├── semantic_search.py
│ ├── serializers/
│ │ ├── PhotosGroupedByDate.py
│ │ ├── __init__.py
│ │ ├── album_auto.py
│ │ ├── album_date.py
│ │ ├── album_place.py
│ │ ├── album_thing.py
│ │ ├── album_user.py
│ │ ├── face.py
│ │ ├── job.py
│ │ ├── person.py
│ │ ├── photo_metadata.py
│ │ ├── photos.py
│ │ ├── simple.py
│ │ └── user.py
│ ├── services.py
│ ├── social_graph.py
│ ├── stack_detection.py
│ ├── stacks/
│ │ ├── __init__.py
│ │ └── live_photo.py
│ ├── stats.py
│ ├── tests/
│ │ ├── __init__.py
│ │ ├── fixtures/
│ │ │ ├── __init__.py
│ │ │ ├── api_util/
│ │ │ │ ├── captions_json.py
│ │ │ │ ├── expectation.py
│ │ │ │ ├── photos.py
│ │ │ │ └── sunburst_expectation.py
│ │ │ ├── geocode/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── expectations/
│ │ │ │ │ ├── mapbox.py
│ │ │ │ │ ├── nominatim.py
│ │ │ │ │ ├── opencage.py
│ │ │ │ │ └── tomtom.py
│ │ │ │ └── responses/
│ │ │ │ ├── mapbox.py
│ │ │ │ ├── nominatim.py
│ │ │ │ ├── opencage.py
│ │ │ │ └── tomtom.py
│ │ │ ├── location_timeline_test_data.csv
│ │ │ └── niaz.xmp
│ │ ├── test_api_robustness.py
│ │ ├── test_api_util.py
│ │ ├── test_auto_select_and_savings.py
│ │ ├── test_background_tasks.py
│ │ ├── test_bktree_and_duplicate_detection.py
│ │ ├── test_bulk_operations.py
│ │ ├── test_burst_detection_rules.py
│ │ ├── test_burst_filename_patterns.py
│ │ ├── test_delete_photos.py
│ │ ├── test_detection_edge_cases.py
│ │ ├── test_directory_watcher_fix.py
│ │ ├── test_dirtree.py
│ │ ├── test_duplicate_api_edge_cases.py
│ │ ├── test_duplicate_detection.py
│ │ ├── test_duplicate_detection_logic.py
│ │ ├── test_duplicate_filtering.py
│ │ ├── test_edge_cases_integration.py
│ │ ├── test_edit_photo_details.py
│ │ ├── test_face_extractor.py
│ │ ├── test_face_writeback.py
│ │ ├── test_favorite_photos.py
│ │ ├── test_file_model.py
│ │ ├── test_file_path_uniqueness.py
│ │ ├── test_geocode.py
│ │ ├── test_get_faces.py
│ │ ├── test_hide_photos.py
│ │ ├── test_im2txt.py
│ │ ├── test_live_photo.py
│ │ ├── test_location_timeline.py
│ │ ├── test_metadata_ordering_sentinel.py
│ │ ├── test_migration_0099.py
│ │ ├── test_migration_0101.py
│ │ ├── test_multi_user_isolation.py
│ │ ├── test_only_photos_or_only_videos.py
│ │ ├── test_perceptual_hash.py
│ │ ├── test_photo_caption_model.py
│ │ ├── test_photo_captions.py
│ │ ├── test_photo_lifecycle.py
│ │ ├── test_photo_list_without_timestamp.py
│ │ ├── test_photo_metadata.py
│ │ ├── test_photo_metadata_api.py
│ │ ├── test_photo_model_integration.py
│ │ ├── test_photo_search_model.py
│ │ ├── test_photo_search_refactor.py
│ │ ├── test_photo_summary.py
│ │ ├── test_photo_viewset_permissions.py
│ │ ├── test_predefined_rules.py
│ │ ├── test_public_photos.py
│ │ ├── test_reading_exif.py
│ │ ├── test_recently_added_photos.py
│ │ ├── test_redetection_idempotency.py
│ │ ├── test_regenerate_titles.py
│ │ ├── test_retrieve_photo.py
│ │ ├── test_scan_percentage_bug.py
│ │ ├── test_scan_photos.py
│ │ ├── test_scan_photos_directories.py
│ │ ├── test_search_term_examples.py
│ │ ├── test_search_terms.py
│ │ ├── test_services.py
│ │ ├── test_setup_directory.py
│ │ ├── test_share_photos.py
│ │ ├── test_skip_raw_files.py
│ │ ├── test_social_graph.py
│ │ ├── test_stack_api_edge_cases.py
│ │ ├── test_stack_detection.py
│ │ ├── test_stack_detection_edge_cases.py
│ │ ├── test_stack_detection_logic.py
│ │ ├── test_stack_duplicate_integration.py
│ │ ├── test_stack_review.py
│ │ ├── test_stack_validation_edge_cases.py
│ │ ├── test_stats_accuracy.py
│ │ ├── test_thumbnail_migration.py
│ │ ├── test_thumbnail_naming.py
│ │ ├── test_trash_api.py
│ │ ├── test_user.py
│ │ ├── test_xmp_association.py
│ │ ├── test_zip_list_photos_view_v2.py
│ │ └── utils.py
│ ├── thumbnails.py
│ ├── util.py
│ └── views/
│ ├── __init__.py
│ ├── album_auto.py
│ ├── album_folder.py
│ ├── albums.py
│ ├── custom_api_view.py
│ ├── dataviz.py
│ ├── duplicates.py
│ ├── faces.py
│ ├── geocode.py
│ ├── jobs.py
│ ├── pagination.py
│ ├── photo_filters.py
│ ├── photo_metadata.py
│ ├── photos.py
│ ├── public_albums.py
│ ├── search.py
│ ├── services.py
│ ├── sharing.py
│ ├── stacks.py
│ ├── timezone.py
│ ├── upload.py
│ ├── user.py
│ └── views.py
├── image_similarity/
│ ├── __init__.py
│ ├── main.py
│ ├── retrieval_index.py
│ └── utils.py
├── librephotos/
│ ├── __init__.py
│ ├── settings/
│ │ ├── __init__.py
│ │ ├── development.py
│ │ ├── production.py
│ │ ├── test.py
│ │ └── test_sqlite.py
│ ├── urls.py
│ └── wsgi.py
├── manage.py
├── nextcloud/
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── directory_watcher.py
│ ├── models.py
│ ├── tests.py
│ └── views.py
├── pyproject.toml
├── renovate.json
├── requirements.dev.txt
├── requirements.mlval.txt
├── requirements.txt
└── service/
├── __init__.py
├── clip_embeddings/
│ ├── __init__.py
│ ├── main.py
│ └── semantic_search/
│ ├── __init__.py
│ └── semantic_search.py
├── exif/
│ ├── __init__.py
│ └── main.py
├── face_recognition/
│ ├── __init__.py
│ └── main.py
├── image_captioning/
│ ├── __init__.py
│ ├── api/
│ │ └── im2txt/
│ │ ├── README.md
│ │ ├── blip/
│ │ │ ├── blip.py
│ │ │ ├── med.py
│ │ │ └── vit.py
│ │ ├── build_vocab.py
│ │ ├── data_loader.py
│ │ ├── model.py
│ │ ├── resize.py
│ │ ├── sample.py
│ │ └── train.py
│ └── main.py
├── llm/
│ ├── __init__.py
│ └── main.py
├── tags/
│ ├── __init__.py
│ ├── main.py
│ ├── places365/
│ │ ├── __init__.py
│ │ ├── places365.py
│ │ └── wideresnet.py
│ └── siglip2/
│ ├── __init__.py
│ ├── siglip2.py
│ └── tags.txt
└── thumbnail/
├── __init__.py
├── main.py
└── test/
├── .gitignore
├── __init__.py
├── samples/
│ ├── .gitkeep
│ └── README.md
└── test_thumbnail_worker.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .coveragerc
================================================
[run]
branch = True
[report]
skip_covered = True
skip_empty = True
# show_missing = True
[html]
skip_covered = True
skip_empty = True
================================================
FILE: .github/FUNDING.yml
================================================
github: derneuere
custom: https://www.paypal.com/donate/?hosted_button_id=5JWVM2UR4LM96
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
# 🛑 Before you create an issue make sure that:
- Your issue is **strictly related to LibrePhotos** itself. Questions about setting up a reverse proxy belong in what ever reverse proxy you are using.
- You have read the [documentation](https://docs.librephotos.com) thoroughly.
- You have searched for a similar issue among all the former issues (even closed ones).
- You have tried to replicate the issue with a clean install of the project.
- You have asked for help on our Discord server [LibrePhotos](https://discord.gg/xwRvtSDGWb) if your issue involves general "how to" questions
**When Submitting please remove every thing above this line**
# 🐛 Bug Report
* [ ] 📁 I've Included a ZIP file containing my librephotos `log` files
* [ ] ❌ I have looked for similar issues (including closed ones)
* [ ] 🎬 (If applicable) I've provided pictures or links to videos that clearly demonstrate the issue
## 📝 Description of issue:
## 🔁 How can we reproduce it:
## Please provide additional information:
- 💻 Operating system:
- ⚙ Architecture (x86 or ARM):
- 🔢 Librephotos version:
- 📸 Librephotos installation method (Docker, Kubernetes, .deb, etc.):
* 🐋 If Docker or Kubernets, provide docker-compose image tag:
- 📁 How is you picture library mounted (Local file system (Type), NFS, SMB, etc.):
- ☁ If you are virtualizing librephotos, Virtualization platform (Proxmox, Xen, HyperV, etc.):
================================================
FILE: .github/ISSUE_TEMPLATE/enhancement-request.md
================================================
---
name: Enhancement request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Describe the enhancement you'd like**
A clear and concise description of what you want to happen.
**Describe why this will benefit the LibrePhotos**
A clear and concise explanation on why this will make LibrePhotos better.
**Additional context**
Add any other context or screenshots about the enhancement request here.
================================================
FILE: .github/workflows/docker-publish.yml
================================================
on:
push:
# Publish `dev` as Docker `latest` image.
branches:
- dev
jobs:
# Run tests.
# See also https://docs.docker.com/docker-hub/builds/automated-testing/
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Run tests
run: echo "to-do"
# Push image to GitHub Packages.
# See also https://docs.docker.com/docker-hub/builds/
push:
# Ensure test job passes before pushing image.
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.REPO_ACCESS_TOKEN }}
repository: librephotos/librephotos-docker
event-type: backend-commit-event
================================================
FILE: .github/workflows/pre-commit.yml
================================================
name: Linting (using pre-commit)
on: [push, pull_request]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: pip install ruff
- name: Run pre-commit check
uses: pre-commit/action@v3.0.1
================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Docker
Dockerfile
entrypoint.sh
# Static assets
static/
# Nuitka
manage.build/
manage.dist/
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
coco/
survey/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# visual studio
.vs/
.vscode/
# IntelliJ
.idea/
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# LibrePhotos
densecap/data/models/densecap/densecap-pretrained-vgg16.t7
*/*.pkl
*/*/*.pkl
thumbnails
media
samplephotos
Conv2d.patch
Linear.patch
Sequential.patch
BatchNorm2d.patch
AvgPool2d.patch
ReLU.patch
run_docker.sh
logs/
playground
api/im2txt/data/
api/im2txt/models/
api/im2txt/png/
*.ipynb
api/im2txt/*.tar.gz
*.db
media*
protected_media
report_dynamo_export.sarif
# Vim
*.swp
*.swo
================================================
FILE: .pre-commit-config.yaml
================================================
default_language_version:
python: python3
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.4 # Check latest version
hooks:
- id: ruff
args: ["--fix"] # Fix lint & format
- id: ruff-format # Format code
================================================
FILE: CLAUDE.md
================================================
# LibrePhotos Backend Agent Guidelines
## Build & Development Commands
**Note:** All commands should be run inside the backend Docker container (`docker exec -it backend bash`).
### Django Management
- **Run Migrations**: `python manage.py migrate`
- **Make Migrations**: `python manage.py makemigrations`
- **Create Superuser**: `python manage.py createsuperuser`
- **Collect Static**: `python manage.py collectstatic`
- **Shell**: `python manage.py shell`
- **Custom Commands**: `python manage.py <command_name>` (see `api/management/commands/`)
### Running Services
- **API Server (Gunicorn)**: Runs automatically in container
- **Background Jobs (django-q2)**: Runs automatically via `qcluster` command
- **Image Similarity Service**: Flask app for semantic search
- **Thumbnail Service**: Separate process for image processing
### Linting & Formatting
- **Lint**: `ruff check .`
- **Format**: `ruff format .`
- **Lint + Fix**: `ruff check --fix .`
### Testing
- **Run All Tests**: `python manage.py test api.tests`
- **Run Specific Test**: `python manage.py test api.tests.test_module`
- **Run with Verbosity**: `python manage.py test api.tests -v 2`
### Debugging
- **PDB Breakpoint**: Add `import pdb; pdb.set_trace()` in code
- **Attach to Container**: `docker attach $(docker ps --filter name=backend -q)`
- **Silk Profiler**: Access `/api/silk` (dev mode only)
- **Detach**: `Ctrl+P` then `Ctrl+Q`
## Code Style & Conventions
- **Formatting**: Ruff with 88 char line width (configured in `pyproject.toml`)
- **Imports**: Sorted by isort (via Ruff)
- **Target Python**: 3.11+
- **Framework**: Django 5.x with Django REST Framework
- **Async Jobs**: django-q2 with ORM broker
- **ML Framework**: PyTorch for machine learning models
## Project Structure
### `api/` - Main Application
- `models/` - Django ORM models (Photo, Face, Person, Album, etc.)
- `views/` - API endpoints using Django REST Framework
- `serializers/` - JSON serialization for models
- `management/commands/` - CLI commands (`python manage.py <cmd>`)
- `migrations/` - Database migrations
- `tests/` - Test suite
- `geocode/` - Reverse geocoding functionality
- `feature/` - Feature extraction utilities
### `service/` - Microservices
- `clip_embeddings/` - CLIP model for semantic search
- `face_recognition/` - Face detection and recognition
- `image_captioning/` - Image captioning (im2txt, BLIP)
- `thumbnail/` - Thumbnail generation
- `llm/` - LLM integration for chat features
- `tags/` - Tag extraction (places365)
- `exif/` - EXIF metadata extraction
### `image_similarity/` - Similarity Search
- FAISS-based image retrieval index
- Flask REST API for similarity queries
### Key Files
- `manage.py` - Django management script
- `requirements.txt` - Python dependencies
- `pyproject.toml` - Ruff/project configuration
- `librephotos/settings/` - Django settings (base, dev, prod)
- `librephotos/urls.py` - URL routing
## Environment Variables
Key environment variables (set in Docker or `.env`):
- `DEBUG` - Enable debug mode (0 or 1)
- `SECRET_KEY` - Django secret key
- `DB_*` - Database connection settings
- `MAPBOX_API_KEY` - For map features
- `WEB_CONCURRENCY` - Gunicorn worker count
## Common Patterns
### Adding a New API Endpoint
1. Create/update model in `api/models/`
2. Create serializer in `api/serializers/`
3. Create view in `api/views/`
4. Add URL in `librephotos/urls.py`
5. Run migrations if model changed
### Adding a New Background Job
1. Define task function in `api/all_tasks.py` or relevant module
2. Use `@shared_task` decorator for django-q2
3. Queue with `async_task()` or schedule in admin
### Adding a New ML Model
1. Add model loading in `api/ml_models.py`
2. Create service wrapper in `service/<model_name>/`
3. Integrate with API views as needed
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to LibrePhotos
Thank you for your interest in contributing to LibrePhotos! This guide will help you get started with the development process.
## Table of Contents
- [Development Environment Setup](#development-environment-setup)
- [Docker & Docker Compose](#docker--docker-compose)
- [IDE Recommendations](#ide-recommendations)
- [Code Quality Standards](#code-quality-standards)
- [How to Open a Pull Request](#how-to-open-a-pull-request)
- [Getting Help](#getting-help)
---
## Development Environment Setup
### Prerequisites
- **Git** - for version control
- **Docker** and **Docker Compose** - for running the development environment
- **Node.js 18+** and **Yarn** - for frontend development (optional, if developing outside Docker)
- **Python 3.11+** - for backend development (optional, if developing outside Docker)
### Step 1: Clone the Repositories
Create a directory for the project and clone all required repositories:
**Linux/macOS:**
```bash
export codedir=~/dev/librephotos
mkdir -p $codedir
cd $codedir
git clone https://github.com/LibrePhotos/librephotos-frontend.git
git clone https://github.com/LibrePhotos/librephotos.git
git clone https://github.com/LibrePhotos/librephotos-docker.git
```
**Windows (PowerShell):**
```powershell
$Env:codedir = "$HOME\dev\librephotos"
New-Item -ItemType Directory -Force -Path $Env:codedir
Set-Location $Env:codedir
git clone https://github.com/LibrePhotos/librephotos-frontend.git
git clone https://github.com/LibrePhotos/librephotos.git
git clone https://github.com/LibrePhotos/librephotos-docker.git
```
### Step 2: Configure Environment
Navigate to the `librephotos-docker` directory and create your `.env` file:
```bash
cd librephotos-docker
cp librephotos.env .env
```
Edit the `.env` file and set these critical variables:
```bash
# Path to your photo library (for testing)
scanDirectory=/path/to/your/test/photos
# Path to LibrePhotos data
data=./librephotos/data
# IMPORTANT: Path where you cloned the repositories
codedir=~/dev/librephotos
```
### Step 3: Start the Development Environment
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```
This command:
- Builds development images with hot-reload enabled
- Mounts your local source code into the containers
- Starts all required services (backend, frontend, database, proxy)
Access LibrePhotos at: **http://localhost:3000**
### Rebuilding After Dependency Changes
If you add new dependencies to `requirements.txt` or `package.json`:
```bash
# Rebuild backend
docker compose -f docker-compose.yml -f docker-compose.dev.yml build --no-cache backend
# Rebuild frontend
docker compose -f docker-compose.yml -f docker-compose.dev.yml build --no-cache frontend
# Restart containers
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```
---
## Docker & Docker Compose
### Architecture Overview
LibrePhotos uses a microservices architecture with four main containers:
| Container | Purpose |
|-------------|------------------------------------------------------|
| `backend` | Django API server, ML models, background jobs |
| `frontend` | React web application |
| `proxy` | Nginx reverse proxy, serves static files |
| `db` | PostgreSQL database |
### Useful Docker Commands
```bash
# View running containers
docker compose ps
# View logs (all containers)
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f
# View logs (specific container)
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f backend
# Restart a container
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart backend
# Stop all containers
docker compose -f docker-compose.yml -f docker-compose.dev.yml down
# Stop and remove volumes (fresh start)
docker compose -f docker-compose.yml -f docker-compose.dev.yml down -v
# Execute command in container
docker exec -it backend bash
docker exec -it frontend sh
# Run Django management commands
docker exec -it backend python manage.py migrate
docker exec -it backend python manage.py createsuperuser
```
### Development vs Production
| Aspect | Development (`docker-compose.dev.yml`) | Production (`docker-compose.yml`) |
|-------------------|-----------------------------------------------------|--------------------------------------|
| Source code | Mounted from local filesystem | Built into image |
| Hot reload | ✅ Enabled | ❌ Disabled |
| Debug mode | ✅ `DEBUG=1` | ❌ `DEBUG=0` |
| Build time | Longer (builds from source) | Fast (pulls pre-built images) |
| Additional tools | pgAdmin available on port 3001 | Minimal |
---
## IDE Recommendations
### VS Code (Recommended)
VS Code is the recommended IDE with excellent Docker and Python support.
**Recommended Extensions:**
- **Python** - Python language support
- **Pylance** - Fast Python language server
- **Docker** - Docker container management
- **Remote - Containers** - Develop inside Docker containers
- **ESLint** - JavaScript/TypeScript linting
- **Prettier** - Code formatting
**Workspace Settings:**
The repository includes VS Code settings in `librephotos-docker/vscode/settings.json` that are automatically mounted into the backend container.
**Attaching to Backend Container:**
For the best development experience, you can attach VS Code directly to the running backend container:
1. Install the "Remote - Containers" extension
2. Open Command Palette (`Ctrl+Shift+P`)
3. Run "Remote-Containers: Attach to Running Container"
4. Select the `backend` container
5. Open the `/code` folder
### PyCharm
PyCharm Professional supports Docker interpreters natively:
1. Go to Settings → Project → Python Interpreter
2. Add Interpreter → On Docker Compose
3. Select the `docker-compose.yml` and `docker-compose.dev.yml` files
4. Choose the `backend` service
### Other IDEs
Any IDE with Python and TypeScript support will work. Key requirements:
- Python 3.11+ interpreter support
- ESLint/Prettier integration for frontend
- Docker integration (optional but helpful)
---
## Code Quality Standards
### Backend (Python/Django)
**Linting and Formatting:**
We use `ruff` for linting and formatting (configured in `pyproject.toml`):
```bash
# Inside the backend container
cd /code
pip install ruff
ruff check .
ruff format .
```
**Pre-commit Hooks:**
Install pre-commit hooks for automatic formatting:
```bash
pip install pre-commit
pre-commit install
```
**Code Style:**
- Line length: 88 characters
- Use type hints where practical
- Follow PEP 8 naming conventions
- Write docstrings for public functions
### Frontend (React/TypeScript)
**Linting and Formatting:**
```bash
# Inside frontend container or locally
yarn lint:error # Check for errors
yarn lint:warning:fix # Fix linting issues
```
**Code Style:**
- Line length: 120 characters
- Use Prettier for formatting (configured in `prettier.config.cjs`)
- Prefer TypeScript types over interfaces (project convention)
- Use functional components with hooks
- Follow the slice pattern for Redux state management
### Pull Request Checklist
Before submitting a PR, ensure:
- [ ] Code follows the project's style guidelines
- [ ] All linting passes without errors
- [ ] New features include tests (if applicable)
- [ ] Documentation is updated (if needed)
- [ ] Commit messages are clear and descriptive
- [ ] The PR addresses a single concern/feature
---
## How to Open a Pull Request
### Step 1: Fork the Repository
1. Navigate to the repository you want to contribute to on GitHub
2. Click the "Fork" button in the top right corner
3. Clone your fork locally:
```bash
git clone https://github.com/YOUR-USERNAME/librephotos.git
cd librephotos
git remote add upstream https://github.com/LibrePhotos/librephotos.git
```
### Step 2: Create a Feature Branch
Always create a new branch for your work:
```bash
git checkout -b feature/my-awesome-feature
# or
git checkout -b fix/bug-description
```
### Step 3: Make Your Changes
1. Write your code following the code quality standards above
2. Test your changes thoroughly
3. Commit your changes with descriptive messages:
```bash
git add .
git commit -m "feat: add support for XYZ"
# or
git commit -m "fix: resolve issue with ABC"
```
**Commit Message Guidelines:**
- Use present tense ("add feature" not "added feature")
- Keep the first line under 72 characters
- Reference issues when applicable: `fix: resolve login bug (#123)`
### Step 4: Push and Create Pull Request
```bash
git push origin feature/my-awesome-feature
```
Then on GitHub:
1. Navigate to your fork
2. Click "Compare & pull request"
3. Fill out the PR template with:
- Clear description of changes
- Reference to related issues
- Screenshots (for UI changes)
- Testing instructions
### Step 5: Respond to Review
- Address reviewer feedback promptly
- Make requested changes in new commits
- Be open to suggestions and discussion
---
## Getting Help
- **Discord:** [Join our Discord server](https://discord.gg/xwRvtSDGWb)
- **GitHub Issues:** [Report bugs or request features](https://github.com/LibrePhotos/librephotos/issues)
- **Documentation:** [docs.librephotos.com](https://docs.librephotos.com)
- **Development Videos:** [Niaz Faridani-Rad's YouTube channel](https://www.youtube.com/channel/UCZJ2pk2BPKxwbuCV9LWDR0w)
### Debugging Tips
**Backend (Django):**
Use `pdb` for debugging:
```python
import pdb; pdb.set_trace()
```
Then attach to the container:
```bash
docker attach $(docker ps --filter name=backend -q)
```
Press `Ctrl+P` followed by `Ctrl+Q` to detach without stopping the container.
**Frontend (React):**
- Use React DevTools browser extension
- Use Redux DevTools for state debugging
- Enable WDYR by setting `WDYR=True` in your `.env`
**API Documentation:**
After starting LibrePhotos, access the API docs at:
- Swagger: http://localhost:3000/api/swagger
- ReDoc: http://localhost:3000/api/redoc
---
## License
By contributing to LibrePhotos, you agree that your contributions will be licensed under the MIT License.
Thank you for contributing! 🎉
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2017 Hooram Nam
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
[][discord] [](https://librephotos.com/)
[](https://docs.librephotos.com/) [](https://github.com/LibrePhotos/librephotos/graphs/contributors) [](https://github.com/LibrePhotos/librephotos/blob/dev/LICENSE)
<a href="https://hosted.weblate.org/engage/librephotos/">
<img src="https://hosted.weblate.org/widgets/librephotos/-/librephotos-frontend/svg-badge.svg" alt="Translation status" />
</a>
# LibrePhotos

<sub>Mockup designed by rawpixel.com / Freepik</sub>
A self-hosted, open-source photo management service with automatic face recognition, object detection, and semantic search — powered by modern machine learning.
- **Stable** demo is available here: https://demo1.librephotos.com/ . User is ```demo```, password is ```demo1234``` (with sample images).
- Latest **development** demo is available here: https://demo2.librephotos.com/ (same user/password)
- You can watch development videos on [Niaz Faridani-Rad's channel](https://www.youtube.com/channel/UCZJ2pk2BPKxwbuCV9LWDR0w)
- You can join our [Discord][discord].
## Installation
Step-by-step installation instructions are available in our [documentation](https://docs.librephotos.com/docs/installation/standard-install).
### System Requirements
| Resource | Minimum | Recommended |
|----------|---------|-------------|
| RAM | 4 GB | 8 GB+ |
| Storage | 10 GB (plus your photo library) | SSD recommended |
| CPU | 2 cores | 4+ cores |
| OS | Any Docker-compatible OS | Linux |
> **Note:** Machine learning features (face recognition, scene classification, image captioning) are memory-intensive. 8 GB+ RAM is strongly recommended for smooth operation.
## Features
- Support for all types of photos including raw photos
- Support for videos
- Timeline view
- Scans pictures on the file system
- Multiuser support
- Generate albums based on events like "Thursday in Berlin"
- Face recognition / Face classification
- Reverse geocoding
- Object / Scene detection
- Semantic image search
- Search by metadata
## Tech Stack
### Backend
- **Framework:** [Django 5](https://www.djangoproject.com/) with [Django REST Framework](https://www.django-rest-framework.org/)
- **Database:** [PostgreSQL](https://www.postgresql.org/)
- **Task Queue:** [Django-Q2](https://github.com/django-q2/django-q2)
- **Image Conversion:** [ImageMagick](https://github.com/ImageMagick/ImageMagick)
- **Video Conversion:** [FFmpeg](https://github.com/FFmpeg/FFmpeg)
- **Exif Support:** [ExifTool](https://github.com/exiftool/exiftool)
### Frontend
- **UI:** [React 18](https://react.dev/) with [TypeScript](https://www.typescriptlang.org/)
- **Build Tool:** [Vite](https://vite.dev/)
- **Component Library:** [Mantine](https://mantine.dev/)
- **Routing:** [TanStack Router](https://tanstack.com/router)
- **Data Fetching:** [TanStack Query](https://tanstack.com/query)
- **Maps:** [MapLibre GL](https://maplibre.org/)
- **Internationalization:** [i18next](https://www.i18next.com/)
### Machine Learning
- **Face detection:** [face_recognition](https://github.com/ageitgey/face_recognition)
- **Face classification/clustering:** [scikit-learn](https://scikit-learn.org/) and [hdbscan](https://github.com/scikit-learn-contrib/hdbscan)
- **Image captioning:** [im2txt](https://github.com/HughKu/Im2txt)
- **Scene classification:** [places365](http://places.csail.mit.edu/)
- **Reverse geocoding:** [geopy](https://github.com/geopy/geopy)
### Infrastructure
- **Deployment:** [Docker](https://www.docker.com/) & [Docker Compose](https://docs.docker.com/compose/)
- **Reverse Proxy:** [Nginx](https://nginx.org/)
### API Documentation
After starting LibrePhotos, interactive API docs are available at:
- **Swagger UI:** `http://localhost:3000/api/swagger`
- **ReDoc:** `http://localhost:3000/api/redoc`
## How to help out
- ⭐ **Star** this repository if you like this project!
- 🚀 **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.
- 🗒️ **Documentation**: Improving the documentation is as simple as submitting a pull request [here](https://github.com/LibrePhotos/librephotos.docs)
- 🧪 **Testing**: If you want to help find bugs, use the ```dev``` tag and update it regularly. If you find a bug, open an issue.
- 🧑🤝🧑 **Outreach**: Talk about this project with other people and help them to get started too!
- 🌐 **Translations**: Make LibrePhotos accessible to more people with [weblate](https://hosted.weblate.org/engage/librephotos/).
- 💸 [**Donate**](https://github.com/sponsors/derneuere) to the developers of LibrePhotos
## Related Projects
| Repository | Description |
|------------|-------------|
| [librephotos-frontend](https://github.com/LibrePhotos/librephotos-frontend) | React/TypeScript web frontend |
| [librephotos-docker](https://github.com/LibrePhotos/librephotos-docker) | Docker Compose deployment configurations |
| [librephotos.docs](https://github.com/LibrePhotos/librephotos.docs) | Documentation website source |
| [librephotos-mobile](https://github.com/LibrePhotos/librephotos-mobile) | Mobile application |
## License
This project is licensed under the [MIT License](LICENSE).
[discord]: https://discord.gg/xwRvtSDGWb
================================================
FILE: api/__init__.py
================================================
default_app_config = "api.apps.ApiConfig"
================================================
FILE: api/admin.py
================================================
from django.contrib import admin
from django_q.tasks import AsyncTask
from .models import (
AlbumAuto,
AlbumDate,
AlbumPlace,
AlbumThing,
AlbumUser,
Cluster,
Face,
File,
LongRunningJob,
Person,
Photo,
User,
Thumbnail,
)
def deduplicate_faces_function(queryset):
for photo in queryset:
# Get all faces in the photo
faces = Face.objects.filter(photo=photo)
# Check if there are any faces which have similar bounding boxes
for face in faces:
margin = int((face.location_right - face.location_left) * 0.05)
similar_faces = Face.objects.filter(
photo=photo,
location_top__lte=face.location_top + margin,
location_top__gte=face.location_top - margin,
location_right__lte=face.location_right + margin,
location_right__gte=face.location_right - margin,
location_bottom__lte=face.location_bottom + margin,
location_bottom__gte=face.location_bottom - margin,
location_left__lte=face.location_left + margin,
location_left__gte=face.location_left - margin,
)
if len(similar_faces) > 1:
# Divide between faces with a person label and faces without
faces_with_person_label = []
faces_without_person_label = []
for similar_face in similar_faces:
if similar_face.person:
faces_with_person_label.append(similar_face)
else:
faces_without_person_label.append(similar_face)
# If there are faces with a person label, keep the first one and delete the rest
for similar_face in faces_with_person_label[1:]:
similar_face.delete()
# If there are faces with a person label, delete all of them
if len(faces_with_person_label) > 0:
for similar_face in faces_without_person_label:
similar_face.delete()
# Otherwise, keep the first face and delete the rest
else:
for similar_face in faces_without_person_label[1:]:
similar_face.delete()
@admin.register(Face)
class FaceAdmin(admin.ModelAdmin):
list_display = (
"id",
"cluster_person",
"cluster_probability",
"classification_person",
"cluster",
"photo",
"person",
)
list_filter = ("person", "cluster")
@admin.register(Photo)
class PhotoAdmin(admin.ModelAdmin):
actions = ["deduplicate_faces"]
list_display = [
"image_hash",
"owner",
"main_file",
"last_modified",
"added_on",
"size",
]
list_filter = ["owner"]
def deduplicate_faces(self, request, queryset):
AsyncTask(
deduplicate_faces_function,
queryset=queryset,
).run()
@admin.register(Thumbnail)
class ThumbnailAdmin(admin.ModelAdmin):
list_display = ["photo", "aspect_ratio"]
raw_id_fields = ["photo"]
admin.site.register(Person)
admin.site.register(AlbumAuto)
admin.site.register(AlbumUser)
admin.site.register(AlbumThing)
admin.site.register(AlbumDate)
admin.site.register(AlbumPlace)
admin.site.register(Cluster)
admin.site.register(LongRunningJob)
admin.site.register(File)
admin.site.register(User)
================================================
FILE: api/all_tasks.py
================================================
import io
import os
import zipfile
from django.conf import settings
from django.utils import timezone
from django_q.tasks import AsyncTask, schedule
from api import util
from api.models.long_running_job import LongRunningJob
def create_download_job(job_type, user, photos, filename):
lrj = LongRunningJob.create_job(
user=user,
job_type=job_type,
)
if job_type == LongRunningJob.JOB_DOWNLOAD_PHOTOS:
AsyncTask(
zip_photos_task, job_id=lrj.job_id, user=user, photos=photos, filename=filename
).run()
return lrj.job_id
def zip_photos_task(job_id, user, photos, filename):
lrj = LongRunningJob.objects.get(job_id=job_id)
lrj.start()
count = len(photos)
lrj.update_progress(current=0, target=count)
output_directory = os.path.join(settings.MEDIA_ROOT, "zip")
zip_file_name = filename
done_count = 0
try:
if not os.path.exists(output_directory):
os.mkdir(output_directory)
mf = io.BytesIO()
files_added = {} # Track files by path to avoid duplicates
for photo in photos:
done_count = done_count + 1
# Collect all files for this photo.
# NOTE: main_file is not guaranteed to be included in Photo.files.
all_files = []
if getattr(photo, "main_file", None) is not None:
all_files.append(photo.main_file)
all_files.extend(list(photo.files.all()))
# Back-compat: some datasets may still represent RAW+JPEG / Live Photo variants
# as deprecated stacks. Include those stack members' files too.
try:
variant_stacks = photo.stacks.filter(
stack_type__in=["raw_jpeg", "live_photo"]
).prefetch_related("photos", "photos__files", "photos__main_file")
for stack in variant_stacks:
for stack_photo in stack.photos.all():
if getattr(stack_photo, "main_file", None) is not None:
all_files.append(stack_photo.main_file)
all_files.extend(list(stack_photo.files.all()))
except Exception:
# If stacks aren't available for some reason, just proceed with variants.
pass
# Include embedded media variants for every collected file (not just main_file)
for file_obj in list(all_files):
try:
if file_obj and file_obj.embedded_media.exists():
all_files.extend(list(file_obj.embedded_media.all()))
except Exception:
continue
# Add each file to the zip
for file_obj in all_files:
if not file_obj or not file_obj.path:
continue
# Skip if file doesn't exist on disk
if not os.path.exists(file_obj.path):
util.logger.warning(f"File not found, skipping: {file_obj.path}")
continue
# Skip if already added (avoid duplicates)
if file_obj.path in files_added:
continue
file_name = os.path.basename(file_obj.path)
# Handle duplicate filenames in the zip
if file_name in files_added.values():
# Find a unique name by prepending a counter
counter = 1
base_name, ext = os.path.splitext(file_name)
while f"{base_name}_{counter}{ext}" in files_added.values():
counter += 1
file_name = f"{base_name}_{counter}{ext}"
files_added[file_obj.path] = file_name
with zipfile.ZipFile(mf, mode="a", compression=zipfile.ZIP_DEFLATED) as zf:
zf.write(file_obj.path, arcname=file_name)
lrj.update_progress(current=done_count, target=count)
with open(os.path.join(output_directory, zip_file_name), "wb") as output_file:
output_file.write(mf.getvalue())
except Exception as e:
util.logger.error(f"Error while converting files to zip: {e}")
lrj.complete()
# scheduling a task to delete the zip file after a day
execution_time = timezone.now() + timezone.timedelta(days=1)
schedule("api.all_tasks.delete_zip_file", filename, next_run=execution_time)
return os.path.join(output_directory, zip_file_name)
def delete_zip_file(filename):
file_path = os.path.join(settings.MEDIA_ROOT, "zip", filename)
try:
if not os.path.exists(file_path):
util.logger.error(f"Error while deleting file not found at : {file_path}")
return
else:
os.remove(file_path)
util.logger.info(f"file deleted sucessfully at path : {file_path}")
return
except Exception as e:
util.logger.error(f"Error while deleting file: {e}")
return e
================================================
FILE: api/api_util.py
================================================
import os
import random
import stat
from api.models import (
LongRunningJob,
Photo,
)
from api.serializers.job import LongRunningJobSerializer
from api.util import logger
def get_current_job():
job_detail = None
running_job = (
LongRunningJob.objects.filter(finished=False).order_by("-started_at").first()
)
if running_job:
job_detail = LongRunningJobSerializer(running_job).data
return job_detail
def shuffle(list):
random.shuffle(list)
return list
def is_hidden(filepath):
name = os.path.basename(os.path.abspath(filepath))
return name.startswith(".") or has_hidden_attribute(filepath)
def has_hidden_attribute(filepath):
try:
return bool(os.stat(filepath).st_file_attributes & stat.FILE_ATTRIBUTE_HIDDEN)
except Exception:
return False
def path_to_dict(path, recurse=2):
d = {"title": os.path.basename(path), "absolute_path": path}
if recurse > 0:
d["children"] = [
path_to_dict(os.path.join(path, x), recurse - 1)
for x in os.scandir(path)
if os.path.isdir(os.path.join(path, x))
and not is_hidden(os.path.join(path, x))
]
else:
d["children"] = []
# sort children by title alphabetically (case insensitive)
d["children"] = sorted(d["children"], key=lambda k: k["title"].lower())
return d
def get_search_term_examples(user):
default_search_terms = [
"for people",
"for places",
"for things",
"for time",
"for file path or file name",
]
possible_ids = list(
Photo.objects.filter(owner=user)
.exclude(caption_instance__captions_json={})
.exclude(caption_instance__captions_json__isnull=True)[:1000]
.values_list("image_hash", flat=True)
)
if len(possible_ids) > 99:
possible_ids = random.choices(possible_ids, k=100)
logger.info(f"{len(possible_ids)} possible ids")
try:
samples = (
Photo.objects.filter(owner=user)
.exclude(caption_instance__captions_json={})
.exclude(caption_instance__captions_json__isnull=True)
.filter(image_hash__in=possible_ids)
.prefetch_related("faces")
.prefetch_related("faces__person")
.prefetch_related("caption_instance")
.all()
)
except ValueError:
return default_search_terms
search_data = []
search_terms = default_search_terms
logger.info("Getting search terms for user %s", user.id)
logger.info("Found %s photos", len(samples))
for p in samples:
faces = p.faces.all()
terms_loc = ""
if (
p.geolocation_json
and p.geolocation_json != {}
and "features" in p.geolocation_json
):
terms_loc = [
f["text"]
for f in p.geolocation_json["features"][-5:]
if "text" in f and not f["text"].isdigit()
]
terms_time = ""
if p.exif_timestamp:
terms_time = [str(p.exif_timestamp.year)]
terms_people = []
if p.faces.count() > 0:
terms_people = [
f.person.name.split(" ")[0] if f.person else "" for f in faces
]
terms_things = ""
if (
p.caption_instance
and p.caption_instance.captions_json
and p.caption_instance.captions_json.get("places365") is not None
):
terms_things = p.caption_instance.captions_json["places365"]["categories"]
terms = {
"loc": terms_loc,
"time": terms_time,
"people": terms_people,
"things": terms_things,
}
search_data.append(terms)
search_terms = []
for datum in search_data:
term_time = ""
term_thing = ""
term_loc = ""
term_people = ""
if datum["loc"]:
term_loc = random.choice(datum["loc"])
search_terms.append(term_loc)
if datum["time"]:
term_time = random.choice(datum["time"])
search_terms.append(term_time)
if datum["things"]:
term_thing = random.choice(datum["things"])
search_terms.append(term_thing)
if datum["people"]:
term_people = random.choice(datum["people"])
search_terms.append(term_people)
search_term_loc_people = " ".join(shuffle([term_loc, term_people]))
if random.random() > 0.3:
search_terms.append(search_term_loc_people)
search_term_time_people = " ".join(shuffle([term_time, term_people]))
if random.random() > 0.3:
search_terms.append(search_term_time_people)
search_term_people_thing = " ".join(shuffle([term_people, term_thing]))
if random.random() > 0.9:
search_terms.append(search_term_people_thing)
search_term_all = " ".join(
shuffle([term_loc, term_people, term_time, term_thing])
)
if random.random() > 0.95:
search_terms.append(search_term_all)
search_term_loc_time = " ".join(shuffle([term_loc, term_time]))
if random.random() > 0.3:
search_terms.append(search_term_loc_time)
search_term_loc_thing = " ".join(shuffle([term_loc, term_thing]))
if random.random() > 0.9:
search_terms.append(search_term_loc_thing)
search_term_time_thing = " ".join(shuffle([term_time, term_thing]))
if random.random() > 0.9:
search_terms.append(search_term_time_thing)
return list(filter(lambda x: len(x), set([x.strip() for x in search_terms])))
================================================
FILE: api/apps.py
================================================
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = "api"
verbose_name = "LibrePhotos"
================================================
FILE: api/autoalbum.py
================================================
from datetime import datetime, timedelta
import numpy as np
import pytz
from django.db.models import Q
from api.models import (
AlbumAuto,
AlbumDate,
AlbumPlace,
AlbumThing,
AlbumUser,
Face,
File,
LongRunningJob,
Photo,
)
from api.util import logger
def regenerate_event_titles(user, job_id):
lrj = LongRunningJob.get_or_create_job(
user=user,
job_type=LongRunningJob.JOB_GENERATE_AUTO_ALBUM_TITLES,
job_id=job_id,
)
try:
aus = AlbumAuto.objects.filter(owner=user).prefetch_related("photos")
target_count = len(aus)
for idx, au in enumerate(aus):
logger.info(f"job {job_id}: {idx}")
au._generate_title()
au.save()
lrj.update_progress(current=idx + 1, target=target_count)
lrj.complete()
logger.info(f"job {job_id}: updated lrj entry to db")
except Exception as e:
logger.exception("An error occurred")
lrj.fail(error=e)
return 1
def generate_event_albums(user, job_id):
lrj = LongRunningJob.get_or_create_job(
user=user,
job_type=LongRunningJob.JOB_GENERATE_AUTO_ALBUMS,
job_id=job_id,
)
try:
photos = (
Photo.objects.filter(Q(owner=user))
.exclude(Q(exif_timestamp=None))
.only("exif_timestamp")
)
def group(photos, dt=timedelta(hours=6)):
photos_with_timestamp = sorted(photos, key=lambda p: p.exif_timestamp)
groups = []
for idx, photo in enumerate(photos_with_timestamp):
if len(groups) == 0:
groups.append([])
groups[-1].append(photo)
# Photos are sorted by timestamp, so we can just check the last photo of the last group
# to see if it is within the time delta
elif photo.exif_timestamp - groups[-1][-1].exif_timestamp < dt:
groups[-1].append(photo)
# If the photo is not within the time delta, we create a new group
else:
groups.append([])
groups[-1].append(photo)
return groups
# Group images that are on the same 1 day and 12 hours interval
groups = group(photos, dt=timedelta(days=1, hours=12))
target_count = len(groups)
logger.info(
f"job {job_id}: made {target_count} groups out of {len(photos)} images"
)
album_locations = []
date_format = "%Y:%m:%d %H:%M:%S"
for idx, group in enumerate(groups):
key = group[0].exif_timestamp - timedelta(hours=11, minutes=59)
lastKey = group[-1].exif_timestamp + timedelta(hours=11, minutes=59)
logger.info(str(key.date) + " - " + str(lastKey.date))
logger.info(
f"job {job_id}: processing auto album with date: "
+ key.strftime(date_format)
+ " to "
+ lastKey.strftime(date_format)
)
items = group
if len(group) >= 2:
qs = AlbumAuto.objects.filter(owner=user).filter(
timestamp__range=(key, lastKey)
)
if qs.count() == 0:
album = AlbumAuto(
created_on=datetime.utcnow().replace(tzinfo=pytz.utc),
owner=user,
)
album.timestamp = key
album.save()
logger.info(f"job {job_id}: generate auto album {album.id}")
locs = []
for item in items:
album.photos.add(item)
item.save()
if item.exif_gps_lat and item.exif_gps_lon:
locs.append([item.exif_gps_lat, item.exif_gps_lon])
if len(locs) > 0:
album_location = np.mean(np.array(locs), 0)
album_locations.append(album_location)
album.gps_lat = album_location[0]
album.gps_lon = album_location[1]
else:
album_locations.append([])
album._generate_title()
album.save()
continue
if qs.count() == 1:
album = qs.first()
logger.info(f"job {job_id}: update auto album {album.id}")
for item in items:
if item in album.photos.all():
continue
album.photos.add(item)
item.save()
album._generate_title()
album.save()
continue
if qs.count() > 1:
# To-Do: Merge both auto albums
logger.info(
f"job {job_id}: found multiple auto albums for date {key.strftime(date_format)}"
)
continue
lrj.update_progress(current=idx + 1, target=target_count)
lrj.complete()
except Exception as e:
logger.exception("An error occurred")
lrj.fail(error=e)
return 1
# To-Do: This does not belong here
def delete_missing_photos(user, job_id):
lrj = LongRunningJob.get_or_create_job(
user=user,
job_type=LongRunningJob.JOB_DELETE_MISSING_PHOTOS,
job_id=job_id,
)
try:
missing_photos = Photo.objects.filter(
Q(owner=user) & Q(files=None) | Q(main_file=None)
)
for missing_photo in missing_photos:
album_dates = AlbumDate.objects.filter(photos=missing_photo)
for album_date in album_dates:
album_date.photos.remove(missing_photo)
album_things = AlbumThing.objects.filter(photos=missing_photo)
for album_thing in album_things:
album_thing.photos.remove(missing_photo)
album_places = AlbumPlace.objects.filter(photos=missing_photo)
for album_place in album_places:
album_place.photos.remove(missing_photo)
album_users = AlbumUser.objects.filter(photos=missing_photo)
for album_user in album_users:
album_user.photos.remove(missing_photo)
faces = Face.objects.filter(photo=missing_photo)
faces.delete()
# To-Do: Remove thumbnails
missing_photos.delete()
missing_files = File.objects.filter(Q(hash__endswith=user) & Q(missing=True))
missing_files.delete()
lrj.complete()
except Exception as e:
logger.exception("An error occurred")
lrj.fail(error=e)
return 1
================================================
FILE: api/background_tasks.py
================================================
from tqdm import tqdm
from django.db import models
from api.models import Photo
from api.models.photo_caption import PhotoCaption
from api.util import logger
def generate_captions(overwrite=False):
if overwrite:
photos = Photo.objects.all()
else:
# Find photos that don't have search captions in PhotoSearch model
photos = Photo.objects.filter(
models.Q(search_instance__isnull=True)
| models.Q(search_instance__search_captions__isnull=True)
)
logger.info("%d photos to be processed for caption generation" % photos.count())
for photo in photos:
logger.info("generating captions for %s" % photo.main_file.path)
caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)
caption_instance.generate_tag_captions()
photo.save()
def geolocate(overwrite=False):
if overwrite:
photos = Photo.objects.all()
else:
photos = Photo.objects.filter(geolocation_json={})
logger.info("%d photos to be geolocated" % photos.count())
for photo in photos:
try:
logger.info("geolocating %s" % photo.main_file.path)
photo._geolocate()
photo._add_location_to_album_dates()
except Exception:
logger.exception("could not geolocate photo: %s", photo)
def add_photos_to_album_things():
photos = Photo.objects.all()
for photo in tqdm(photos):
photo._add_to_album_place()
================================================
FILE: api/batch_jobs.py
================================================
import os
from django.db.models import Q
from api import util
from api.image_similarity import build_image_similarity_index
from api.models.long_running_job import LongRunningJob
from api.models.photo import Photo
from api.semantic_search import create_clip_embeddings
def batch_calculate_clip_embedding(user):
import torch
lrj = LongRunningJob.create_job(
user=user,
job_type=LongRunningJob.JOB_CALCULATE_CLIP_EMBEDDINGS,
start_now=True,
)
count = Photo.objects.filter(
Q(owner=user) & Q(clip_embeddings__isnull=True)
).count()
lrj.update_progress(current=0, target=count)
if not torch.cuda.is_available():
num_threads = 1
torch.set_num_threads(num_threads)
os.environ["OMP_NUM_THREADS"] = str(num_threads)
else:
torch.multiprocessing.set_start_method("spawn", force=True)
BATCH_SIZE = 64
util.logger.info(f"Using threads: {torch.get_num_threads()}")
done_count = 0
while done_count < count:
try:
objs = list(
Photo.objects.filter(Q(owner=user) & Q(clip_embeddings__isnull=True))[
:BATCH_SIZE
]
)
done_count += len(objs)
if len(objs) == 0:
break
valid_objs = []
for obj in objs:
# Thumbnail could have been deleted
if obj.thumbnail.thumbnail_big and os.path.exists(
obj.thumbnail.thumbnail_big.path
):
valid_objs.append(obj)
imgs = list(map(lambda obj: obj.thumbnail.thumbnail_big.path, valid_objs))
if len(valid_objs) == 0:
continue
imgs_emb, magnitudes = create_clip_embeddings(imgs)
for obj, img_emb, magnitude in zip(valid_objs, imgs_emb, magnitudes):
obj.clip_embeddings = img_emb.tolist()
obj.clip_embeddings_magnitude = magnitude
obj.save()
except Exception as e:
util.logger.error(f"Error calculating clip embeddings: {e}")
lrj.update_progress(current=done_count, target=count)
build_image_similarity_index(user)
lrj.complete()
================================================
FILE: api/burst_detection_rules.py
================================================
"""
Burst detection rules module for grouping photos taken in rapid succession.
This module provides a rules-based system for detecting burst sequences,
following the same pattern as date_time_extractor.py. Rules are stored
as JSON in each user's profile and applied sequentially.
Rules are divided into two categories:
- Hard criteria: Deterministic detection based on EXIF data and filename patterns
(e.g., BurstMode tag, SequenceNumber, filename patterns like IMG_001_BURST001)
- Soft criteria: Estimation based on timestamp proximity and visual similarity
(e.g., photos within 2 seconds of each other from the same camera)
By default, only hard criteria rules are enabled.
"""
import json
import os
import re
from datetime import timedelta
from api.metadata.tags import Tags
from api.util import logger
class BurstRuleTypes:
"""Types of burst detection rules."""
# Hard criteria (deterministic)
EXIF_BURST_MODE = "exif_burst_mode"
EXIF_SEQUENCE_NUMBER = "exif_sequence_number"
FILENAME_PATTERN = "filename_pattern"
# Soft criteria (estimation)
TIMESTAMP_PROXIMITY = "timestamp_proximity"
VISUAL_SIMILARITY = "visual_similarity"
class BurstRuleCategory:
"""Categories for burst detection rules."""
HARD = "hard" # Deterministic (EXIF, filenames)
SOFT = "soft" # Estimation (timestamps, visual similarity)
# Predefined filename patterns for burst detection
BURST_FILENAME_PATTERNS = {
# Pattern name: (regex, description)
"burst_suffix": (
r"_BURST\d+",
"Files with _BURST followed by numbers (e.g., IMG_001_BURST001.jpg)",
),
"sequence_suffix": (
r"_\d{3,}$",
"Files ending with 3+ digit sequence number (e.g., IMG_001.jpg, IMG_002.jpg)",
),
"bracketed_sequence": (
r"\(\d+\)$",
"Files with bracketed numbers at end (e.g., photo (1).jpg, photo (2).jpg)",
),
"samsung_burst": (r"_\d{3}_COVER", "Samsung burst cover images"),
"iphone_burst": (r"IMG_\d{4}_\d+", "iPhone burst sequence pattern"),
}
class BurstDetectionRule:
"""
A rule for detecting burst sequences.
Each rule has:
- id: Unique identifier
- name: Human-readable name
- rule_type: One of BurstRuleTypes
- category: 'hard' or 'soft' (for UI grouping)
- enabled: Whether the rule is active
- is_default: Whether this is a default rule
- Type-specific parameters (e.g., interval_ms for timestamp_proximity)
Additionally, each rule can have conditions:
- condition_path: Regex to match full path
- condition_filename: Regex to match filename
- condition_exif: Format: "TAG_NAME//regex_pattern"
"""
def __init__(self, params):
self.id = params.get("id")
self.name = params.get("name", "Unnamed rule")
self.rule_type = params["rule_type"]
self.category = params.get("category", BurstRuleCategory.HARD)
self.enabled = params.get("enabled", True)
self.is_default = params.get("is_default", True)
self.params = params
def get_required_exif_tags(self):
"""Return set of EXIF tags needed by this rule."""
tags = set()
# Add condition tag if present
condition_exif = self.params.get("condition_exif")
if condition_exif:
tag_name = condition_exif.split("//", maxsplit=1)[0]
tags.add(tag_name)
# Add rule-specific tags
if self.rule_type == BurstRuleTypes.EXIF_BURST_MODE:
tags.add(Tags.BURST_MODE)
tags.add(Tags.CONTINUOUS_DRIVE)
elif self.rule_type == BurstRuleTypes.EXIF_SEQUENCE_NUMBER:
tags.add(Tags.SEQUENCE_NUMBER)
tags.add(Tags.IMAGE_NUMBER)
tags.add(Tags.SUBSEC_TIME_ORIGINAL)
return tags
def _check_condition_path(self, path):
"""Check if path matches condition_path regex."""
condition = self.params.get("condition_path")
if condition:
return re.search(condition, path) is not None
return True
def _check_condition_filename(self, path):
"""Check if filename matches condition_filename regex."""
condition = self.params.get("condition_filename")
if condition:
filename = os.path.basename(path)
return re.search(condition, filename) is not None
return True
def _check_condition_exif(self, exif_tags):
"""Check if EXIF tag value matches condition_exif pattern."""
condition = self.params.get("condition_exif")
if not condition:
return True
parts = condition.split("//", maxsplit=1)
if len(parts) != 2:
logger.warning(f"Invalid condition_exif format: {condition}")
return False
tag_name, pattern = parts
tag_value = exif_tags.get(tag_name)
if not tag_value:
return False
return re.search(pattern, str(tag_value)) is not None
def check_conditions(self, path, exif_tags):
"""Check all conditions for this rule."""
return (
self._check_condition_path(path)
and self._check_condition_filename(path)
and self._check_condition_exif(exif_tags)
)
def is_burst_photo(self, photo, exif_tags):
"""
Check if a photo is part of a burst sequence according to this rule.
Args:
photo: Photo model instance
exif_tags: Dict of EXIF tag name -> value
Returns:
Tuple of (is_burst: bool, group_key: str or None)
group_key can be used to group photos into the same burst
"""
if not self.enabled:
return False, None
path = photo.main_file.path if photo.main_file else ""
if not self.check_conditions(path, exif_tags):
return False, None
if self.rule_type == BurstRuleTypes.EXIF_BURST_MODE:
return self._check_exif_burst_mode(photo, exif_tags)
elif self.rule_type == BurstRuleTypes.EXIF_SEQUENCE_NUMBER:
return self._check_exif_sequence_number(photo, exif_tags)
elif self.rule_type == BurstRuleTypes.FILENAME_PATTERN:
return self._check_filename_pattern(photo, exif_tags)
else:
# Soft rules don't use is_burst_photo - they use group_by_proximity
return False, None
def _check_exif_burst_mode(self, photo, exif_tags):
"""Check if EXIF BurstMode or ContinuousDrive indicates burst."""
burst_mode = exif_tags.get(Tags.BURST_MODE)
continuous_drive = exif_tags.get(Tags.CONTINUOUS_DRIVE)
# BurstMode: 1 = On (Canon, etc.)
if burst_mode and str(burst_mode) in ("1", "On", "True", "Yes"):
# Group by timestamp (rounded to second) + camera model
camera = exif_tags.get(Tags.CAMERA, "unknown")
timestamp = photo.exif_timestamp
if timestamp:
group_key = f"burst_{camera}_{timestamp.strftime('%Y%m%d_%H%M%S')}"
return True, group_key
return True, None
# ContinuousDrive: Continuous, etc.
if continuous_drive and continuous_drive.lower() in ("continuous", "on", "1"):
camera = exif_tags.get(Tags.CAMERA, "unknown")
timestamp = photo.exif_timestamp
if timestamp:
group_key = f"burst_{camera}_{timestamp.strftime('%Y%m%d_%H%M%S')}"
return True, group_key
return True, None
return False, None
def _check_exif_sequence_number(self, photo, exif_tags):
"""Check if photo has sequence number indicating burst."""
sequence_num = exif_tags.get(Tags.SEQUENCE_NUMBER)
# If we have a sequence number, it's likely part of a burst
if sequence_num is not None:
try:
int(sequence_num) # Validate it's a valid number
# Sequence numbers suggest burst
# Group by directory + base timestamp
camera = exif_tags.get(Tags.CAMERA, "unknown")
timestamp = photo.exif_timestamp
if timestamp:
# Round to same second for grouping
group_key = f"seq_{camera}_{timestamp.strftime('%Y%m%d_%H%M%S')}"
return True, group_key
return True, None
except (ValueError, TypeError):
pass
return False, None
def _check_filename_pattern(self, photo, exif_tags):
"""Check if filename matches burst pattern."""
if not photo.main_file:
return False, None
filename = os.path.basename(photo.main_file.path)
basename = os.path.splitext(filename)[0]
# Get pattern type from params, default to checking all patterns
pattern_type = self.params.get("pattern_type", "all")
custom_pattern = self.params.get("custom_pattern")
if custom_pattern:
if re.search(custom_pattern, basename):
# Extract base name for grouping (remove trailing numbers/burst suffix)
base = re.sub(r"(_BURST\d+|_\d{3,}|\(\d+\))$", "", basename)
directory = os.path.dirname(photo.main_file.path)
group_key = f"filename_{directory}_{base}"
return True, group_key
elif pattern_type == "all":
# Check all predefined patterns
for pattern_name, (pattern, _) in BURST_FILENAME_PATTERNS.items():
if re.search(pattern, basename, re.IGNORECASE):
base = re.sub(
r"(_BURST\d+|_\d{3,}|\(\d+\)|_COVER)$",
"",
basename,
flags=re.IGNORECASE,
)
directory = os.path.dirname(photo.main_file.path)
group_key = f"filename_{directory}_{base}"
return True, group_key
else:
# Check specific pattern
if pattern_type in BURST_FILENAME_PATTERNS:
pattern, _ = BURST_FILENAME_PATTERNS[pattern_type]
if re.search(pattern, basename, re.IGNORECASE):
base = re.sub(
r"(_BURST\d+|_\d{3,}|\(\d+\)|_COVER)$",
"",
basename,
flags=re.IGNORECASE,
)
directory = os.path.dirname(photo.main_file.path)
group_key = f"filename_{directory}_{base}"
return True, group_key
return False, None
def check_filename_pattern(photo, pattern_type="any"):
"""
Check if a photo's filename matches a burst pattern.
Standalone function for testing and external use.
Args:
photo: Photo model instance with main_file
pattern_type: "any" to check all patterns, or specific pattern name
(e.g., "burst_suffix", "sequence_suffix", "bracketed_sequence",
"samsung_burst", "iphone_burst")
Returns:
Tuple of (matches: bool, group_key: str or None)
group_key can be used to group photos into the same burst
"""
if not photo.main_file:
return False, None
filename = os.path.basename(photo.main_file.path)
basename = os.path.splitext(filename)[0]
directory = os.path.dirname(photo.main_file.path)
if pattern_type == "any" or pattern_type == "all":
# Check all predefined patterns
for pattern_name, (pattern, _) in BURST_FILENAME_PATTERNS.items():
if re.search(pattern, basename, re.IGNORECASE):
base = re.sub(
r"(_BURST\d+|_\d{3,}|\(\d+\)|_COVER)$",
"",
basename,
flags=re.IGNORECASE,
)
group_key = f"filename_{directory}_{base}"
return True, group_key
else:
# Check specific pattern
if pattern_type in BURST_FILENAME_PATTERNS:
pattern, _ = BURST_FILENAME_PATTERNS[pattern_type]
if re.search(pattern, basename, re.IGNORECASE):
base = re.sub(
r"(_BURST\d+|_\d{3,}|\(\d+\)|_COVER)$",
"",
basename,
flags=re.IGNORECASE,
)
group_key = f"filename_{directory}_{base}"
return True, group_key
return False, None
def group_photos_by_timestamp(photos, interval_ms=2000, require_same_camera=True):
"""
Group photos by timestamp proximity (soft criterion).
Args:
photos: QuerySet of Photo objects ordered by exif_timestamp
interval_ms: Maximum milliseconds between consecutive burst shots
require_same_camera: If True, only group photos from same camera
Returns:
List of lists, each inner list is a group of Photo objects
"""
if not photos:
return []
interval = timedelta(milliseconds=interval_ms)
groups = []
current_group = []
prev_photo = None
prev_camera = None
for photo in photos:
if not photo.exif_timestamp:
continue
# Get camera info for same-camera check
camera = None
if require_same_camera and hasattr(photo, "metadata") and photo.metadata:
camera = f"{photo.metadata.camera_make or ''}_{photo.metadata.camera_model or ''}"
if prev_photo is None:
current_group = [photo]
prev_photo = photo
prev_camera = camera
continue
# Check time difference
time_diff = photo.exif_timestamp - prev_photo.exif_timestamp
# Check if same camera (if required)
same_camera = True
if require_same_camera and camera and prev_camera:
same_camera = camera == prev_camera
if time_diff <= interval and same_camera:
# Part of same burst
current_group.append(photo)
else:
# End of current burst, save if we have a group
if len(current_group) >= 2:
groups.append(current_group)
# Start new group
current_group = [photo]
prev_photo = photo
prev_camera = camera
# Don't forget the last group
if len(current_group) >= 2:
groups.append(current_group)
return groups
def group_photos_by_visual_similarity(photos, similarity_threshold=15):
"""
Group photos by visual similarity (soft criterion).
Uses perceptual hash comparison to group visually similar photos.
Args:
photos: List of Photo objects
similarity_threshold: Maximum hamming distance (lower = more similar)
Returns:
List of lists, each inner list is a group of visually similar Photo objects
"""
from api.perceptual_hash import hamming_distance
if not photos:
return []
# Filter photos with perceptual hashes
photos_with_hash = [p for p in photos if p.perceptual_hash]
if len(photos_with_hash) < 2:
return []
# Simple clustering: group consecutive similar photos
groups = []
current_group = [photos_with_hash[0]]
for i in range(1, len(photos_with_hash)):
photo = photos_with_hash[i]
prev_photo = photos_with_hash[i - 1]
distance = hamming_distance(photo.perceptual_hash, prev_photo.perceptual_hash)
if distance <= similarity_threshold:
current_group.append(photo)
else:
if len(current_group) >= 2:
groups.append(current_group)
current_group = [photo]
if len(current_group) >= 2:
groups.append(current_group)
return groups
# Default rules configuration
# Hard criteria rules (enabled by default)
DEFAULT_HARD_RULES = [
{
"id": 1,
"name": "EXIF Burst Mode Tag",
"rule_type": BurstRuleTypes.EXIF_BURST_MODE,
"category": BurstRuleCategory.HARD,
"enabled": True,
"is_default": True,
"description": "Detects photos where camera was in burst mode (using MakerNotes:BurstMode or MakerNotes:ContinuousDrive EXIF tags)",
},
{
"id": 2,
"name": "EXIF Sequence Number",
"rule_type": BurstRuleTypes.EXIF_SEQUENCE_NUMBER,
"category": BurstRuleCategory.HARD,
"enabled": True,
"is_default": True,
"description": "Groups photos by EXIF sequence number (MakerNotes:SequenceNumber) taken at the same time",
},
{
"id": 3,
"name": "Filename Burst Pattern",
"rule_type": BurstRuleTypes.FILENAME_PATTERN,
"category": BurstRuleCategory.HARD,
"enabled": True,
"is_default": True,
"pattern_type": "all",
"description": "Detects burst sequences from filename patterns (e.g., IMG_001_BURST001, photo (1), photo (2))",
},
]
# Soft criteria rules (disabled by default)
DEFAULT_SOFT_RULES = [
{
"id": 101,
"name": "Timestamp Proximity",
"rule_type": BurstRuleTypes.TIMESTAMP_PROXIMITY,
"category": BurstRuleCategory.SOFT,
"enabled": False,
"is_default": True,
"interval_ms": 2000,
"require_same_camera": True,
"description": "Groups photos taken within a short time interval (configurable, default 2 seconds)",
},
{
"id": 102,
"name": "Visual Similarity",
"rule_type": BurstRuleTypes.VISUAL_SIMILARITY,
"category": BurstRuleCategory.SOFT,
"enabled": False,
"is_default": True,
"similarity_threshold": 15,
"description": "Groups visually similar consecutive photos using perceptual hash comparison",
},
]
# Other available rules (not included by default)
OTHER_RULES = [
{
"id": 4,
"name": "Filename Burst Suffix Only",
"rule_type": BurstRuleTypes.FILENAME_PATTERN,
"category": BurstRuleCategory.HARD,
"enabled": False,
"is_default": False,
"pattern_type": "burst_suffix",
"description": "Only detect files with explicit _BURST suffix in filename",
},
{
"id": 5,
"name": "Custom Filename Pattern",
"rule_type": BurstRuleTypes.FILENAME_PATTERN,
"category": BurstRuleCategory.HARD,
"enabled": False,
"is_default": False,
"pattern_type": "custom",
"custom_pattern": "",
"description": "Use a custom regex pattern to match burst filenames",
},
{
"id": 103,
"name": "Timestamp Proximity (Loose)",
"rule_type": BurstRuleTypes.TIMESTAMP_PROXIMITY,
"category": BurstRuleCategory.SOFT,
"enabled": False,
"is_default": False,
"interval_ms": 5000,
"require_same_camera": False,
"description": "Groups photos taken within 5 seconds, regardless of camera",
},
]
def get_default_burst_detection_rules():
"""Get default burst detection rules as JSON-serializable list."""
return DEFAULT_HARD_RULES + DEFAULT_SOFT_RULES
def get_all_predefined_burst_rules():
"""Get all predefined burst detection rules (default + optional)."""
return DEFAULT_HARD_RULES + DEFAULT_SOFT_RULES + OTHER_RULES
def _as_json(configs):
"""Convert rule configs to JSON string."""
return json.dumps(configs, default=lambda x: x.__dict__)
# Pre-computed JSON strings for API responses
DEFAULT_RULES_JSON = _as_json(get_default_burst_detection_rules())
PREDEFINED_RULES_JSON = _as_json(get_all_predefined_burst_rules())
def as_rules(configs):
"""Convert list of rule configs to list of BurstDetectionRule objects."""
return [BurstDetectionRule(config) for config in configs]
def get_hard_rules(rules):
"""Filter to only hard criteria rules."""
return [r for r in rules if r.category == BurstRuleCategory.HARD and r.enabled]
def get_soft_rules(rules):
"""Filter to only soft criteria rules."""
return [r for r in rules if r.category == BurstRuleCategory.SOFT and r.enabled]
def get_enabled_rules(rules):
"""Filter to only enabled rules."""
return [r for r in rules if r.enabled]
================================================
FILE: api/cluster_manager.py
================================================
import numpy as np
from api.models.cluster import UNKNOWN_CLUSTER_ID, Cluster, get_unknown_cluster
from api.models.face import Face
from api.models.person import Person, get_or_create_person
from api.models.user import User
from api.util import logger
class ClusterManager:
@staticmethod
def try_add_cluster(
user: User, cluster_id: int, faces: list[Face], padLen: int = 1
) -> list[Cluster]:
added_clusters: list[Cluster] = []
known_faces: list[Face] = []
face_ids_by_cluster: dict[int, list[int]] = dict()
unknown_faces: list[Face] = []
unknown_ids: list[int] = []
encoding_by_person: dict[int, list[np.ndarray]] = dict()
face: Face
new_cluster: Cluster
unknown_cluster: Cluster = get_unknown_cluster(user=user)
labelStr = str(cluster_id).zfill(padLen)
for face in faces:
if not face.person:
unknown_faces.append(face)
unknown_ids.append(face.id)
else:
known_faces.append(face)
if cluster_id == UNKNOWN_CLUSTER_ID:
logger.info("Adding unknown cluster")
logger.info(
"Adding unknown %d faces to unknown cluster" % len(unknown_faces)
)
logger.info("Adding known %d faces to unknown cluster" % len(known_faces))
for face in unknown_faces:
face.cluster = unknown_cluster
face.cluster_person = None
face.save()
for face in known_faces:
face.cluster = unknown_cluster
face.save()
return added_clusters
if len(known_faces) == 0:
new_cluster: Cluster
new_person: Person
new_person = get_or_create_person(
name="Unknown " + labelStr, owner=user, kind=Person.KIND_CLUSTER
)
new_person.cluster_owner = user
new_person.save()
new_cluster = Cluster.get_or_create_cluster_by_id(user, cluster_id)
new_cluster.name = "Cluster " + str(cluster_id)
new_cluster.person = new_person
encoding_by_person[new_cluster.person.id] = []
new_cluster.save()
added_clusters.append(new_cluster)
for face in unknown_faces:
encoding_by_person[new_cluster.person.id].append(
face.get_encoding_array()
)
Face.objects.filter(id__in=unknown_ids).update(
cluster=new_cluster,
cluster_person=new_person,
)
else:
clusters_by_person: dict[int, Cluster] = dict()
mean_encoding_by_cluster: dict[int, list[np.ndarray]] = dict()
idx: int = 0
for face in known_faces:
if face.person.id not in clusters_by_person.keys():
idx = idx + 1
new_cluster = Cluster.get_or_create_cluster_by_name(
user, "Cluster " + str(cluster_id) + "-" + str(idx)
)
new_cluster.cluster_id = cluster_id
new_cluster.person = face.person
clusters_by_person[new_cluster.person.id] = new_cluster
added_clusters.append(new_cluster)
encoding_by_person[face.person.id] = []
face_ids_by_cluster[new_cluster.id] = []
else:
new_cluster = clusters_by_person[face.person.id]
encoding_by_person[face.person.id].append(face.get_encoding_array())
face_ids_by_cluster[new_cluster.id].append(face.id)
for new_cluster in added_clusters:
Face.objects.filter(id__in=face_ids_by_cluster[new_cluster.id]).update(
cluster=new_cluster
)
# Set initial metadata on the split clusters based on known faces
for new_cluster in added_clusters:
new_cluster.set_metadata(encoding_by_person[new_cluster.person.id])
mean_encoding_by_cluster[new_cluster.id] = (
new_cluster.get_mean_encoding_array()
)
# Clear the face IDs list to prepare for processing the unknown faces
for new_cluster in added_clusters:
face_ids_by_cluster[new_cluster.id] = []
for new_cluster in added_clusters:
Face.objects.filter(id__in=face_ids_by_cluster[new_cluster.id]).update(
cluster=new_cluster,
cluster_person=new_cluster.person,
)
# Update statistics again and save everything, since we've added more faces
for new_cluster in added_clusters:
new_cluster.set_metadata(encoding_by_person[new_cluster.person.id])
new_cluster.save()
return added_clusters
================================================
FILE: api/date_time_extractor.py
================================================
import json
import math
import os
import pathlib
import re
from datetime import datetime
import pytz
from api.metadata.tags import Tags
from api.util import logger
def _regexp_group_range(a, b):
return "(" + "|".join(f"{i:02}" for i in range(a, b)) + ")"
_REGEXP_GROUP_YEAR = r"((?:19|20|21)\d\d)"
_REGEXP_GROUP_MONTH = _regexp_group_range(1, 13)
_REGEXP_GROUP_DAY = _regexp_group_range(1, 32)
_REGEXP_GROUP_HOUR = _regexp_group_range(0, 24)
_REGEXP_GROUP_MIN = r"([0-5]\d)"
_REGEXP_GROUP_SEC = r"([0-5]\d)"
_REGEXP_DELIM = r"[-:_\., ]*"
_NOT_A_NUMBER = r"(?<!\d)"
REGEXP_NO_TZ = re.compile(
_NOT_A_NUMBER
+ _REGEXP_DELIM.join(
[
_REGEXP_GROUP_YEAR,
_REGEXP_GROUP_MONTH,
_REGEXP_GROUP_DAY,
_REGEXP_GROUP_HOUR,
_REGEXP_GROUP_MIN,
_REGEXP_GROUP_SEC,
]
)
)
# WhatsApp style filename - like IMG-20220101-WA0007.jpg
# Here we get year, month, day from the filename and use the number as microsecond so that
# media is ordered by that number but all of these images are grouped together separated from
# other media on that date.
REGEXP_WHATSAPP = re.compile(r"^(?:IMG|VID)[-_](\d{4})(\d{2})(\d{2})(?:[-_]WA(\d+))?")
REGEXP_WHATSAPP_GROUP_MAPPING = ["year", "month", "day", "microsecond"]
PREDEFINED_REGEXPS = {
"default": (REGEXP_NO_TZ, None),
"whatsapp": (REGEXP_WHATSAPP, REGEXP_WHATSAPP_GROUP_MAPPING),
}
REGEXP_GROUP_MAPPINGS = {
"year": 0,
"month": 1,
"day": 2,
"hour": 3,
"minute": 4,
"second": 5,
"microsecond": 6,
}
def _extract_no_tz_datetime_from_str(x, regexp=REGEXP_NO_TZ, group_mapping=None):
match = re.search(regexp, x)
if not match:
return None
g = match.groups()
if group_mapping is None:
datetime_args = list(map(int, g))
else:
if len(g) > len(group_mapping):
raise ValueError(
f"Can't have more groups than group mapping values: {x}, regexp: {regexp}, mapping: {group_mapping}"
)
datetime_args = [
None,
None,
None,
0,
0,
0,
0,
] # year, month, day, hour, minute, second, microsecond
for value, how_to_use in zip(g, group_mapping):
if how_to_use not in REGEXP_GROUP_MAPPINGS:
raise ValueError(
f"Group mapping {how_to_use} is unknown - must be one of {list(REGEXP_GROUP_MAPPINGS.keys())}"
)
ind = REGEXP_GROUP_MAPPINGS[how_to_use]
# handle case when we have less groups than expected
if value is not None:
datetime_args[ind] = int(value)
try:
parsed_datetime = datetime(*datetime_args)
delta = parsed_datetime - datetime.now()
if delta.days > 30:
logger.error(
f"Error while parsing datetime from '{x}': Parsed datetime is {delta.days} in the future."
)
return None
return parsed_datetime
except ValueError:
logger.error(
f"Error while trying to create datetime using '{x}': datetime arguments {datetime_args}. Regexp used: '{regexp}'"
)
return None
class RuleTypes:
EXIF = "exif"
PATH = "path"
FILESYSTEM = "filesystem"
USER_DEFINED = "user_defined"
class TimeExtractionRule:
"""The goal is to help extract local time, but for historical reason it is expected the returned
datetime will have timezone to be set to pytz.utc (so local time + timezone equal to UTC)..
Some sources of data might give us very rich information, e.g. timestamp + timezone,
but others only allow to get local time (without knowing real timestamp).
The logic for extracting local time is described as a list of rules that should be applied
one after another until one rule is able to extract date time (or until all rules are tried
without success).
Currently supported rule types:
- "exif" - local time is taken using exif tag params["exif_tag"] as obtained with exiftool
- "path" - time is taken from the filename using a regular expression matching
- if params["path_part"] is set to "full_path" then full path (as seen by backend container)
is used for regexp matching instead of just fileanme.
- if params["custom_regexp"] is specified - that regexp is used instead of default one
(it is still expecting 6 groups to be matched: year, month, day, hour, minute, second).
- "fs" - time is taken from file property. Since these are unix timestamps without timezones
they are always translated to local time using UTC.
- params["file_property"] must be specified and equal to one of the following:
- "mtime" - for file modifided time
- "ctime" - for file created time
If a rule can't fetch the time (e.g. the exif tag value is not present or path doesn't match
a regexp) then that rule is considered to be not applicable.
In some cases it is known that the local time the rule would obtain is not in the desired
timezone. E.g. video datetime tag QuickTime:CreateDate is by standard written in UTC rather
than local time. For such cases each rule can optionally have setting "transform_tz" set to "1"
- in that case this rule should also specify "source_tz" and "report_tz" settings where
"source_tz" is describing the timezone that the rule is getting and "report_tz" is describing
the timezone of the location where the photo/video was taken. Both "source_tz" and "report_tz"
should be one of the following:
- "utc" - UTC timezone
- "gps_timezonefinder" - the timezone of the GPS location associated with the photo/video
- "server_local" - the timezone of the librephotos server - not very useful since we run docker containers in UTC timezone.
- "user_default" - user default timezone
- "name:<timezone_name>" - the timezone with the name <timezone_name>
If either "source_tz" or "report_tz" could not be obtained the rule is considered to be not applicable.
Additionally each rule can have condition specifications that limits the rule application
to only the photos/videos that meet the condition's requirement. Supported conditions:
- "condition_path": "<regexp>" - rule only applied to files with full path (as seen by backend)
matching the regexp
- "condition_filename": "<regexp>" - rule only applied to files with filename matching the regexp
- "condition_exif": "<tag_name>//<regexp>" - first "//" is considered end of tag name and the rule is only
applied if value of tag <tag_name> exists and matches the regexp.
If multiple conditions are provided the rule is considered applicable only if all of them are met.
Examples of the rules:
- Take local time from exif tag "EXIF:DateTimeOriginal" if it is available:
{
"rule_type": "exif",
"exif_tag": "EXIF:DateTimeOriginal",
}
- Take UTC time using tag "QuickTime:CreateDate" and convert it from UTC
to timezone associated with the GPS coordinates (only applies if both
tag value and GPS coordinates are available):
{
"rule_type": "exif",
"exif_tag": "QuickTime:CreateDate",
"transform_tz": 1,
"source_tz": "utc",
"report_tz": "gps_timezonefinder",
}
- Look at the filename and try to extract time from it - treat it as local time
(it is known that some devices are not using local time here - e.g. some phones
might use UTC time for video filenames):
{
"rule_type": "path",
}
- Take UTC time time using tag "QuickTime:CreateDate" and convert it from UTC
to a fixed timezone "Europe/Moscow" to get local time. Only apply to files
which path contains "Moscow_Visit" or "FromMoscow":
{
"rule_type": "exif",
"exif_tag": "QuickTime:CreateDate",
"transform_tz": 1,
"source_tz": "utc",
"report_tz": "name:Europe/Moscow",
"condition_path": "(Moscow_Visit|FromMoscow)",
}
- Take modified time of the file and get local time using timezone associated
with the GPS location. Only apply to files with "EXIF:Model" exif tag
containing "FooBar":
{
"rule_type": "filesystem",
"file_property": "mtime",
"transform_tz": 1,
"source_tz": "utc",
"report_tz": "gps_timezonefinder",
"condition_exif": "EXIF:Model//FooBar"
}
"""
def __init__(self, params):
self.rule_type = params["rule_type"]
self.params = params
def get_required_exif_tags(self):
condition_tag, pattern = self._get_condition_exif()
res = set()
if condition_tag is not None:
res.add(condition_tag)
if self.rule_type == RuleTypes.EXIF:
res.add(self.params["exif_tag"])
return res
def _get_no_tz_dt_from_tag(self, tag_name, exif_tags):
tag_val = exif_tags.get(tag_name)
if not tag_val:
return None
dt = _extract_no_tz_datetime_from_str(tag_val)
return dt
def _check_condition_path(self, path):
if "condition_path" in self.params:
return re.search(self.params["condition_path"], path) is not None
else:
return True
def _check_condition_filename(self, path):
if "condition_filename" in self.params:
return (
re.search(self.params["condition_filename"], pathlib.Path(path).name)
is not None
)
else:
return True
def _get_condition_exif(self):
val = self.params.get("condition_exif")
if val is None:
return None, None
tag_and_pattern = val.split("//", maxsplit=1)
if len(tag_and_pattern) != 2:
raise ValueError(
f"Value of condition_exif must contain '//' delimiter between tag name and pattern: '{val}'"
)
tag, pattern = tag_and_pattern
return tag, pattern
def _check_condition_exif(self, exif_tags):
tag, pattern = self._get_condition_exif()
if tag:
tag_value = exif_tags.get(tag)
if not tag_value:
return False
return re.search(pattern, tag_value) is not None
else:
return True
def _check_conditions(self, path, exif_tags, gps_lat, gps_lon):
return (
self._check_condition_exif(exif_tags)
and self._check_condition_path(path)
and self._check_condition_filename(path)
)
def apply(
self, path, exif_tags, gps_lat, gps_lon, user_default_tz, user_defined_timestamp
):
if not self._check_conditions(path, exif_tags, gps_lat, gps_lon):
return None
if self.rule_type == RuleTypes.EXIF:
return self._apply_exif(exif_tags, gps_lat, gps_lon, user_default_tz)
elif self.rule_type == RuleTypes.PATH:
return self._apply_path(path, gps_lat, gps_lon, user_default_tz)
elif self.rule_type == RuleTypes.FILESYSTEM:
return self._apply_filesystem(path, gps_lat, gps_lon, user_default_tz)
elif self.rule_type == RuleTypes.USER_DEFINED:
return user_defined_timestamp
else:
raise ValueError(f"Unknown rule type {self.rule_type}")
def _get_tz(self, description, gps_lat, gps_lon, user_default_tz):
"""None is a valid timezone returned here (meaning that we want to use server local time).
This is why this function returns a tuple with the first element specifying success of
determining the timezone, and the second element - the timezone itself.
"""
if description == "gps_timezonefinder":
if not _check_gps_ok(gps_lat, gps_lon):
return (False, None)
from timezonefinder import TimezoneFinder
tzfinder = TimezoneFinder()
tz_name = tzfinder.timezone_at(lng=gps_lon, lat=gps_lat)
return (True, pytz.timezone(tz_name)) if tz_name else (False, None)
elif description == "user_default":
return (True, pytz.timezone(user_default_tz))
elif description == "server_local":
return (True, None)
elif description.lower() == "utc":
return (True, pytz.utc)
elif description.startswith("name:"):
return (True, pytz.timezone(description[5:]))
else:
raise ValueError(f"Unknown tz description {description}")
def _transform_tz(self, dt, gps_lat, gps_lon, user_default_tz):
if not dt:
return None
if self.params.get("transform_tz"):
has_source_tz, source_tz = self._get_tz(
self.params["source_tz"], gps_lat, gps_lon, user_default_tz
)
if not has_source_tz:
return None
has_report_tz, report_tz = self._get_tz(
self.params["report_tz"], gps_lat, gps_lon, user_default_tz
)
if not has_report_tz:
return None
# Either of source_tz or report_tz might be None - meaning that we want to use
# server local timezone
dt = datetime.fromtimestamp(
dt.replace(tzinfo=source_tz).timestamp(), report_tz
)
return dt.replace(tzinfo=pytz.utc)
def _apply_exif(self, exif_tags, gps_lat, gps_lon, user_default_tz):
dt = self._get_no_tz_dt_from_tag(self.params["exif_tag"], exif_tags)
return self._transform_tz(dt, gps_lat, gps_lon, user_default_tz)
def _apply_path(self, path, gps_lat, gps_lon, user_default_tz):
path_part = self.params.get("path_part")
if path_part is None or path_part == "filename":
source = pathlib.Path(path).name
elif path_part == "full_path":
source = path
else:
raise ValueError(f"Unknown path_part {path_part}")
group_mapping = None
regexp = self.params.get("custom_regexp")
if not regexp:
predefined_regexp_type = self.params.get("predefined_regexp", "default")
if predefined_regexp_type not in PREDEFINED_REGEXPS:
raise ValueError(
f"Unknown predefined regexp type {predefined_regexp_type}"
)
regexp, group_mapping = PREDEFINED_REGEXPS[predefined_regexp_type]
dt = _extract_no_tz_datetime_from_str(source, regexp, group_mapping)
return self._transform_tz(dt, gps_lat, gps_lon, user_default_tz)
def _apply_filesystem(self, path, gps_lat, gps_lon, user_default_tz):
file_property = self.params.get("file_property")
if file_property == "mtime":
dt = datetime.fromtimestamp(os.path.getmtime(path), pytz.utc)
elif file_property == "ctime":
dt = datetime.fromtimestamp(os.path.getctime(path), pytz.utc)
else:
raise ValueError(f"Unknown file_property {file_property}")
return self._transform_tz(dt, gps_lat, gps_lon, user_default_tz)
def _check_gps_ok(lat, lon):
return (
lat is not None
and lon is not None
and math.isfinite(lat)
and math.isfinite(lon)
and (lat != 0.0 or lon != 0.0)
)
ALL_TIME_ZONES = pytz.all_timezones
DEFAULT_RULES_PARAMS = [
{
"id": 14,
"name": "Timestamp set by user",
"rule_type": RuleTypes.USER_DEFINED,
},
{
"id": 15,
"name": f"Local time from {Tags.DATE_TIME} exif tag",
"rule_type": RuleTypes.EXIF,
"exif_tag": Tags.DATE_TIME,
},
{
"id": 1,
"name": f"Local time from {Tags.DATE_TIME_ORIGINAL} exif tag",
"rule_type": RuleTypes.EXIF,
"exif_tag": Tags.DATE_TIME_ORIGINAL,
},
{
"id": 2,
"name": "Get Video creation tag in UTC + figure out timezone using GPS coordinates",
"rule_type": RuleTypes.EXIF,
"exif_tag": Tags.QUICKTIME_CREATE_DATE,
"transform_tz": 1,
"source_tz": "utc",
"report_tz": "gps_timezonefinder",
},
{
"id": 11,
"name": f"Use {Tags.GPS_DATE_TIME} tag + figure out timezone using GPS coordinates",
"rule_type": RuleTypes.EXIF,
"exif_tag": Tags.GPS_DATE_TIME,
"transform_tz": 1,
"source_tz": "utc",
"report_tz": "gps_timezonefinder",
},
{
"id": 3,
"name": "Using filename assuming time is local (most of filenames auto generated by smartphones etc)",
"rule_type": RuleTypes.PATH,
},
{
"id": 4,
"name": "Video creation datetime in user default timezone (can't find out actual timezone)",
"rule_type": RuleTypes.EXIF,
"exif_tag": Tags.QUICKTIME_CREATE_DATE,
"transform_tz": 1,
"source_tz": "utc",
"report_tz": "user_default",
},
{
"id": 5,
"name": "Extract date using WhatsApp file name",
"rule_type": RuleTypes.PATH,
"predefined_regexp": "whatsapp",
},
]
OTHER_RULES_PARAMS = [
{
"id": 6,
"name": "Video creation datetime in UTC timezone (can't find out actual timezone)",
"rule_type": RuleTypes.EXIF,
"exif_tag": Tags.QUICKTIME_CREATE_DATE,
},
{
"id": 7,
"name": "File modified time in user default timezone",
"rule_type": RuleTypes.FILESYSTEM,
"file_property": "mtime",
"transform_tz": 1,
"source_tz": "utc",
"report_tz": "user_default",
},
{
"id": 8,
"name": "File modified time in UTC timezone",
"rule_type": RuleTypes.FILESYSTEM,
"file_property": "mtime",
},
{
"id": 9,
"name": "File created time in user default timezone",
"rule_type": RuleTypes.FILESYSTEM,
"file_property": "ctime",
"transform_tz": 1,
"source_tz": "utc",
"report_tz": "user_default",
},
{
"id": 10,
"name": "File created time in UTC timezone",
"rule_type": RuleTypes.FILESYSTEM,
"file_property": "ctime",
},
{
"id": 12,
"name": f"Use {Tags.GPS_DATE_TIME} tag in user default timezone (can't find out actual timezone)",
"rule_type": RuleTypes.EXIF,
"exif_tag": Tags.GPS_DATE_TIME,
"transform_tz": 1,
"source_tz": "utc",
"report_tz": "user_default",
},
{
"id": 13,
"name": f"Use {Tags.GPS_DATE_TIME} tag in UTC timezone (can't find out actual timezone)",
"rule_type": RuleTypes.EXIF,
"exif_tag": Tags.GPS_DATE_TIME,
},
]
def set_as_default_rule(rule):
rule["is_default"] = True
return rule
def set_as_other_rule(rule):
rule["is_default"] = False
return rule
PREDEFINED_RULES_PARAMS = list(map(set_as_default_rule, DEFAULT_RULES_PARAMS)) + list(
map(set_as_other_rule, OTHER_RULES_PARAMS)
)
def _as_json(configs):
return json.dumps(configs, default=lambda x: x.__dict__)
DEFAULT_RULES_JSON = _as_json(DEFAULT_RULES_PARAMS)
PREDEFINED_RULES_JSON = _as_json(PREDEFINED_RULES_PARAMS)
ALL_TIME_ZONES_JSON = _as_json(ALL_TIME_ZONES)
def as_rules(configs):
return list(map(TimeExtractionRule, configs))
def extract_local_date_time(
path, rules, exif_getter, gps_lat, gps_lon, user_default_tz, user_defined_timestamp
):
required_tags = set()
for rule in rules:
required_tags.update(rule.get_required_exif_tags())
required_tags = list(required_tags)
exif_values = exif_getter(required_tags)
exif_tags = {k: v for k, v in zip(required_tags, exif_values)}
for rule in rules:
res = rule.apply(
path, exif_tags, gps_lat, gps_lon, user_default_tz, user_defined_timestamp
)
if res:
return res
return None
================================================
FILE: api/directory_watcher/__init__.py
================================================
"""
Directory watcher module for scanning and processing photos.
This module implements a two-phase scan architecture to avoid race conditions
when processing RAW+JPEG pairs concurrently:
Phase 1: Collect all files and group by (directory, basename)
- IMG_001.jpg, IMG_001.CR2, IMG_001.xmp -> one group
- IMG_002.jpg -> separate group
Phase 2: Process each group, creating one Photo per group
with all file variants attached.
"""
# Main scan functions
from api.directory_watcher.scan_jobs import (
scan_photos,
scan_missing_photos,
photo_scanner,
)
# File handling
from api.directory_watcher.file_handlers import (
create_new_image,
create_file_record,
group_files_into_photo,
handle_new_image,
handle_file_group,
)
# File grouping utilities
from api.directory_watcher.file_grouping import (
JPEG_EXTENSIONS,
FILE_TYPE_PRIORITY,
get_file_grouping_key,
select_main_file,
find_matching_jpeg_photo,
find_matching_image_for_video,
)
# Processing jobs
from api.directory_watcher.processing_jobs import (
generate_tags,
generate_tag_job,
add_geolocation,
geolocation_job,
scan_faces,
generate_face_embeddings,
)
# Repair jobs
from api.directory_watcher.repair_jobs import (
repair_ungrouped_file_variants,
)
# Utilities
from api.directory_watcher.utils import (
is_hidden,
should_skip,
walk_directory,
walk_files,
update_scan_counter,
)
# Re-export from api.models.file for backwards compatibility
from api.models.file import is_valid_media
__all__ = [
# Scan jobs
"scan_photos",
"scan_missing_photos",
"photo_scanner",
# File handling
"create_new_image",
"create_file_record",
"group_files_into_photo",
"handle_new_image",
"handle_file_group",
# File grouping
"JPEG_EXTENSIONS",
"FILE_TYPE_PRIORITY",
"get_file_grouping_key",
"select_main_file",
"find_matching_jpeg_photo",
"find_matching_image_for_video",
# Processing jobs
"generate_tags",
"generate_tag_job",
"add_geolocation",
"geolocation_job",
"scan_faces",
"generate_face_embeddings",
# Repair jobs
"repair_ungrouped_file_variants",
# Utilities
"is_hidden",
"should_skip",
"walk_directory",
"walk_files",
"update_scan_counter",
# Re-exported from api.models.file
"is_valid_media",
]
================================================
FILE: api/directory_watcher/file_grouping.py
================================================
"""
File grouping utilities for the two-phase scan architecture.
This module provides functions for grouping related files (RAW+JPEG pairs,
Live Photos, etc.) so they can be processed together as a single Photo.
"""
import os
from api.models import File, Photo
# JPEG/image extensions that RAW files can be paired with
JPEG_EXTENSIONS = {'.jpg', '.jpeg', '.heic', '.heif', '.png', '.tiff', '.tif'}
# File type priority for main_file selection (lower number = higher priority)
# JPEG/processed images should be main_file, RAW/video variants are secondary
FILE_TYPE_PRIORITY = {
File.IMAGE: 1, # JPEG, HEIC, PNG - highest priority
File.VIDEO: 2, # Videos (standalone or Live Photo motion)
File.RAW_FILE: 3, # RAW files are variants, not main
File.METADATA_FILE: 4, # XMP sidecars - lowest priority
File.UNKNOWN: 5,
}
def get_file_grouping_key(path: str) -> tuple[str, str]:
"""
Get the grouping key for a file path.
Files with the same (directory, basename) should be grouped together
as variants of the same Photo (e.g., IMG_001.jpg + IMG_001.CR2 + IMG_001.xmp).
Args:
path: File path to get grouping key for
Returns:
Tuple of (directory, lowercase_basename_without_extension)
"""
directory = os.path.dirname(path)
basename = os.path.splitext(os.path.basename(path))[0].lower()
return (directory, basename)
def select_main_file(files: list[File]) -> File | None:
"""
Select the best file to be the main_file for a Photo.
Priority: IMAGE > VIDEO > RAW > METADATA
Within same type, prefer the first one found (alphabetically by path).
Args:
files: List of File objects to choose from
Returns:
The File that should be main_file, or None if empty list
"""
if not files:
return None
return min(files, key=lambda f: (FILE_TYPE_PRIORITY.get(f.type, 999), f.path))
def find_matching_jpeg_photo(raw_path: str, user) -> Photo | None:
"""
Find an existing Photo with a matching JPEG/image file for a RAW file.
Matches based on same base filename (without extension) in the same directory.
This implements the PhotoPrism-like file variant model where RAW+JPEG are
one Photo with multiple file variants, not separate Photos.
Args:
raw_path: Path to the RAW file
user: Owner of the photos
Returns:
Matching Photo if found, None otherwise
"""
raw_dir = os.path.dirname(raw_path)
raw_basename = os.path.splitext(os.path.basename(raw_path))[0]
# Look for matching JPEG/image file in same directory
for jpeg_ext in JPEG_EXTENSIONS:
# Try both lowercase and uppercase extensions
for ext in [jpeg_ext, jpeg_ext.upper()]:
jpeg_path = os.path.join(raw_dir, raw_basename + ext)
photo = Photo.objects.filter(
owner=user,
main_file__path=jpeg_path
).first()
if photo:
return photo
return None
def find_matching_image_for_video(video_path: str, user) -> Photo | None:
"""
Find an existing Photo with a matching image file for a Live Photo video.
Apple Live Photos store the video as a separate .mov file with the same
base name as the image. This allows attaching the video as a file variant.
Args:
video_path: Path to the video file
user: Owner of the photos
Returns:
Matching Photo if found, None otherwise
"""
video_dir = os.path.dirname(video_path)
video_basename = os.path.splitext(os.path.basename(video_path))[0]
video_ext_lower = os.path.splitext(video_path)[1].lower()
# Only match .mov files (Apple Live Photos) with same base name
if video_ext_lower not in ['.mov']:
return None
# Look for matching image file with same base name
image_extensions = list(JPEG_EXTENSIONS) + ['.heic', '.HEIC']
for img_ext in image_extensions:
for ext in [img_ext, img_ext.upper()]:
image_path = os.path.join(video_dir, video_basename + ext)
photo = Photo.objects.filter(
owner=user,
main_file__path=image_path
).first()
if photo:
return photo
return None
================================================
FILE: api/directory_watcher/file_handlers.py
================================================
"""
File and Photo creation handlers.
This module contains functions for creating File records and grouping
them into Photo objects.
"""
import datetime
import os
import pytz
from django.conf import settings
from django.db.models import Q
from api import util
from api.models import File, Photo, Thumbnail
from api.models.file import calculate_hash, is_metadata, is_raw, is_valid_media, is_video
from api.models.photo_search import PhotoSearch
from api.perceptual_hash import calculate_hash_from_thumbnail
from api.stacks.live_photo import has_embedded_motion_video, extract_embedded_motion_video
from api.directory_watcher.file_grouping import (
FILE_TYPE_PRIORITY,
find_matching_jpeg_photo,
find_matching_image_for_video,
select_main_file,
)
from api.directory_watcher.utils import update_scan_counter
def create_file_record(user, path) -> File | None:
"""
Phase 1: Create a File record for a path without creating/grouping Photos.
This is the first phase of the two-phase scan architecture:
- Phase 1: Create File records for all discovered files (this function)
- Phase 2: Group files into Photos by (directory, basename)
This separation eliminates race conditions where concurrent processing
of RAW and JPEG files could create separate Photos instead of grouping them.
Args:
user: The owner of the file
path: The file path
Returns:
File object if created/found, None if invalid media
"""
if not is_valid_media(path=path, user=user):
return None
hash_value = calculate_hash(user, path)
# Skip if this is embedded media (already attached to another file)
if File.embedded_media.through.objects.filter(Q(to_file_id=hash_value)).exists():
util.logger.warning(f"embedded content file found {path}")
return None
# Create the File record (File.create handles race conditions via unique path constraint)
file = File.create(path, user)
return file
def group_files_into_photo(user, files: list[File], job_id) -> Photo | None:
"""
Phase 2: Group a list of related files into a single Photo.
Creates a new Photo with the given files as variants, selecting the
best file as main_file based on type priority (IMAGE > VIDEO > RAW > METADATA).
This function should be called with all files that share the same
(directory, basename) - e.g., IMG_001.jpg, IMG_001.CR2, IMG_001.xmp.
Args:
user: The owner of the photo
files: List of File objects to group (must not be empty)
job_id: Job ID for logging
Returns:
The created Photo, or None if no valid files
"""
if not files:
return None
# Filter out metadata files for main photo creation - they're sidecars
non_metadata_files = [f for f in files if f.type != File.METADATA_FILE]
if not non_metadata_files:
# Only metadata files - no photo to create
util.logger.warning(f"job {job_id}: Only metadata files in group, skipping")
return None
# Select main file based on priority
main_file = select_main_file(non_metadata_files)
if not main_file:
return None
# Check if a Photo already exists with any of these files
existing_photo = Photo.objects.filter(
owner=user,
files__in=files
).first()
if existing_photo:
# Add any missing files to the existing photo
for f in files:
if not existing_photo.files.filter(hash=f.hash).exists():
existing_photo.files.add(f)
util.logger.info(f"job {job_id}: Attached file {f.path} to existing Photo {existing_photo.image_hash}")
# Update main_file if current one has lower priority
if existing_photo.main_file:
current_priority = FILE_TYPE_PRIORITY.get(existing_photo.main_file.type, 999)
new_priority = FILE_TYPE_PRIORITY.get(main_file.type, 999)
if new_priority < current_priority:
existing_photo.main_file = main_file
existing_photo.save(update_fields=['main_file'])
return existing_photo
# Create new Photo
photo = Photo()
photo.image_hash = main_file.hash
photo.owner = user
photo.added_on = datetime.datetime.now().replace(tzinfo=pytz.utc)
photo.geolocation_json = {}
photo.video = (main_file.type == File.VIDEO)
photo.save()
# Add all files to the photo
for f in files:
photo.files.add(f)
photo.main_file = main_file
photo.save()
# Handle embedded media (Google/Samsung Live Photos with embedded video)
if has_embedded_motion_video(main_file.path) and settings.FEATURE_PROCESS_EMBEDDED_MEDIA:
em_path = extract_embedded_motion_video(main_file.path, main_file.hash)
if em_path:
em_file = File.create(em_path, user)
main_file.embedded_media.add(em_file)
photo.files.add(em_file)
photo.save()
util.logger.info(f"job {job_id}: Created Photo {photo.image_hash} with {len(files)} file(s)")
return photo
def create_new_image(user, path) -> Photo | None:
"""
Creates a new Photo object based on user input and file path.
This is the legacy single-file creation function, kept for backwards
compatibility with upload handling. For scan operations, use the
two-phase approach (create_file_record + group_files_into_photo).
Args:
user: The owner of the photo.
path: The file path of the image.
Returns:
The created Photo object if successful, otherwise returns None.
Note:
This function implements file variant grouping (PhotoPrism-like):
- RAW files are attached to existing JPEG Photos as file variants
- Live Photo videos (.mov) are attached to existing image Photos as file variants
- Other files create new Photo entities
"""
if not is_valid_media(path=path, user=user):
return None
hash_value = calculate_hash(user, path)
if File.embedded_media.through.objects.filter(Q(to_file_id=hash_value)).exists():
util.logger.warning(f"embedded content file found {path}")
return None
# Handle metadata files (XMP sidecars)
if is_metadata(path):
photo_name = os.path.splitext(os.path.basename(path))[0]
photo_dir = os.path.dirname(path)
photo = Photo.objects.filter(
Q(files__path__contains=photo_dir)
& Q(files__path__contains=photo_name)
& ~Q(files__path__contains=os.path.basename(path))
).first()
if photo:
file = File.create(path, user)
photo.files.add(file)
photo.save()
else:
util.logger.warning(f"no photo to metadata file found {path}")
return None
# === File Variant Handling (PhotoPrism-like model) ===
# Handle RAW files: attach to existing JPEG Photo if found
if is_raw(path):
existing_photo = find_matching_jpeg_photo(path, user)
if existing_photo:
# Check if this RAW file is already attached
if not existing_photo.files.filter(path=path).exists():
raw_file = File.create(path, user)
existing_photo.files.add(raw_file)
existing_photo.save()
util.logger.info(f"Attached RAW file {path} to existing Photo {existing_photo.image_hash}")
return existing_photo
# Handle Live Photo videos (.mov): attach to existing image Photo if found
if is_video(path):
existing_photo = find_matching_image_for_video(path, user)
if existing_photo:
# Check if this video is already attached
if not existing_photo.files.filter(path=path).exists():
video_file = File.create(path, user)
existing_photo.files.add(video_file)
existing_photo.video = False # Keep photo as image (video is just a variant)
existing_photo.save()
util.logger.info(f"Attached Live Photo video {path} to existing Photo {existing_photo.image_hash}")
return existing_photo
# === Standard Photo Creation ===
photo = Photo()
photo.image_hash = hash_value
photo.owner = user
photo.added_on = datetime.datetime.now().replace(tzinfo=pytz.utc)
photo.geolocation_json = {}
photo.video = is_video(path)
photo.save()
file = File.create(path, user)
# Live Photo detection - extracts embedded motion video if present (Google/Samsung)
if has_embedded_motion_video(file.path) and settings.FEATURE_PROCESS_EMBEDDED_MEDIA:
em_path = extract_embedded_motion_video(file.path, file.hash)
if em_path:
em_file = File.create(em_path, user)
file.embedded_media.add(em_file)
# Also add embedded video to Photo.files as a variant
photo.files.add(em_file)
photo.files.add(file)
photo.main_file = file
photo.save()
return photo
def handle_new_image(user, path, job_id, photo=None):
"""
Handles the creation and all the processing of the photo needed for it to be displayed.
Args:
user: The owner of the photo.
path: The file path of the image.
job_id: The long-running job id, which gets updated when the task runs
photo: An optional parameter, where you can input a photo instead of creating a new one. Used for uploading.
Note:
This function is used when uploading a picture, because rescanning does not perform machine learning tasks.
"""
try:
start = datetime.datetime.now()
if photo is None:
photo = create_new_image(user, path)
elapsed = (datetime.datetime.now() - start).total_seconds()
util.logger.info(f"job {job_id}: save image: {path}, elapsed: {elapsed}")
if photo:
_process_photo(photo, path, job_id, start)
except Exception as e:
try:
util.logger.exception(
f"job {job_id}: could not load image {path}. reason: {str(e)}"
)
except Exception:
util.logger.exception(f"job {job_id}: could not load image {path}")
finally:
update_scan_counter(job_id)
def handle_file_group(user, file_paths: list[str], job_id):
"""
Phase 2 handler: Process a group of related files into a single Photo.
This is called after Phase 1 has created File records for all paths.
Files are grouped by (directory, basename) so RAW+JPEG pairs are processed together.
Args:
user: The owner of the files
file_paths: List of file paths that share the same (directory, basename)
job_id: Job ID for logging and progress tracking
"""
try:
start = datetime.datetime.now()
# Get or create File records for all paths
files = []
for path in file_paths:
file = create_file_record(user, path)
if file:
files.append(file)
if not files:
util.logger.warning(f"job {job_id}: No valid files in group: {file_paths}")
return
# Group files into a Photo
photo = group_files_into_photo(user, files, job_id)
if not photo:
util.logger.warning(f"job {job_id}: Could not create photo for files: {file_paths}")
return
elapsed = (datetime.datetime.now() - start).total_seconds()
util.logger.info(f"job {job_id}: created photo with {len(files)} files, elapsed: {elapsed}")
# Process the photo (thumbnails, EXIF, etc.) using main_file
if photo.main_file:
_process_photo(photo, photo.main_file.path, job_id, start)
except Exception as e:
try:
util.logger.exception(
f"job {job_id}: could not process file group {file_paths}. reason: {str(e)}"
)
except Exception:
util.logger.exception(f"job {job_id}: could not process file group")
finally:
update_scan_counter(job_id)
def _process_photo(photo: Photo, path: str, job_id, start: datetime.datetime):
"""
Process a photo: generate thumbnails, extract EXIF, calculate hashes, etc.
This is the common processing logic shared between handle_new_image and handle_file_group.
Args:
photo: The Photo object to process
path: The main file path (for logging)
job_id: Job ID for logging
start: Start time for elapsed time calculation
"""
util.logger.info(f"job {job_id}: handling image {path}")
# Create or get thumbnail instance
thumbnail, _ = Thumbnail.objects.get_or_create(photo=photo)
thumbnail._generate_thumbnail()
elapsed = (datetime.datetime.now() - start).total_seconds()
util.logger.info(
f"job {job_id}: generate thumbnails: {path}, elapsed: {elapsed}"
)
thumbnail._calculate_aspect_ratio()
elapsed = (datetime.datetime.now() - start).total_seconds()
util.logger.info(
f"job {job_id}: calculate aspect ratio: {path}, elapsed: {elapsed}"
)
# Calculate perceptual hash for duplicate detection
if thumbnail.thumbnail_big and os.path.exists(thumbnail.thumbnail_big.path):
phash = calculate_hash_from_thumbnail(thumbnail.thumbnail_big.path)
if phash:
photo.perceptual_hash = phash
photo.save(update_fields=["perceptual_hash"])
elapsed = (datetime.datetime.now() - start).total_seconds()
util.logger.info(
f"job {job_id}: calculate perceptual hash: {path}, elapsed: {elapsed}"
)
from api.models.photo_metadata import PhotoMetadata
PhotoMetadata.extract_exif_data(photo, commit=True)
elapsed = (datetime.datetime.now() - start).total_seconds()
util.logger.info(
f"job {job_id}: extract exif data: {path}, elapsed: {elapsed}"
)
photo._extract_date_time_from_exif(True)
elapsed = (datetime.datetime.now() - start).total_seconds()
util.logger.info(
f"job {job_id}: extract date time: {path}, elapsed: {elapsed}"
)
thumbnail._get_dominant_color()
elapsed = (datetime.datetime.now() - start).total_seconds()
util.logger.info(
f"job {job_id}: get dominant color: {path}, elapsed: {elapsed}"
)
search_instance, created = PhotoSearch.objects.get_or_create(photo=photo)
search_instance.recreate_search_captions()
search_instance.save()
elapsed = (datetime.datetime.now() - start).total_seconds()
util.logger.info(
f"job {job_id}: search caption recreated: {path}, elapsed: {elapsed}"
)
================================================
FILE: api/directory_watcher/processing_jobs.py
================================================
"""
Photo processing jobs (tags, geolocation, faces).
These jobs run after the main scan to enrich photos with additional
metadata like location information, image tags, and face detection.
"""
import traceback
import uuid
from uuid import UUID
from django import db
from django.db.models import Q
from django_q.tasks import AsyncTask
from api import util
from api.face_classify import cluster_all_faces
from api.models import Face, LongRunningJob, Photo
from api.models.photo_caption import PhotoCaption
from api.directory_watcher.utils import update_scan_counter
def generate_face_embeddings(user, job_id: UUID):
"""
Generate face embeddings for faces that don't have them yet.
Args:
user: The user whose faces to process
job_id: Job ID for tracking progress
"""
if Face.objects.filter(encoding="").count() == 0:
return
lrj = LongRunningJob.get_or_create_job(
user=user,
job_type=LongRunningJob.JOB_GENERATE_FACE_EMBEDDINGS,
job_id=job_id,
)
try:
faces = Face.objects.filter(encoding="")
lrj.update_progress(current=0, target=faces.count())
db.connections.close_all()
for face in faces:
failed = False
error = None
try:
face.generate_encoding()
except Exception as err:
util.logger.exception("An error occurred: ")
print(f"[ERR]: {err}")
failed = True
error_msg = f"Face {face.id}: {str(err)}\n{traceback.format_exc()}"
error = error_msg
update_scan_counter(job_id, failed, error)
lrj.complete()
except Exception as err:
util.logger.exception("An error occurred: ")
print(f"[ERR]: {err}")
lrj.fail(error=err)
def generate_tags(user, job_id: UUID, full_scan=False):
"""
Generate image tags (Places365 captions) for photos.
Args:
user: The user whose photos to process
job_id: Job ID for tracking progress
full_scan: If True, process all photos; otherwise only new ones
"""
lrj = LongRunningJob.get_or_create_job(
user=user,
job_type=LongRunningJob.JOB_GENERATE_TAGS,
job_id=job_id,
)
try:
last_scan = (
LongRunningJob.objects.filter(finished=True)
.filter(job_type=LongRunningJob.JOB_GENERATE_TAGS)
.filter(started_by=user)
.order_by("-finished_at")
.first()
)
from constance import config as site_config
tagging_model = site_config.TAGGING_MODEL
existing_photos = Photo.objects.filter(
Q(owner=user.id)
& (
Q(caption_instance__isnull=True)
| Q(caption_instance__captions_json__isnull=True)
| Q(**{f"caption_instance__captions_json__{tagging_model}__isnull": True})
)
)
if not full_scan and last_scan:
existing_photos = existing_photos.filter(added_on__gt=last_scan.started_at)
if existing_photos.count() == 0:
lrj.update_progress(current=0, target=0)
lrj.complete()
return
lrj.update_progress(current=0, target=existing_photos.count())
db.connections.close_all()
for photo in existing_photos:
AsyncTask(generate_tag_job, photo, job_id).run()
except Exception as err:
util.logger.exception("An error occurred: ")
print(f"[ERR]: {err}")
lrj.fail(error=err)
def generate_tag_job(photo: Photo, job_id: str):
"""
Worker task to generate tags for a single photo.
Args:
photo: The photo to process
job_id: Job ID for tracking progress
"""
failed = False
error = None
try:
photo.refresh_from_db()
caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo)
caption_instance.generate_tag_captions(commit=True)
except Exception as err:
util.logger.exception("An error occurred: %s", photo.image_hash)
print(f"[ERR]: {err}")
failed = True
error_msg = f"Photo {photo.image_hash}: {str(err)}\n{traceback.format_exc()}"
error = error_msg
update_scan_counter(job_id, failed, error)
def add_geolocation(user, job_id: UUID, full_scan=False):
"""
Add geolocation data to photos based on GPS coordinates.
Args:
user: The user whose photos to process
job_id: Job ID for tracking progress
full_scan: If True, process all photos; otherwise only new ones
"""
lrj = LongRunningJob.get_or_create_job(
user=user,
job_type=LongRunningJob.JOB_ADD_GEOLOCATION,
job_id=job_id,
)
try:
last_scan = (
LongRunningJob.objects.filter(finished=True)
.filter(job_type=LongRunningJob.JOB_ADD_GEOLOCATION)
.filter(started_by=user)
.order_by("-finished_at")
.first()
)
existing_photos = Photo.objects.filter(owner=user.id)
if not full_scan and last_scan:
existing_photos = existing_photos.filter(added_on__gt=last_scan.started_at)
if existing_photos.count() == 0:
lrj.update_progress(current=0, target=0)
lrj.complete()
return
lrj.update_progress(current=0, target=existing_photos.count())
db.connections.close_all()
for photo in existing_photos:
AsyncTask(geolocation_job, photo, job_id).run()
except Exception as err:
util.logger.exception("An error occurred: ")
print(f"[ERR]: {err}")
lrj.fail(error=err)
def geolocation_job(photo: Photo, job_id: UUID):
"""
Worker task to add geolocation for a single photo.
Args:
photo: The photo to process
job_id: Job ID for tracking progress
"""
failed = False
error = None
try:
photo.refresh_from_db()
photo._geolocate()
photo._add_location_to_album_dates()
except Exception as err:
util.logger.exception("An error occurred: ")
failed = True
error_msg = f"Photo {photo.image_hash}: {str(err)}\n{traceback.format_exc()}"
error = error_msg
update_scan_counter(job_id, failed, error)
def scan_faces(user, job_id: UUID, full_scan=False):
"""
Detect and extract faces from photos.
Args:
user: The user whose photos to process
job_id: Job ID for tracking progress
full_scan: If True, process all photos; otherwise only new ones
"""
lrj = LongRunningJob.get_or_create_job(
user=user,
job_type=LongRunningJob.JOB_SCAN_FACES,
job_id=job_id,
)
try:
last_scan = (
LongRunningJob.objects.filter(finished=True)
.filter(job_type=LongRunningJob.JOB_SCAN_FACES)
.filter(started_by=user)
.order_by("-finished_at")
.first()
)
existing_photos = Photo.objects.filter(
Q(owner=user.id) & Q(thumbnail__thumbnail_big__isnull=False)
)
if not full_scan and last_scan:
existing_photos = existing_photos.filter(added_on__gt=last_scan.started_at)
if existing_photos.count() == 0:
lrj.update_progress(current=0, target=0)
lrj.complete()
return
lrj.update_progress(current=0, target=existing_photos.count())
db.connections.close_all()
for photo in existing_photos:
failed = False
error = None
try:
photo._extract_faces()
except Exception as err:
util.logger.exception("An error occurred: ")
print(f"[ERR]: {err}")
failed = True
error_msg = f"Photo {photo.image_hash}: {str(err)}\n{traceback.format_exc()}"
error = error_msg
update_scan_counter(job_id, failed, error)
except Exception as err:
util.logger.exception("An error occurred: ")
print(f"[ERR]: {err}")
lrj.fail(error=err)
generate_face_embeddings(user, uuid.uuid4())
cluster_all_faces(user, uuid.uuid4())
================================================
FILE: api/directory_watcher/repair_jobs.py
================================================
"""
Repair jobs for fixing ungrouped file variants.
This module contains jobs that repair data inconsistencies, such as
RAW files that weren't properly grouped with their JPEG counterparts
due to race conditions in previous scans.
"""
from uuid import UUID
from api import util
from api.models import File, LongRunningJob, Photo
from api.directory_watcher.file_grouping import find_matching_jpeg_photo
def repair_ungrouped_file_variants(user, job_id: UUID):
"""
Post-scan job to fix any ungrouped file variants.
This handles:
1. Race conditions from previous scans where RAW+JPEG weren't grouped
2. Rescans where files were added incrementally
3. Any orphaned RAW/metadata files that should be attached to existing Photos
Strategy: Find Photos with RAW-only main_file, look for matching JPEG Photos,
merge the RAW file into the JPEG Photo and delete the RAW-only Photo.
Args:
user: The user whose photos to repair
job_id: Job ID for tracking progress
"""
lrj = LongRunningJob.get_or_create_job(
user=user,
job_type=LongRunningJob.JOB_REPAIR_FILE_VARIANTS,
job_id=job_id,
)
try:
# Find Photos where main_file is RAW (potential orphans)
raw_only_photos = Photo.objects.filter(
owner=user,
main_file__type=File.RAW_FILE
)
lrj.update_progress(current=0, target=raw_only_photos.count())
merged_count = 0
fixed_main_file_count = 0
for raw_photo in raw_only_photos:
if not raw_photo.main_file:
continue
# Check if this RAW photo has any IMAGE files (already grouped)
has_image = raw_photo.files.filter(type=File.IMAGE).exists()
if has_image:
# Already properly grouped, just fix main_file priority
image_file = raw_photo.files.filter(type=File.IMAGE).first()
if image_file:
raw_photo.main_file = image_file
raw_photo.video = False
raw_photo.save(update_fields=['main_file', 'video'])
fixed_main_file_count += 1
continue
# Look for matching JPEG Photo
jpeg_photo = find_matching_jpeg_photo(raw_photo.main_file.path, user)
if jpeg_photo and jpeg_photo.id != raw_photo.id:
# Merge: move all files from RAW photo to JPEG photo
for f in raw_photo.files.all():
if not jpeg_photo.files.filter(hash=f.hash).exists():
jpeg_photo.files.add(f)
jpeg_photo.save()
# Delete the orphaned RAW-only photo
raw_photo.delete()
merged_count += 1
util.logger.info(
f"job {job_id}: Merged RAW photo into JPEG photo {jpeg_photo.image_hash}"
)
util.logger.info(
f"job {job_id}: Repaired {merged_count} ungrouped file variants, "
f"fixed {fixed_main_file_count} main_file priorities"
)
lrj.complete()
except Exception as e:
util.logger.exception(f"job {job_id}: Error repairing file variants: {e}")
lrj.fail(error=e)
================================================
FILE: api/directory_watcher/scan_jobs.py
================================================
"""
Main scan jobs for photo discovery and processing.
This module contains the core scan_photos function that implements the
two-phase scan architecture to avoid race conditions with RAW+JPEG grouping.
"""
import datetime
import os
import uuid
from collections import defaultdict
from uuid import UUID
import pytz
from django import db
from django.conf import settings
from django.core.paginator import Paginator
from django.db.models import F, Q
from django.utils import timezone
from django_q.tasks import AsyncTask, Chain
from api import util
from api.metadata.reader import get_sidecar_files_in_priority_order
from api.batch_jobs import batch_calculate_clip_embedding
from api.models import LongRunningJob, Photo, Thumbnail
from api.models.file import is_metadata
from api.directory_watcher.file_grouping import get_file_grouping_key
from api.directory_watcher.file_handlers import handle_new_image, handle_file_group
from api.directory_watcher.processing_jobs import (
generate_tags,
add_geolocation,
scan_faces,
)
from api.directory_watcher.repair_jobs import repair_ungrouped_file_variants
from api.directory_watcher.utils import (
walk_directory,
walk_files,
update_scan_counter,
)
def _file_was_modified_after(filepath, time):
"""Check if a file was modified after a given time."""
try:
modified = os.path.getmtime(filepath)
except OSError:
return False
return datetime.datetime.fromtimestamp(modified).replace(tzinfo=pytz.utc) > time
def wait_for_group_and_process_metadata(
group_id: str,
metadata_paths: list[str],
user_id: int,
full_scan: bool,
job_id: UUID | str,
expected_count: int,
*,
attempt: int = 1,
max_attempts: int = 2,
**kwargs, # Django-Q may pass additional arguments like 'schedule'
):
"""
Sentinel task: waits until the expected number of image/video tasks in the group complete,
then processes metadata files. It runs inside a django-q worker (non-blocking for the caller).
Failure handling:
- If the group is not complete yet, it will re-enqueue itself up to `max_attempts`.
- After exhausting attempts, it proceeds with metadata processing anyway (best-effort).
"""
from django_q.tasks import count_group
from django.contrib.auth import get_user_model
util.logger.info(
f"Sentinel attempt {attempt}/{max_attempts} for group {group_id} (expecting {expected_count} tasks)"
)
# Check current completion count for the group
try:
completed = count_group(group_id) # counts successes by default
except Exception as e:
util.logger.warning(
f"Could not read group status for {group_id}: {e}. Treating as incomplete."
)
completed = 0
# Normalize to an int to avoid None-related type issues
completed_int = int(completed or 0)
if completed_int < expected_count and attempt < max_attempts:
util.logger.info(
f"Group {group_id} not complete yet: {completed_int}/{expected_count}. Re-enqueue sentinel (attempt {attempt + 1})."
)
# Requeue the sentinel to check again later
AsyncTask(
wait_for_group_and_process_metadata,
group_id,
metadata_paths,
user_id,
full_scan,
job_id,
expected_count,
attempt=attempt + 1,
max_attempts=max_attempts,
schedule=datetime.timedelta(seconds=5),
).run()
return
# Proceed with metadata processing (either completed or after exhausting attempts)
if completed_int < expected_count:
util.logger.warning(
f"Proceeding with metadata despite incomplete image group {group_id}: {completed_int}/{expected_count}."
)
else:
util.logger.info(
f"Image group {group_id} completed. Processing {len(metadata_paths)} metadata files"
)
if not metadata_paths:
util.logger.info("No metadata files to process after images completion")
return
User = get_user_model()
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
util.logger.warning(
f"User {user_id} not found when processing metadata for job {job_id}"
)
return
last_scan = (
LongRunningJob.objects.filter(finished=True)
.filter(job_type=LongRunningJob.JOB_SCAN_PHOTOS)
.filter(started_by=user)
.order_by("-finished_at")
.first()
)
for path in metadata_paths:
try:
photo_scanner(user, last_scan, full_scan, path, job_id)
except Exception as e:
util.logger.exception(
f"Failed processing metadata {path} for job {job_id}: {e}"
)
def photo_scanner(user, last_scan, full_scan, path, job_id):
"""
Check if a single file needs processing and queue it.
Used primarily for metadata files after the main scan.
"""
files_to_check = [path]
files_to_check.extend(get_sidecar_files_in_priority_order(path))
if (
not Photo.objects.filter(files__path=path).exists()
or full_scan
or not last_scan
or any(
[_file_was_modified_after(p, last_scan.finished_at) for p in files_to_check]
)
):
# Queue processing for this file. Metadata is queued here without grouping on purpose,
# because grouping is managed at the higher-level scan phase to ensure images complete first.
AsyncTask(handle_new_image, user, path, job_id).run()
else:
update_scan_counter(job_id)
def scan_photos(user, full_scan, job_id, scan_directory="", scan_files=[]):
"""
Two-phase scan to avoid race conditions with RAW+JPEG grouping.
Phase 1: Collect all files and group by (directory, basename)
- IMG_001.jpg, IMG_001.CR2, IMG_001.xmp -> one group
- IMG_002.jpg -> separate group
Phase 2: Process each group sequentially, creating one Photo per group
with all file variants attached.
This eliminates the race condition where concurrent processing of
RAW and JPEG files could create separate Photos.
Args:
user: The user performing the scan
full_scan: If True, rescan all files; otherwise only new/modified
job_id: Job ID for tracking progress
scan_directory: Directory to scan (defaults to user's scan_directory)
scan_files: Optional list of specific files to scan
"""
thumbnail_dirs = [
os.path.join(settings.MEDIA_ROOT, "square_thumbnails_small"),
os.path.join(settings.MEDIA_ROOT, "square_thumbnails"),
os.path.join(settings.MEDIA_ROOT, "thumbnails_big"),
]
for directory in thumbnail_dirs:
os.makedirs(directory, exist_ok=True)
lrj = LongRunningJob.get_or_create_job(
user=user,
job_type=LongRunningJob.JOB_SCAN_PHOTOS,
job_id=job_id,
)
photo_count_before = Photo.objects.count()
try:
if scan_directory == "":
scan_directory = user.scan_directory
photo_list = []
if scan_files:
walk_files(scan_files, photo_list)
else:
walk_directory(scan_directory, photo_list)
files_found = len(photo_list)
last_scan = (
LongRunningJob.objects.filter(finished=True)
.filter(job_type=1)
.filter(started_by=user)
.order_by("-finished_at")
.first()
)
# === PHASE 1: Group files by (directory, basename) ===
# This ensures RAW+JPEG pairs are processed together, eliminating race conditions
file_groups: dict[tuple[str, str], list[str]] = defaultdict(list)
metadata_paths: list[str] = []
for path in photo_list:
if is_metadata(path):
# Metadata files are processed after their parent photos exist
metadata_paths.append(path)
else:
# Group by (directory, basename_lowercase)
group_key = get_file_grouping_key(path)
file_groups[group_key].append(path)
# Determine which groups need processing
groups_to_process: list[tuple[tuple[str, str], list[str]]] = []
for group_key, paths in file_groups.items():
# Check if any file in this group needs processing
needs_processing = False
for path in paths:
files_to_check = [path]
files_to_check.extend(get_sidecar_files_in_priority_order(path))
if (
not Photo.objects.filter(files__path=path).exists()
or full_scan
or not last_scan
or any(
[
_file_was_modified_after(p, last_scan.finished_at)
for p in files_to_check
]
)
):
needs_processing = True
break
if needs_processing:
groups_to_process.append((group_key, paths))
# Progress target is number of groups (not individual files)
# Each group = one Photo with potentially multiple file variants
total_groups = len(groups_to_process) + len(metadata_paths)
lrj.update_progress(current=0, target=total_groups)
db.connections.close_all()
util.logger.info(
f"Grouped {files_found} files into {len(file_groups)} groups, {len(groups_to_process)} need processing"
)
# === PHASE 2: Process each file group ===
# Process groups sequentially to avoid race conditions
# Each group creates one Photo with all file variants
image_group_id = str(uuid.uuid4())
for group_key, paths in groups_to_process:
AsyncTask(
handle_file_group,
user,
paths,
job_id,
group=image_group_id,
).run()
# If there are only metadata files (no image groups queued), process metadata now
if not groups_to_process and metadata_paths:
util.logger.info(
f"No images to process, processing {len(metadata_paths)} metadata files directly"
)
for path in metadata_paths:
photo_scanner(user, last_scan, full_scan, path, job_id)
# If there are images and metadata, enqueue a sentinel task that waits for the image group
if groups_to_process and metadata_paths:
util.logger.info(
f"Scheduling sentinel to process {len(metadata_paths)} metadata files after {len(groups_to_process)} image groups"
)
AsyncTask(
wait_for_group_and_process_metadata,
image_group_id,
metadata_paths,
user.id,
full_scan,
job_id,
len(groups_to_process),
attempt=1,
max_attempts=2,
).run()
util.logger.info(f"Scanned {files_found} files in : {scan_directory}")
# If no files were queued for processing (empty directory or all files already processed),
# mark the job as finished immediately since progress_current will equal progress_target (both 0)
LongRunningJob.objects.filter(
job_id=job_id, progress_current=F("progress_target")
).update(finished=True, finished_at=timezone.now())
util.logger.info("Finished updating album things")
# Check for photos with missing aspect ratios but existing thumbnails
photos_with_missing_aspect_ratio = Photo.objects.filter(
Q(owner=user.id)
& Q(thumbnail__isnull=False)
& Q(thumbnail__thumbnail_big__isnull=False)
& Q(thumbnail__aspect_ratio__isnull=True)
)
if photos_with_missing_aspect_ratio.exists():
util.logger.info(
f"Found {photos_with_missing_aspect_ratio.count()} photos with missing aspect ratios"
)
for photo in photos_with_missing_aspect_ratio:
try:
thumbnail = getattr(photo, "thumbnail", None)
if thumbnail and isinstance(thumbnail, Thumbnail):
thumbnail._calculate_aspect_ratio()
except Exception as e:
util.logger.exception(
f"Could not calculate aspect ratio for photo {photo.image_hash}: {str(e)}"
)
# if the scan type is not the default user scan directory, or if it is specified as only scanning
# specific files, there is no need to rescan fully for missing photos.
if full_scan or (scan_directory == user.scan_directory and not scan_files):
AsyncTask(scan_missing_photos, user, uuid.uuid4()).run()
# Run repair job to fix any previously ungrouped file variants
# This handles race conditions from previous scans and incremental adds
AsyncTask(repair_ungrouped_file_variants, user, uuid.uuid4()).run()
AsyncTask(generate_tags, user, uuid.uuid4(), full_scan).run()
AsyncTask(add_geolocation, user, uuid.uuid4(), full_scan).run()
# The scan faces job will have issues if the embeddings haven't been generated before it runs
chain = Chain()
chain.append(batch_calculate_clip_embedding, user)
chain.append(scan_faces, user, uuid.uuid4(), full_scan)
chain.run()
except Exception as e:
util.logger.exception("An error occurred: ")
lrj.fail(error=e)
added_photo_count = Photo.objects.count() - photo_count_before
util.logger.info(f"Added {added_photo_count} photos")
def scan_missing_photos(user, job_id: UUID):
"""
Scan for photos whose files no longer exist on disk.
Args:
user: The user whose photos to check
job_id: Job ID for tracking progress
"""
lrj = LongRunningJob.get_or_create_job(
user=user,
job_type=LongRunningJob.JOB_SCAN_MISSING_PHOTOS,
job_id=job_id,
)
try:
existing_photos = Photo.objects.filter(owner=user.id).order_by("image_hash")
paginator = Paginator(existing_photos, 5000)
lrj.update_progress(current=0, target=paginator.num_pages)
for page in range(1, paginator.num_pages + 1):
for existing_photo in paginator.page(page).object_list:
existing_photo._check_files()
update_scan_counter(job_id)
util.logger.info("Finished checking paths for missing photos")
except Exception as e:
util.logger.exception("An error occurred: ")
lrj.fail(error=e)
================================================
FILE: api/directory_watcher/utils.py
================================================
"""
Utility functions for directory scanning and job management.
"""
import os
import stat
from constance import config as site_config
from django.db.models import F
from django.utils import timezone
from api.models import LongRunningJob
def should_skip(path):
"""Check if a path should be skipped based on configured patterns."""
if not site_config.SKIP_PATTERNS:
return False
skip_patterns = site_config.SKIP_PATTERNS
skip_list = skip_patterns.split(",")
skip_list = map(str.strip, skip_list)
res = [ele for ele in skip_list if (ele in path)]
return bool(res)
if os.name == "Windows":
def is_hidden(path):
"""Check if a file is hidden (Windows version)."""
name = os.path.basename(os.path.abspath(path))
return name.startswith(".") or _has_hidden_attribute(path)
def _has_hidden_attribute(path):
"""Check if file has Windows hidden attribute."""
try:
return bool(os.stat(path).st_file_attributes & stat.FILE_ATTRIBUTE_HIDDEN)
except Exception:
return False
else:
def is_hidden(path):
"""Check if a file is hidden (Unix version - starts with dot)."""
return os.path.basename(path).startswith(".")
def walk_directory(directory, callback):
"""
Recursively walk a directory and collect file paths.
Args:
directory: Directory to scan
callback: List to append file paths to
"""
for file in os.scandir(directory):
fpath = os.path.join(directory, file)
if not is_hidden(fpath) and not should_skip(fpath):
if os.path.isdir(fpath):
walk_directory(fpath, callback)
else:
callback.append(fpath)
def walk_files(scan_files, callback):
"""
Walk a list of specific files.
Args:
scan_files: List of file paths to check
callback: List to append valid file paths to
"""
for fpath in scan_files:
if os.path.isfile(fpath):
callback.append(fpath)
def update_scan_counter(job_id, failed=False, error=None):
"""
Update the progress counter for a long-running job.
Increments progress_current and marks job as finished when complete.
Also tracks errors for failed items.
Args:
job_id: The job ID to update
failed: Whether this item failed processing
error: Error message if failed
"""
# Increment the current progress and get the updated job
LongRunningJob.objects.filter(job_id=job_id).update(
progress_current=F("progress_current") + 1
)
# Refetch the job to get the updated progress_current value
job = LongRunningJob.objects.filter(job_id=job_id).first()
if not job:
return
# Mark the job as finished if the current progress equals the target
if job.progress_current >= job.progress_target:
# Job is finishing, update result with errors if any
result = job.result or {}
if failed or error:
result["status"] = "failed"
if "errors" not in result:
result["errors"] = []
if error:
error_str = str(error)
# Avoid duplicate errors
if error_str not in result["errors"]:
result["errors"].append(error_str)
# Set main error field for backward compatibility
if "error" not in result and error:
result["error"] = str(error)
elif "error" not in result and result.get("errors"):
result["error"] = result["errors"][0] # Use first error as main error
job.finished = True
job.finished_at = timezone.now()
if failed:
job.failed = True
job.result = result
job.save(update_fields=["finished", "finished_at", "failed", "result"])
else:
# Job is still running, accumulate errors in result
if failed or error:
job = LongRunningJob.objects.filter(job_id=job_id).first()
if job:
result = job.result or {}
result["status"] = "partial_failure" if not job.finished else "failed"
if "errors" not in result:
result["errors"] = []
if error:
error_str = str(error)
# Avoid duplicate errors (limit to last 100 to prevent unbounded growth)
if error_str not in result["errors"]:
result["errors"].append(error_str)
if len(result["errors"]) > 100:
result["errors"] = result["errors"][-100:] # Keep last 100 errors
# Set main error field for backward compatibility
if "error" not in result and error:
result["error"] = str(error)
elif "error" not in result and result.get("errors"):
result["error"] = result["errors"][0] # Use first error as main error
job.result = result
job.failed = failed or job.failed
job.save(update_fields=["failed", "result"])
================================================
FILE: api/drf_optimize.py
================================================
from django.db import ProgrammingError, models
from django.db.models.constants import LOOKUP_SEP
from django.db.models.query import normalize_prefetch_lookups
from rest_framework import serializers
from rest_framework.utils import model_meta
class OptimizeRelatedModelViewSetMetaclass(type):
"""This metaclass optimizes the queryset using `prefetch_related` and `select_related`.
Any attribute of `_base_forward_rel` as attributes on either the class or on
any of its superclasses will be include in the `base_forward_rel`
they must be ForeignKey fields and only those will be included.
If the `serializer_class` attribute is an instance of `serializers.ModelSerializer`
included as an attribute on the class the `serializers.ModelSerializer.Meta.fields`
is also added calling prefetch_related on Many-To-One and Many-To-Many related objects.
"""
@classmethod
def get_many_to_many_rel(cls, info, meta_fields):
many_to_many_fields = [
field_name
for field_name, relation_info in info.relations.items()
if relation_info.to_many
]
many_to_many_lookups = []
for lookup_name, lookup in cls.get_lookups(meta_fields):
if lookup_name in many_to_many_fields:
many_to_many_lookups.append(lookup)
return many_to_many_lookups
@classmethod
def get_lookups(cls, fields, strict=False):
field_lookups = [(lookup.split(LOOKUP_SEP, 1)[0], lookup) for lookup in fields]
if strict:
field_lookups = [f for f in field_lookups if LOOKUP_SEP in f[1]]
return field_lookups
@classmethod
def get_many_to_one_rel(cls, info, meta_fields):
try:
fields = [
field_name
for field_name, relation_info in info.forward_relations.items()
if issubclass(type(relation_info[0]), models.ForeignKey)
]
except IndexError:
pass
else:
if fields:
forward_many_to_many_rel = []
for lookup_name, lookup in cls.get_lookups(meta_fields, strict=True):
if lookup_name in fields:
forward_many_to_many_rel.append(lookup)
return forward_many_to_many_rel
return []
@classmethod
def get_forward_rel(cls, info, meta_fields):
return [
field_name
for field_name, relation_info in info.forward_relations.items()
if field_name in meta_fields and not relation_info.to_many
]
def __new__(cls, name, bases, attrs):
serializer_class = attrs.get("serializer_class", None)
many_to_many_fields = many_to_one_fields = related_fields = []
info = None
base_forward_rel = list(attrs.pop("_base_forward_rel", ()))
for base in reversed(bases):
if hasattr(base, "_base_forward_rel"):
base_forward_rel.extend(list(base._base_forward_rel))
if serializer_class and issubclass(
serializer_class, serializers.ModelSerializer
):
base_forward_rel.extend(
list(getattr(serializer_class, "_related_fields", [])),
)
many_to_many_fields.extend(
list(getattr(serializer_class, "_many_to_many_fields", [])),
)
many_to_one_fields.extend(
list(getattr(serializer_class, "_many_to_one_fields", [])),
)
if hasattr(serializer_class.Meta, "model"):
info = model_meta.get_field_info(serializer_class.Meta.model)
meta_fields = list(serializer_class.Meta.fields)
many_to_many_fields.extend(meta_fields)
many_to_one_fields.extend(meta_fields)
base_forward_rel.extend(meta_fields)
if info is not None:
many_to_many_fields = cls.get_many_to_many_rel(
info, set(many_to_many_fields)
)
many_to_one_fields = cls.get_many_to_one_rel(info, set(many_to_one_fields))
related_fields = cls.get_forward_rel(info, set(base_forward_rel))
queryset = attrs.get("queryset", None)
try:
if queryset:
if many_to_many_fields:
queryset = queryset.prefetch_related(
*normalize_prefetch_lookups(
set(many_to_many_fields + many_to_one_fields)
),
)
if related_fields:
queryset = queryset.select_related(*related_fields)
attrs["queryset"] = queryset.all()
except ProgrammingError:
pass
return super(OptimizeRelatedModelViewSetMetaclass, cls).__new__(
cls, name, bases, attrs
)
================================================
FILE: api/duplicate_detection.py
================================================
"""
Duplicate detection module for finding duplicate photos.
Handles two types of duplicates:
- EXACT_COPY: Files with identical MD5 hash (byte-for-byte copies)
- VISUAL_DUPLICATE: Photos with similar perceptual hash
This is separate from stack detection (RAW+JPEG pairs, bursts, etc.)
because duplicates are about storage cleanup, not photo organization.
Optimized with BK-Tree for efficient visual duplicate detection.
Memory Optimizations (v2):
- detect_exact_copies: Uses database aggregation (GROUP BY) instead of loading
all photos into memory. Only photo IDs are loaded, not full objects.
- detect_visual_duplicates: Processes photos in configurable batches (default 10k).
Uses two-pass algorithm: within-batch BK-Tree search, then cross-batch linear scan.
Memory usage: O(batch_size) instead of O(total_photos).
With 300k photos:
- Old: ~10GB+ RAM (all photos + files + large BK-Tree)
- New: ~100-200MB RAM (batch + hash list only)
"""
from collections import defaultdict
from django.db.models import Q
from api.models import Photo
from api.models.duplicate import Duplicate
from api.models.file import File
from api.models.long_running_job import LongRunningJob
from api.perceptual_hash import DEFAULT_HAMMING_THRESHOLD, hamming_distance
from api.util import logger
class BKTree:
"""
Burkhard-Keller Tree for efficient Hamming distance queries.
Achieves O(log n) average case by pruning branches using triangle inequality.
"""
def __init__(self, distance_func):
self.distance = distance_func
self.root = None
self.size = 0
def add(self, item_id, item_hash):
"""Add an item (id, hash) to the tree."""
self.size += 1
if self.root is None:
self.root = {"id": item_id, "hash": item_hash, "children": {}}
return
node = self.root
while True:
dist = self.distance(item_hash, node["hash"])
if dist in node["children"]:
node = node["children"][dist]
else:
node["children"][dist] = {
"id": item_id,
"hash": item_hash,
"children": {},
}
break
def search(self, query_hash, threshold):
"""Find all items within threshold Hamming distance of query."""
if self.root is None:
return []
results = []
candidates = [self.root]
while candidates:
node = candidates.pop()
dist = self.distance(query_hash, node["hash"])
if dist <= threshold:
results.append((node["id"], dist))
min_dist = max(0, dist - threshold)
max_dist = dist + threshold
for d, child in node["children"].items():
if min_dist <= d <= max_dist:
candidates.append(child)
return results
class UnionFind:
"""Union-Find with path compression and union by rank."""
def __init__(self):
self.parent = {}
self.rank = {}
def find(self, x):
if x not in self.parent:
self.parent[x] = x
self.rank[x] = 0
return x
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
px, py = self.find(x), self.find(y)
if px == py:
return
if self.rank[px] < self.rank[py]:
px, py = py, px
self.parent[py] = px
if self.rank[px] == self.rank[py]:
self.rank[px] += 1
def get_groups(self):
groups = defaultdict(list)
for item in self.parent:
groups[self.find(item)].append(item)
return [group for group in groups.values() if len(group) > 1]
def detect_exact_copies(user, progress_callback=None):
"""
Detect exact file copies for a user.
Groups photos that have the same content hash. Uses database aggregation
to efficiently find duplicate groups without loading all photos into memory.
Memory optimized: Uses database GROUP BY instead of Python dictionaries.
Args:
user: The user whose photos to analyze
progress_callback: Optional callback(current, total, found) for progress
Returns:
Number of duplicate groups created
"""
from django.db.models import Count
# Method 1: Find duplicate groups by Photo.image_hash using database aggregation
# This is memory efficient as we only load photo IDs grouped by hash
image_hash_groups = (
Photo.objects.filter(
Q(owner=user)
& Q(hidden=False)
& Q(in_trashcan=False)
& Q(removed=False)
& Q(image_hash__isnull=False)
)
.values("image_hash")
.annotate(count=Count("id"))
.filter(count__gt=1)
.values_list("image_hash", flat=True)
)
# Method 2: Find duplicate groups by File content hash (MD5 part)
# We need to use a SUBSTRING operation to extract the MD5 part
# This is done via raw SQL for efficiency
from django.db import connection
file_hash_duplicates = []
with connection.cursor() as cursor:
# Extract first 32 chars (MD5) from File.hash and find duplicates
# Only consider non-metadata files
cursor.execute(
"""
SELECT SUBSTRING(f.hash, 1, 32) as content_hash
FROM api_file f
INNER JOIN api_photo_files pf ON pf.file_id = f.hash
INNER JOIN api_photo p ON p.id = pf.photo_id
WHERE p.owner_id = %s
AND p.hidden = FALSE
AND p.in_trashcan = FALSE
AND p.removed = FALSE
AND f.type != %s
GROUP BY SUBSTRING(f.hash, 1, 32)
HAVING COUNT(DISTINCT p.id) > 1
""",
[user.id, File.METADATA_FILE],
)
file_hash_duplicates = [row[0] for row in cursor.fetchall()]
# Use Union-Find to merge overlapping groups
uf = UnionFind()
# Process image_hash groups
# Note: We need to iterate through the queryset, which will load the hashes into memory
# But this is much better than loading all photos with files
image_hash_list = list(image_hash_groups) # Load just the hashes
total_image_groups = len(image_hash_list)
for i, image_hash in enumerate(image_hash_list):
# Only load photo IDs, not full Photo objects
photo_ids = list(
Photo.objects.filter(
owner=user,
image_hash=image_hash,
hidden=False,
in_trashcan=False,
removed=False,
).values_list("id", flat=True)
)
if len(photo_ids) >= 2:
first = photo_ids[0]
for pid in photo_ids[1:]:
uf.union(first, pid)
if progress_callback and i % 100 == 0:
progress_callback(i, total_image_groups * 2, 0)
# Process file_hash groups
for i, content_hash in enumerate(file_hash_duplicates):
# Find photos with files matching this content hash
photo_ids = list(
Photo.objects.filter(
owner=user,
hidden=False,
in_trashcan=False,
removed=False,
files__hash__startswith=content_hash,
)
.exclude(files__type=File.METADATA_FILE)
.distinct()
.values_list("id", flat=True)
)
if len(photo_ids) >= 2:
first = photo_ids[0]
for pid in photo_ids[1:]:
uf.union(first, pid)
if progress_callback and i % 100 == 0:
progress_callback(total_image_groups + i, total_image_groups * 2, 0)
# Get merged groups from Union-Find
merged_groups = uf.get_groups()
duplicates_created = 0
total = len(merged_groups)
for i, photo_id_group in enumerate(merged_groups):
if len(photo_id_group) < 2:
continue
# Get Photo objects for this group
group_photos = Photo.objects.filter(id__in=photo_id_group)
# Create or merge duplicate group using the helper method
duplicate = Duplicate.create_or_merge(
owner=user,
duplicate_type=Duplicate.DuplicateType.EXACT_COPY,
photos=group_photos,
)
if duplicate:
duplicates_created += 1
if progress_callback and i % 100 == 0:
progress_callback(i, total, duplicates_created)
logger.info(
f"Exact copy detection for {user.username}: found {duplicates_created} duplicate groups"
)
return duplicates_created
def detect_visual_duplicates(
user, threshold=DEFAULT_HAMMING_THRESHOLD, progress_callback=None, batch_size=10000
):
"""
Detect visually similar photos using perceptual hash.
Memory optimized: Processes photos in batches to avoid loading all data into memory.
Uses a two-pass approach for complete duplicate detection with bounded memory.
Algorithm:
1. First pass: Build BKTree in batches, find within-batch duplicates
2. Second pass: Compare each batch against all previous batches using linear scan
The linear scan in pass 2 is acceptable because:
- We only store (id, hash) tuples, not full Photo objects
- Hamming distance is very fast to compute
- With 300k photos, we have ~300k comparisons per batch, which is fast
Args:
user: The user whose photos to analyze
threshold: Hamming distance threshold (default: 10)
progress_callback: Optional callback(current, total, found) for progress
batch_size: Number of photos to process per batch (default: 10000)
Returns:
Number of duplicate groups created
"""
# Get photos with perceptual hash that aren't already in visual duplicate groups
# Exclude removed photos to avoid including merged/deleted duplicates
photos_queryset = (
Photo.objects.filter(
Q(owner=user)
& Q(hidden=False)
& Q(in_trashcan=False)
& Q(removed=False)
& Q(perceptual_hash__isnull=False)
)
.exclude(duplicates__duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE)
.only("id", "perceptual_hash")
)
total = photos_queryset.count()
if total < 2:
return 0
logger.info(
f"Processing {total} photos in batches of {batch_size} (user: {user.username})"
)
# Union-Find for grouping across all batches
uf = UnionFind()
pairs_found = 0
processed = 0
# Store all photo hashes as (id, hash) tuples for cross-batch comparison
# Memory efficient: 300k photos × ~28 bytes = ~8.4MB theoretical
# In practice, Python overhead means ~25-40MB for list + objects
all_photo_hashes = []
# Calculate number of batches
num_batches = (total + batch_size - 1) // batch_size
# Pass 1: Process each batch internally and build the complete hash list
for batch_idx in range(num_batches):
offset = batch_idx * batch_size
# Get current batch using slicing (memory efficient)
batch_photos = list(
photos_queryset[offset : offset + batch_size].values(
"id", "perceptual_hash"
)
)
if not batch_photos:
break
logger.info(
f"Pass 1: Processing batch {batch_idx + 1}/{num_batches} ({len(batch_photos)} photos)"
)
# Build temporary BK-Tree for current batch (for efficient within-batch search)
batch_tree = BKTree(hamming_distance)
batch_hashes = []
for photo in batch_photos:
photo_id = photo["id"]
phash = photo["perceptual_hash"]
if phash:
batch_tree.add(photo_id, phash)
batch_hashes.append((photo_id, phash))
# Find duplicates within current batch using BK-Tree
for photo_id, phash in batch_hashes:
similar = batch_tree.search(phash, threshold)
for similar_id, distance in similar:
if similar_id != photo_id:
uf.union(photo_id, similar_id)
pairs_found += 1
# Add batch to the complete list for cross-batch comparison
all_photo_hashes.extend(batch_hashes)
processed += len(batch_photos)
if progress_callback:
# Report progress for pass 1 (first 50% of total work)
progress_callback(processed // 2, total, pairs_found)
logger.info(
f"Pass 1 complete. Found {pairs_found} within-batch pairs. Starting cross-batch comparison."
)
# Pass 2: Compare each batch against all previous photos (linear scan)
# This ensures we don't miss duplicates between distant batches
processed = 0
for batch_idx in range(num_batches):
start_idx = batch_idx * batch_size
end_idx = min(start_idx + batch_size, len(all_photo_hashes))
if start_idx >= end_idx:
break
batch_hashes = all_photo_hashes[start_idx:end_idx]
logger.info(
f"Pass 2: Comparing batch {batch_idx + 1}/{num_batches} against previous photos"
)
# Compare current batch against all previous photos
# Store the previous photos slice once to avoid repeated slicing
previous_hashes = all_photo_hashes[:start_idx] if start_idx > 0 else []
for photo_id, phash in batch_hashes:
# Only compare against photos in previous batches (avoid duplicate comparisons)
for prev_id, prev_hash in previous_hashes:
distance = hamming_distance(phash, prev_hash)
if distance <= threshold:
uf.union(photo_id, prev_id)
pairs_found += 1
processed += len(batch_hashes)
if progress_callback:
# Report progress for pass 2 (second 50% of total work)
progress_callback(total // 2 + processed // 2, total, pairs_found)
logger.info(f"Pass 2 complete. Total pairs found: {pairs_found}")
# Create duplicate groups from Union-Find groups
groups = uf.get_groups()
duplicates_created = 0
for group in groups:
if len(group) < 2:
continue
# Get Photo objects for this group
group_photos = Photo.objects.filter(id__in=group)
# Create or merge duplicate group
duplicate = Duplicate.create_or_merge(
owner=user,
duplicate_type=Duplicate.DuplicateType.VISUAL_DUPLICATE,
photos=group_photos,
)
if duplicate:
duplicates_created += 1
logger.info(
f"Visual duplicate detection for {user.username}: found {duplicates_created} groups from {pairs_found} pairs"
)
return duplicates_created
def batch_detect_duplicates(user, options=None):
"""
Run batch duplicate detection for a user.
Args:
user: The user whose photos to analyze
options: Dict with detection options:
- detect_exact_copies: bool (default: True)
- detect_visual_duplicates: bool (default: True)
- visual_threshold: int (default: 10)
- clear_pending: bool (default: False)
- batch_size: int (default: 10000) - photos per batch for visual detection
"""
if options is None:
options = {}
detect_exact = options.get("detect_exact_copies", True)
detect_visual = options.get("detect_visual_duplicates", True)
visual_threshold = options.get("visual_threshold", DEFAULT_HAMMING_THRESHOLD)
clear_pending = options.get("clear_pending", False)
batch_size = options.get("batch_size", 10000)
# Create long-running job for progress tracking
job = LongRunningJob.create_job(
user=user,
job_type=LongRunningJob.JOB_DETECT_DUPLICATES,
start_now=True,
)
try:
# Clear pending duplicates if requested
if clear_pending:
cleared = Duplicate.objects.filter(
owner=user, review_status=Duplicate.ReviewStatus.PENDING
).delete()[0]
logger.info(f"Cleared {cleared} pending duplicates for {user.username}")
total_found = 0
# Detect exact copies
if detect_exact:
def progress_exact(current, total, found):
job.set_result(
{
"stage": "exact_copies",
"current": current,
"total": total,
"found": found,
}
)
exact_count = detect_exact_copies(user, progress_exact)
total_found += exact_count
# Detect visual duplicates
if detect_visual:
def progress_visual(current, total, found):
job.set_result(
{
"stage": "visual_duplicates",
"current": current,
"total": total,
"found": found,
}
)
visual_count = detect_visual_duplicates(
user, visual_threshold, progress_visual, batch_size
)
total_found += visual_count
job.complete(result={"status": "completed", "duplicates_found": total_found})
logger.info(
f"Duplicate detection completed for {user.username}: {total_found} groups found"
)
except Exception as e:
logger.error(f"Duplicate detection failed for {user.username}: {e}")
job.fail(error=e)
raise
================================================
FILE: api/face_classify.py
================================================
import datetime
import uuid
import numpy as np
import seaborn as sns
from bulk_update.helper import bulk_update
from django.core.paginator import Paginator
from django.db.models import Q
from django_q.tasks import AsyncTask
from hdbscan import HDBSCAN
from sklearn.decomposition import PCA
from sklearn.neural_network import MLPClassifier
from api.cluster_manager import ClusterManager
from api.models import Face, LongRunningJob, Person
from api.models.cluster import UNKNOWN_CLUSTER_ID, Cluster, get_unknown_cluster
from api.models.user import User, get_deleted_user
from api.util import logger
FACE_CLASSIFY_COLUMNS = [
"person",
"classification_person",
"classification_probability",
"cluster_person",
"cluster_probability",
"id",
"cluster",
]
def cluster_faces(user, inferred=True):
# Fetch distinct persons associated with the user's faces
persons = [p.id for p in Person.objects.filter(faces__photo__owner=user).distinct()]
# Create a color mapping for each person
p2c = dict(zip(persons, sns.color_palette(n_colors=len(persons)).as_hex()))
face_encoding = []
faces_with_encoding = []
# Fetch faces that belong to the user and are not deleted
faces = Face.objects.filter(Q(photo__owner=user) & Q(deleted=False))
paginator = Paginator(faces, 5000)
for page in range(1, paginator.num_pages + 1):
for face in paginator.page(page).object_list:
if ((not face.person) or inferred) and face.encoding:
face_encoding.append(face.get_encoding_array())
faces_with_encoding.append(face)
# Return empty result if no faces with encodings
if len(face_encoding) == 0:
return {"status": True, "data": []}
# Perform PCA for dimensionality reduction
pca = PCA(n_components=3)
vis_all = pca.fit_transform(face_encoding)
res = []
for face, vis in zip(faces_with_encoding, vis_all):
person_id = face.person.id if face.person else UNKNOWN_CLUSTER_ID
person_name = face.person.name if face.person else "unknown"
# Ensure UNKNOWN_CLUSTER_ID is in p2c
if person_id not in p2c:
# Assign a default color if not found
color = "#000000" # Default to black or any fallback color
else:
color = p2c[person_id]
res.append(
{
"person_id": person_id,
"person_name": person_name,
"person_label_is_inferred": not face.person,
"color": color,
"face_url": face.image.url,
"value": {"x": vis[0], "y": vis[1], "size": vis[2]},
}
)
return {"status": True, "data": res}
def cluster_all_faces(user, job_id) -> bool:
"""Groups all faces into clusters for ease of labeling. It first deletes all
existing clusters, then regenerates them all. It will split clusters that have
more than one kind of labeled face.
:param user: the current user running the training
:param job_id: the background job ID
"""
lrj = LongRunningJob.get_or_create_job(
user=user,
job_type=LongRunningJob.JOB_CLUSTER_ALL_FACES,
job_id=job_id,
)
lrj.update_progress(current=0, target=1)
try:
delete_clustered_people(user)
delete_clusters(user)
delete_persons_without_faces()
target_count: int = create_all_clusters(user, lrj)
lrj.update_progress(current=target_count, target=target_count)
lrj.complete()
train_job_id = uuid.uuid4()
AsyncTask(train_faces, user, train_job_id).run()
return True
except BaseException as err:
logger.exception("An error occurred")
print(f"[ERR] {err}")
lrj.fail(error=err)
return False
def create_all_clusters(user: User, lrj: LongRunningJob = None) -> int:
"""Generate Cluster records for each different clustering of people
:param user: the current user
:param lrj: LongRunningJob to update, if needed
"""
all_clusters: list[Cluster] = []
face: Face
logger.info("Creating clusters")
data = {
"all": {"encoding": [], "id": [], "person_id": [], "person_labeled": []},
}
for face in Face.objects.filter(photo__owner=user).prefetch_related("person"):
data["all"]["encoding"].append(face.get_encoding_array())
data["all"]["id"].append(face.id)
target_count = len(data["all"]["id"])
if target_count == 0:
return target_count
min_cluster_size = 2
# double cluster size for every 10x increase in target counts, if user has not set a valid min_cluster_size
if (
user.min_cluster_size == 0
or user.min_cluster_size is None
or user.min_cluster_size == 1
):
if target_count > 1000:
min_cluster_size = 4
if target_count > 10000:
min_cluster_size = 8
if target_count > 100000:
min_cluster_size = 16
else:
min_cluster_size = user.min_cluster_size
min_samples = 1
if user.min_samples > 0:
min_samples = user.min_samples
# creating HDBSCAN object for clustering the encodings with the metric "euclidean"
clt = HDBSCAN(
min_cluster_size=min_cluster_size,
min_samples=min_samples,
cluster_selection_epsilon=user.cluster_selection_epsilon,
metric="euclidean",
)
logger.info("Before finding clusters")
# clustering the encodings
clt.fit(np.array(data["all"]["encoding"]))
logger.info("After finding clusters")
labelIDs = np.unique(clt.labels_)
labelID: np.intp
commit_time = datetime.datetime.now() + datetime.timedelta(seconds=5)
count: int = 0
maxLen: int = len(str(np.size(labelIDs)))
sortedIndexes: dict[int, np.ndarray] = dict()
clusterCount: int = 0
clusterId: int
for labelID in labelIDs:
idxs = np.where(clt.labels_ == labelID)[0]
sortedIndexes[labelID] = idxs
logger.info(f"Found {len(sortedIndexes)} clusters")
for labelID in sorted(
sortedIndexes, key=lambda key: np.size(sortedIndexes[key]), reverse=True
):
if labelID != UNKNOWN_CLUSTER_ID:
clusterCount = clusterCount + 1
clusterId = clusterCount
else:
clusterId = labelID
face_array: list[Face] = []
face_id_list: list[int] = []
for i in sortedIndexes[labelID]:
count = count + 1
face_id = data["all"]["id"][i]
face_id_list.append(face_id)
face_array = Face.objects.filter(
Q(pk__in=face_id_list) & Q(encoding__isnull=False) & Q(deleted=False)
)
new_clusters: list[Cluster] = ClusterManager.try_add_cluster(
user, clusterId, face_array, maxLen
)
if commit_time < datetime.datetime.now() and lrj is not None:
lrj.progress_current = count
lrj.progress_target = target_count
lrj.save()
commit_time = datetime.datetime.now() + datetime.timedelta(seconds=5)
all_clusters.extend(new_clusters)
print(f"[INFO] Created {len(all_clusters)} clusters")
return target_count
def delete_persons_without_faces():
"""Delete all existing Person records that have no associated Face records"""
print("[INFO] Deleting all people without faces")
Person.objects.filter(faces=None, kind=Person.KIND_USER).delete()
def delete_clusters(user: User):
"""Delete all existing Cluster records"""
print("[INFO] Deleting all clusters")
Cluster.objects.filter(Q(owner=user)).delete()
Cluster.objects.filter(Q(owner=None)).delete()
Cluster.objects.filter(Q(owner=get_deleted_user())).delete()
def delete_clustered_people(user: User):
"""Delete all existing Person records of type CLUSTER"""
print("[INFO] Deleting all clustered people")
Person.objects.filter(kind=Person.KIND_CLUSTER, cluster_owner=user).delete()
Person.objects.filter(kind=Person.KIND_UNKNOWN, cluster_owner=user).delete()
Person.objects.filter(cluster_owner=None).delete()
Person.objects.filter(cluster_owner=get_deleted_user()).delete()
# Function to filter data based on a desired shape
def filter_data(encodings, ids):
valid_encodings = []
valid_ids = []
expected_shape = (
len(encodings[0]) if encodings else 0
) # Set expected shape from first entry
for i, (encoding, id_) in enumerate(zip(encodings, ids)):
if len(encoding) == expected_shape: # Check if shape is consistent
valid_encodings.append(encoding)
valid_ids.append(id_)
else:
logger.error(
f"Discarding entry {i}: ID={id_}, encoding shape={len(encoding)} (expected {expected_shape})"
)
return np.array(valid_encodings), np.array(valid_ids)
def train_faces(user: User, job_id) -> bool:
"""Given existing Cluster records for all faces, determines the probability
that unknown faces belong to those Clusters. It takes any known, labeled faces
and adds the centroids of "unknown" clusters, assuming that those clusters
correspond to *some* face. It then trains a classifier on that data to use
in calculating the probabilities for unknown faces.
:param user: the current user running the training
:param job_id: the background job ID
"""
lrj = LongRunningJob.get_or_create_job(
user=user,
job_type=LongRunningJob.JOB_TRAIN_FACES,
job_id=job_id,
)
lrj.update_progress(current=1, target=2)
try:
# Use two array, so that the first one gets thrown out, if it is no longer used.
data_known = {"encoding": [], "id": []}
data_unknown = {"encoding": [], "id": []}
# First, sort all faces into known and unknown ones
face: Face
for face in Face.objects.filter(
Q(photo__owner=user) & Q(encoding__isnull=False) & Q(deleted=False)
).prefetch_related("person"):
if not face.person:
data_unknown["encoding"].append(face.get_encoding_array())
data_unknown["id"].append(face.id)
else:
data_known["encoding"].append(face.get_encoding_array())
data_known["id"].append(face.person.id)
if len(data_known["id"]) == 0:
classifier = None
else:
logger.info("Before fitting")
classifier = MLPClassifier(
solver="adam", alpha=1e-5, random_state=1, max_iter=1000
).fit(np.array(data_known["encoding"]), np.array(data_known["id"]))
logger.info("After fitting")
# Next, pretend all unknown face clusters are known and add their mean encoding. This allows us
# to predict the likelihood of other unknown faces belonging to those simulated clusters. For
# the "Unknown - Other"-type cluster, we can still try to predict the probability that the face
# can't be classified into another group, i.e. that it should be classified that way
cluster: Cluster
for cluster in Cluster.objects.filter(owner=user):
if cluster.person and cluster.person.kind == Person.KIND_CLUSTER:
print(cluster.person)
data_known["encoding"].append(cluster.get_mean_encoding_array())
data_known["id"].append(cluster.person.id)
filtered_encodings, filtered_ids = filter_data(
data_known["encoding"], data_known["id"]
)
# Fit the classifier based on the "known" faces, including the simulated clusters
logger.info("Before cluster fitting")
cluster_classifier = MLPClassifier(
solver="adam", alpha=1e-5, random_state=1, max_iter=1000
).fit(filtered_encodings, filtered_ids)
logger.info("After cluster fitting")
# Collect the probabilities for each unknown face. The probabilities returned
# are arrays in the same order as the people IDs in the original training set
target_count = len(data_unknown["id"])
if target_count == 0:
logger.info("No clusters found")
lrj.update_progress(current=2, target=2)
lrj.complete()
return True
logger.info(f"Number of Cluster: {target_count}")
# Hacky way to split arrays into smaller arrays
pages_encoding = [
data_unknown["encoding"][i : i + 100]
for i in range(0, len(data_unknown["encoding"]), 100)
]
pages_id = [
data_unknown["id"][i : i + 100]
for i in range(0, len(data_unknown["encoding"]), 100)
]
for idx, page in enumerate(pages_encoding):
page_id = pages_id[idx]
pages_of_faces = Face.objects.filter(id__in=page_id).all()
# sort pages of faces by id by page_id
pages_of_faces = sorted(pages_of_faces, key=lambda x: page_id.index(x.id))
face_encodings_unknown_np = np.array(page)
cluster_probs = cluster_classifier.predict_proba(face_encodings_unknown_np)
if classifier:
classification_probs = classifier.predict_proba(
face_encodings_unknown_np
)
else:
classification_probs = []
while len(classification_probs) < len(cluster_probs):
classification_probs.append(
[0.0] * len(cluster_classifier.classes_)
)
commit_time = datetime.datetime.now() + datetime.timedelta(seconds=5)
face_stack = []
unknown_cluster: Cluster = get_unknown_cluster(user=user)
for idx, (
face,
cluster_probabilities,
classification_probabilties,
) in enumerate(zip(pages_of_faces, cluster_probs, classification_probs)):
face.cluster_probability = 0.0 # Cluster probability
face.classification_probability = 0.0 # Classification probability
classification_person = None
classification_probability = 0.0
highest_classification_probability = max(classification_probabilties)
highest_classification_person = 0
# Find the person with the highest probability for classification
if classifier:
for i, target in enumerate(classifier.classes_):
if (
highest_classification_probability
== classification_probabilties[i]
):
highest_classification_person = target
classification_person = highest_classification_person
classification_probability = highest_classification_probability
# Find the probability in the probability array corresponding to the person
# that we currently believe the face is, even a simulated "unknown" person
highest_probability = max(cluster_probabilities)
highest_probability_person = 0
for i, target in enumerate(cluster_classifier.classes_):
if highest_probability == cluster_probabilities[i]:
highest_probability_person = target
if face.cluster != unknown_cluster:
face.cluster_person = Person.objects.get(
id=highest_probability_person
)
face.cluster_probability = highest_probability
if classification_person:
face.classification_person = Person.objects.get(
id=classification_person
)
face.classification_probability = classification_probability
face_stack.append(face)
if commit_time < datetime.datetime.now():
lrj.update_progress(current=idx + 1, target=target_count)
commit_time = datetime.datetime.now() + datetime.timedelta(
seconds=5
)
if len(face_stack) > 200:
bulk_update(face_stack, update_fields=FACE_CLASSIFY_COLUMNS)
face_stack = []
bulk_update(face_stack, update_fields=FACE_CLASSIFY_COLUMNS)
lrj.update_progress(current=target_count, target=target_count)
lrj.complete()
return True
except BaseException as err:
logger.exception("An error occurred")
print(f"[ERR] {err}")
lrj.fail(error=err)
return False
================================================
FILE: api/face_extractor.py
================================================
import numpy as np
import PIL
from api.face_recognition import get_face_locations
from api.metadata.reader import get_metadata
from api.metadata.tags import Tags
from api.util import is_number, logger
class RuleTypes:
EXIF = "exif"
DLIB = "dlib"
def extract_from_exif(image_path, big_thumbnail_image_path):
(region_info, orientation) = get_metadata(
image_path,
tags=[Tags.REGION_INFO, Tags.ORIENTATION],
try_sidecar=True,
struct=True,
)
if not region_info:
return
logger.debug(f"Extracted region_info for {image_path}")
logger.debug(f"region_info: {region_info}")
face_locations = []
for region in region_info["RegionList"]:
if region.get("Type") != "Face":
continue
person_name = region.get("Name")
area = region.get("Area")
applied_to_dimensions = region.get("AppliedToDimensions")
big_thumbnail_image = np.array(PIL.Image.open(big_thumbnail_image_path))
if (area and area.get("Unit") == "normalized") or (
applied_to_dimensions and applied_to_dimensions.get("Unit") == "pixel"
):
image_width = big_thumbnail_image.shape[1]
image_height = big_thumbnail_image.shape[0]
if (
not is_number(area.get("X"))
or not is_number(area.get("Y"))
or not is_number(area.get("W"))
or not is_number(area.get("H"))
):
logger.info(
f"Broken face area exif data! No numerical positional data. region_info: {region_info}"
)
continue
correct_w = float(area.get("W"))
correct_h = float(area.get("H"))
correct_x = float(area.get("X"))
correct_y = float(area.get("Y"))
if orientation == "Rotate 90 CW":
temp_x = correct_x
correct_x = 1 - correct_y
correct_y = temp_x
correct_w, correct_h = correct_h, correct_w
elif orientation == "Mirror horizontal":
correct_x = 1 - correct_x
elif orientation == "Rotate 180":
correct_x = 1 - correct_x
correct_y = 1 - correct_y
elif orientation == "Mirror vertical":
correct_y = 1 - correct_y
elif orientation == "Mirror horizontal and rotate 270 CW":
temp_x = correct_x
correct_x = 1 - correct_y
correct_y = temp_x
correct_w, correct_h = correct_h, correct_w
elif orientation == "Mirror horizontal and rotate 90 CW":
temp_x = correct_x
correct_x = correct_y
correct_y = 1 - temp_x
correct_w, correct_h = correct_h, correct_w
elif orientation == "Rotate 270 CW":
temp_x = correct_x
correct_x = correct_y
correct_y = 1 - temp_x
correct_w, correct_h = correct_h, correct_w
# Calculate the half-width and half-height of the box
half_width = (correct_w * image_width) / 2
half_height = (correct_h * image_height) / 2
# Calculate the top, right, bottom, and left coordinates
top = int((correct_y * image_height) - half_height)
right = int((correct_x * image_width) + half_width)
bottom = int((correct_y * image_height) + half_height)
left = int((correct_x * image_width) - half_width)
face_locations.append((top, right, bottom, left, person_name))
return face_locations
def extract_from_dlib(image_path, big_thumbnail_path, owner):
try:
face_locations = get_face_locations(
big_thumbnail_path,
model=owner.face_recognition_model.lower(),
)
except Exception as e:
logger.info(f"Can't extract face information on photo: {image_path}")
logger.info(e)
face_locations = []
for i, face_location in enumerate(face_locations):
face_locations[i] = (*face_location, None)
return face_locations
def extract(image_path,
gitextract_0h3h3ffo/
├── .coveragerc
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── enhancement-request.md
│ └── workflows/
│ ├── docker-publish.yml
│ └── pre-commit.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CLAUDE.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── api/
│ ├── __init__.py
│ ├── admin.py
│ ├── all_tasks.py
│ ├── api_util.py
│ ├── apps.py
│ ├── autoalbum.py
│ ├── background_tasks.py
│ ├── batch_jobs.py
│ ├── burst_detection_rules.py
│ ├── cluster_manager.py
│ ├── date_time_extractor.py
│ ├── directory_watcher/
│ │ ├── __init__.py
│ │ ├── file_grouping.py
│ │ ├── file_handlers.py
│ │ ├── processing_jobs.py
│ │ ├── repair_jobs.py
│ │ ├── scan_jobs.py
│ │ └── utils.py
│ ├── drf_optimize.py
│ ├── duplicate_detection.py
│ ├── face_classify.py
│ ├── face_extractor.py
│ ├── face_recognition.py
│ ├── feature/
│ │ ├── __init__.py
│ │ ├── embedded_media.py
│ │ └── tests/
│ │ ├── __init__.py
│ │ └── test_embedded_media.py
│ ├── filters.py
│ ├── geocode/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── geocode.py
│ │ └── parsers/
│ │ ├── __init__.py
│ │ ├── mapbox.py
│ │ ├── nominatim.py
│ │ ├── opencage.py
│ │ └── tomtom.py
│ ├── image_captioning.py
│ ├── image_similarity.py
│ ├── llm.py
│ ├── management/
│ │ ├── __init__.py
│ │ └── commands/
│ │ ├── build_similarity_index.py
│ │ ├── clear_cache.py
│ │ ├── createadmin.py
│ │ ├── createuser.py
│ │ ├── save_metadata.py
│ │ ├── scan.py
│ │ ├── start_cleaning_service.py
│ │ ├── start_job_cleanup_service.py
│ │ └── start_service.py
│ ├── metadata/
│ │ ├── __init__.py
│ │ ├── face_regions.py
│ │ ├── reader.py
│ │ ├── tags.py
│ │ └── writer.py
│ ├── middleware.py
│ ├── migrations/
│ │ ├── 0001_initial.py
│ │ ├── 0002_add_confidence.py
│ │ ├── 0003_remove_unused_thumbs.py
│ │ ├── 0004_fix_album_thing_constraint.py
│ │ ├── 0005_add_video_to_photo.py
│ │ ├── 0006_migrate_to_boolean_field.py
│ │ ├── 0007_migrate_to_json_field.py
│ │ ├── 0008_remove_image_path.py
│ │ ├── 0009_add_aspect_ratio.py
│ │ ├── 0009_add_clip_embedding_field.py
│ │ ├── 0010_merge_20210725_1547.py
│ │ ├── 0011_a_add_rating.py
│ │ ├── 0011_b_migrate_favorited_to_rating.py
│ │ ├── 0011_c_remove_favorited.py
│ │ ├── 0012_add_favorite_min_rating.py
│ │ ├── 0013_add_image_scale_and_misc.py
│ │ ├── 0014_add_save_metadata_to_disk.py
│ │ ├── 0015_add_dominant_color.py
│ │ ├── 0016_add_transcode_videos.py
│ │ ├── 0017_add_cover_photo.py
│ │ ├── 0018_user_config_datetime_rules.py
│ │ ├── 0019_change_config_datetime_rules.py
│ │ ├── 0020_add_default_timezone.py
│ │ ├── 0021_remove_photo_image.py
│ │ ├── 0022_photo_video_length.py
│ │ ├── 0023_photo_deleted.py
│ │ ├── 0024_photo_timestamp.py
│ │ ├── 0025_add_cover_photo.py
│ │ ├── 0026_add_cluster_info.py
│ │ ├── 0027_rename_unknown_person.py
│ │ ├── 0028_add_metadata_fields.py
│ │ ├── 0029_change_to_text_field.py
│ │ ├── 0030_user_confidence_person.py
│ │ ├── 0031_remove_account.py
│ │ ├── 0032_always_have_owner.py
│ │ ├── 0033_add_post_delete_person.py
│ │ ├── 0034_allow_deleting_person.py
│ │ ├── 0035_add_files_model.py
│ │ ├── 0036_handle_missing_files.py
│ │ ├── 0037_migrate_to_files.py
│ │ ├── 0038_add_main_file.py
│ │ ├── 0039_remove_photo_image_paths.py
│ │ ├── 0040_add_user_public_sharing_flag.py
│ │ ├── 0041_apply_user_enum_for_person.py
│ │ ├── 0042_alter_albumuser_cover_photo_alter_photo_main_file.py
│ │ ├── 0043_alter_photo_size.py
│ │ ├── 0044_alter_cluster_person_alter_person_cluster_owner.py
│ │ ├── 0045_alter_face_cluster.py
│ │ ├── 0046_add_embedded_media.py
│ │ ├── 0047_alter_file_embedded_media.py
│ │ ├── 0048_fix_null_height.py
│ │ ├── 0049_fix_metadata_files_as_main_files.py
│ │ ├── 0050_person_face_count.py
│ │ ├── 0051_set_person_defaults.py
│ │ ├── 0052_alter_person_name.py
│ │ ├── 0053_user_confidence_unknown_face_and_more.py
│ │ ├── 0054_user_cluster_selection_epsilon_user_min_samples.py
│ │ ├── 0055_alter_longrunningjob_job_type.py
│ │ ├── 0056_user_llm_settings_alter_longrunningjob_job_type.py
│ │ ├── 0057_remove_face_image_path_and_more.py
│ │ ├── 0058_alter_user_avatar_alter_user_nextcloud_app_password_and_more.py
│ │ ├── 0059_person_cover_face.py
│ │ ├── 0060_apply_default_face_cover.py
│ │ ├── 0061_alter_person_name.py
│ │ ├── 0062_albumthing_cover_photos.py
│ │ ├── 0063_apply_default_album_things_cover.py
│ │ ├── 0064_albumthing_photo_count.py
│ │ ├── 0065_apply_default_photo_count.py
│ │ ├── 0066_photo_last_modified_alter_longrunningjob_job_type.py
│ │ ├── 0067_alter_longrunningjob_job_type.py
│ │ ├── 0068_remove_longrunningjob_result_and_more.py
│ │ ├── 0069_rename_to_in_trashcan.py
│ │ ├── 0070_photo_removed.py
│ │ ├── 0071_rename_person_label_probability_face_cluster_probability_and_more.py
│ │ ├── 0072_alter_face_person.py
│ │ ├── 0073_remove_unknown_person.py
│ │ ├── 0074_migrate_cluster_person.py
│ │ ├── 0075_alter_face_cluster_person.py
│ │ ├── 0076_alter_file_path_alter_longrunningjob_job_type_and_more.py
│ │ ├── 0077_alter_albumdate_title.py
│ │ ├── 0078_create_photo_thumbnail.py
│ │ ├── 0079_alter_albumauto_title.py
│ │ ├── 0080_create_photo_caption.py
│ │ ├── 0081_remove_caption_fields_from_photo.py
│ │ ├── 0082_create_photo_search.py
│ │ ├── 0083_remove_search_fields.py
│ │ ├── 0084_convert_arrayfield_to_json.py
│ │ ├── 0085_albumuser_public_expires_at_albumuser_public_slug.py
│ │ ├── 0086_remove_albumuser_public_and_more.py
│ │ ├── 0087_add_folder_album.py
│ │ ├── 0088_remove_folder_album.py
│ │ ├── 0089_add_text_alignment.py
│ │ ├── 0090_add_header_size.py
│ │ ├── 0091_alter_user_scan_directory.py
│ │ ├── 0092_add_skip_raw_files_field.py
│ │ ├── 0093_migrate_photon_to_nominatim.py
│ │ ├── 0094_add_slideshow_interval.py
│ │ ├── 0095_photo_perceptual_hash_alter_longrunningjob_job_type_and_more.py
│ │ ├── 0096_add_progress_step_and_result_to_longrunningjob.py
│ │ ├── 0097_add_duplicate_detection_settings_to_user.py
│ │ ├── 0098_add_photo_stack.py
│ │ ├── 0099_photo_uuid_primary_key.py
│ │ ├── 0100_metadataedit_metadatafile_photometadata_stackreview_and_more.py
│ │ ├── 0101_populate_photo_metadata.py
│ │ ├── 0102_photo_stacks_manytomany.py
│ │ ├── 0103_remove_photo_metadata_fields.py
│ │ ├── 0104_remove_photostack_potential_savings_and_more.py
│ │ ├── 0105_alter_photo_image_hash.py
│ │ ├── 0106_alter_longrunningjob_options.py
│ │ ├── 0107_add_burst_detection_rules.py
│ │ ├── 0108_add_stack_raw_jpeg_field.py
│ │ ├── 0109_migrate_skip_raw_to_stack_raw_jpeg.py
│ │ ├── 0110_fix_file_embedded_media_self_reference.py
│ │ ├── 0111_alter_file_embedded_media.py
│ │ ├── 0112_convert_file_stacks_to_variants.py
│ │ ├── 0113_alter_photostack_stack_type.py
│ │ ├── 0114_add_file_path_unique.py
│ │ ├── 0115_cleanup_duplicate_photos.py
│ │ ├── 0116_cleanup_duplicate_groups_removed_photos.py
│ │ ├── 0117_delete_removed_photos.py
│ │ ├── 0118_alter_longrunningjob_job_type.py
│ │ ├── 0119_add_public_sharing_options.py
│ │ ├── 0120_rename_thumbnails_uuid_to_hash.py
│ │ ├── 0121_add_default_tagging_model.py
│ │ ├── 0121_user_save_face_tags_to_disk.py
│ │ └── __init__.py
│ ├── ml_models.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── album_auto.py
│ │ ├── album_date.py
│ │ ├── album_place.py
│ │ ├── album_thing.py
│ │ ├── album_user.py
│ │ ├── album_user_share.py
│ │ ├── cluster.py
│ │ ├── duplicate.py
│ │ ├── face.py
│ │ ├── file.py
│ │ ├── long_running_job.py
│ │ ├── person.py
│ │ ├── photo.py
│ │ ├── photo_caption.py
│ │ ├── photo_metadata.py
│ │ ├── photo_search.py
│ │ ├── photo_stack.py
│ │ ├── stack_review.py
│ │ ├── thumbnail.py
│ │ └── user.py
│ ├── nextcloud.py
│ ├── perceptual_hash.py
│ ├── permissions.py
│ ├── schemas/
│ │ └── site_settings.py
│ ├── semantic_search.py
│ ├── serializers/
│ │ ├── PhotosGroupedByDate.py
│ │ ├── __init__.py
│ │ ├── album_auto.py
│ │ ├── album_date.py
│ │ ├── album_place.py
│ │ ├── album_thing.py
│ │ ├── album_user.py
│ │ ├── face.py
│ │ ├── job.py
│ │ ├── person.py
│ │ ├── photo_metadata.py
│ │ ├── photos.py
│ │ ├── simple.py
│ │ └── user.py
│ ├── services.py
│ ├── social_graph.py
│ ├── stack_detection.py
│ ├── stacks/
│ │ ├── __init__.py
│ │ └── live_photo.py
│ ├── stats.py
│ ├── tests/
│ │ ├── __init__.py
│ │ ├── fixtures/
│ │ │ ├── __init__.py
│ │ │ ├── api_util/
│ │ │ │ ├── captions_json.py
│ │ │ │ ├── expectation.py
│ │ │ │ ├── photos.py
│ │ │ │ └── sunburst_expectation.py
│ │ │ ├── geocode/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── expectations/
│ │ │ │ │ ├── mapbox.py
│ │ │ │ │ ├── nominatim.py
│ │ │ │ │ ├── opencage.py
│ │ │ │ │ └── tomtom.py
│ │ │ │ └── responses/
│ │ │ │ ├── mapbox.py
│ │ │ │ ├── nominatim.py
│ │ │ │ ├── opencage.py
│ │ │ │ └── tomtom.py
│ │ │ ├── location_timeline_test_data.csv
│ │ │ └── niaz.xmp
│ │ ├── test_api_robustness.py
│ │ ├── test_api_util.py
│ │ ├── test_auto_select_and_savings.py
│ │ ├── test_background_tasks.py
│ │ ├── test_bktree_and_duplicate_detection.py
│ │ ├── test_bulk_operations.py
│ │ ├── test_burst_detection_rules.py
│ │ ├── test_burst_filename_patterns.py
│ │ ├── test_delete_photos.py
│ │ ├── test_detection_edge_cases.py
│ │ ├── test_directory_watcher_fix.py
│ │ ├── test_dirtree.py
│ │ ├── test_duplicate_api_edge_cases.py
│ │ ├── test_duplicate_detection.py
│ │ ├── test_duplicate_detection_logic.py
│ │ ├── test_duplicate_filtering.py
│ │ ├── test_edge_cases_integration.py
│ │ ├── test_edit_photo_details.py
│ │ ├── test_face_extractor.py
│ │ ├── test_face_writeback.py
│ │ ├── test_favorite_photos.py
│ │ ├── test_file_model.py
│ │ ├── test_file_path_uniqueness.py
│ │ ├── test_geocode.py
│ │ ├── test_get_faces.py
│ │ ├── test_hide_photos.py
│ │ ├── test_im2txt.py
│ │ ├── test_live_photo.py
│ │ ├── test_location_timeline.py
│ │ ├── test_metadata_ordering_sentinel.py
│ │ ├── test_migration_0099.py
│ │ ├── test_migration_0101.py
│ │ ├── test_multi_user_isolation.py
│ │ ├── test_only_photos_or_only_videos.py
│ │ ├── test_perceptual_hash.py
│ │ ├── test_photo_caption_model.py
│ │ ├── test_photo_captions.py
│ │ ├── test_photo_lifecycle.py
│ │ ├── test_photo_list_without_timestamp.py
│ │ ├── test_photo_metadata.py
│ │ ├── test_photo_metadata_api.py
│ │ ├── test_photo_model_integration.py
│ │ ├── test_photo_search_model.py
│ │ ├── test_photo_search_refactor.py
│ │ ├── test_photo_summary.py
│ │ ├── test_photo_viewset_permissions.py
│ │ ├── test_predefined_rules.py
│ │ ├── test_public_photos.py
│ │ ├── test_reading_exif.py
│ │ ├── test_recently_added_photos.py
│ │ ├── test_redetection_idempotency.py
│ │ ├── test_regenerate_titles.py
│ │ ├── test_retrieve_photo.py
│ │ ├── test_scan_percentage_bug.py
│ │ ├── test_scan_photos.py
│ │ ├── test_scan_photos_directories.py
│ │ ├── test_search_term_examples.py
│ │ ├── test_search_terms.py
│ │ ├── test_services.py
│ │ ├── test_setup_directory.py
│ │ ├── test_share_photos.py
│ │ ├── test_skip_raw_files.py
│ │ ├── test_social_graph.py
│ │ ├── test_stack_api_edge_cases.py
│ │ ├── test_stack_detection.py
│ │ ├── test_stack_detection_edge_cases.py
│ │ ├── test_stack_detection_logic.py
│ │ ├── test_stack_duplicate_integration.py
│ │ ├── test_stack_review.py
│ │ ├── test_stack_validation_edge_cases.py
│ │ ├── test_stats_accuracy.py
│ │ ├── test_thumbnail_migration.py
│ │ ├── test_thumbnail_naming.py
│ │ ├── test_trash_api.py
│ │ ├── test_user.py
│ │ ├── test_xmp_association.py
│ │ ├── test_zip_list_photos_view_v2.py
│ │ └── utils.py
│ ├── thumbnails.py
│ ├── util.py
│ └── views/
│ ├── __init__.py
│ ├── album_auto.py
│ ├── album_folder.py
│ ├── albums.py
│ ├── custom_api_view.py
│ ├── dataviz.py
│ ├── duplicates.py
│ ├── faces.py
│ ├── geocode.py
│ ├── jobs.py
│ ├── pagination.py
│ ├── photo_filters.py
│ ├── photo_metadata.py
│ ├── photos.py
│ ├── public_albums.py
│ ├── search.py
│ ├── services.py
│ ├── sharing.py
│ ├── stacks.py
│ ├── timezone.py
│ ├── upload.py
│ ├── user.py
│ └── views.py
├── image_similarity/
│ ├── __init__.py
│ ├── main.py
│ ├── retrieval_index.py
│ └── utils.py
├── librephotos/
│ ├── __init__.py
│ ├── settings/
│ │ ├── __init__.py
│ │ ├── development.py
│ │ ├── production.py
│ │ ├── test.py
│ │ └── test_sqlite.py
│ ├── urls.py
│ └── wsgi.py
├── manage.py
├── nextcloud/
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── directory_watcher.py
│ ├── models.py
│ ├── tests.py
│ └── views.py
├── pyproject.toml
├── renovate.json
├── requirements.dev.txt
├── requirements.mlval.txt
├── requirements.txt
└── service/
├── __init__.py
├── clip_embeddings/
│ ├── __init__.py
│ ├── main.py
│ └── semantic_search/
│ ├── __init__.py
│ └── semantic_search.py
├── exif/
│ ├── __init__.py
│ └── main.py
├── face_recognition/
│ ├── __init__.py
│ └── main.py
├── image_captioning/
│ ├── __init__.py
│ ├── api/
│ │ └── im2txt/
│ │ ├── README.md
│ │ ├── blip/
│ │ │ ├── blip.py
│ │ │ ├── med.py
│ │ │ └── vit.py
│ │ ├── build_vocab.py
│ │ ├── data_loader.py
│ │ ├── model.py
│ │ ├── resize.py
│ │ ├── sample.py
│ │ └── train.py
│ └── main.py
├── llm/
│ ├── __init__.py
│ └── main.py
├── tags/
│ ├── __init__.py
│ ├── main.py
│ ├── places365/
│ │ ├── __init__.py
│ │ ├── places365.py
│ │ └── wideresnet.py
│ └── siglip2/
│ ├── __init__.py
│ ├── siglip2.py
│ └── tags.txt
└── thumbnail/
├── __init__.py
├── main.py
└── test/
├── .gitignore
├── __init__.py
├── samples/
│ ├── .gitkeep
│ └── README.md
└── test_thumbnail_worker.py
Showing preview only (288K chars total). Download the full file or copy to clipboard to get everything.
SYMBOL INDEX (3220 symbols across 344 files)
FILE: api/admin.py
function deduplicate_faces_function (line 21) | def deduplicate_faces_function(queryset):
class FaceAdmin (line 62) | class FaceAdmin(admin.ModelAdmin):
class PhotoAdmin (line 76) | class PhotoAdmin(admin.ModelAdmin):
method deduplicate_faces (line 88) | def deduplicate_faces(self, request, queryset):
class ThumbnailAdmin (line 96) | class ThumbnailAdmin(admin.ModelAdmin):
FILE: api/all_tasks.py
function create_download_job (line 13) | def create_download_job(job_type, user, photos, filename):
function zip_photos_task (line 26) | def zip_photos_task(job_id, user, photos, filename):
function delete_zip_file (line 118) | def delete_zip_file(filename):
FILE: api/api_util.py
function get_current_job (line 13) | def get_current_job():
function shuffle (line 23) | def shuffle(list):
function is_hidden (line 28) | def is_hidden(filepath):
function has_hidden_attribute (line 33) | def has_hidden_attribute(filepath):
function path_to_dict (line 40) | def path_to_dict(path, recurse=2):
function get_search_term_examples (line 56) | def get_search_term_examples(user):
FILE: api/apps.py
class ApiConfig (line 4) | class ApiConfig(AppConfig):
FILE: api/autoalbum.py
function regenerate_event_titles (line 21) | def regenerate_event_titles(user, job_id):
function generate_event_albums (line 46) | def generate_event_albums(user, job_id):
function delete_missing_photos (line 157) | def delete_missing_photos(user, job_id):
FILE: api/background_tasks.py
function generate_captions (line 9) | def generate_captions(overwrite=False):
function geolocate (line 26) | def geolocate(overwrite=False):
function add_photos_to_album_things (line 41) | def add_photos_to_album_things():
FILE: api/batch_jobs.py
function batch_calculate_clip_embedding (line 12) | def batch_calculate_clip_embedding(user):
FILE: api/burst_detection_rules.py
class BurstRuleTypes (line 26) | class BurstRuleTypes:
class BurstRuleCategory (line 39) | class BurstRuleCategory:
class BurstDetectionRule (line 66) | class BurstDetectionRule:
method __init__ (line 85) | def __init__(self, params):
method get_required_exif_tags (line 94) | def get_required_exif_tags(self):
method _check_condition_path (line 115) | def _check_condition_path(self, path):
method _check_condition_filename (line 122) | def _check_condition_filename(self, path):
method _check_condition_exif (line 130) | def _check_condition_exif(self, exif_tags):
method check_conditions (line 147) | def check_conditions(self, path, exif_tags):
method is_burst_photo (line 155) | def is_burst_photo(self, photo, exif_tags):
method _check_exif_burst_mode (line 185) | def _check_exif_burst_mode(self, photo, exif_tags):
method _check_exif_sequence_number (line 211) | def _check_exif_sequence_number(self, photo, exif_tags):
method _check_filename_pattern (line 233) | def _check_filename_pattern(self, photo, exif_tags):
function check_filename_pattern (line 283) | def check_filename_pattern(photo, pattern_type="any"):
function group_photos_by_timestamp (line 335) | def group_photos_by_timestamp(photos, interval_ms=2000, require_same_cam...
function group_photos_by_visual_similarity (line 399) | def group_photos_by_visual_similarity(photos, similarity_threshold=15):
function get_default_burst_detection_rules (line 541) | def get_default_burst_detection_rules():
function get_all_predefined_burst_rules (line 546) | def get_all_predefined_burst_rules():
function _as_json (line 551) | def _as_json(configs):
function as_rules (line 561) | def as_rules(configs):
function get_hard_rules (line 566) | def get_hard_rules(rules):
function get_soft_rules (line 571) | def get_soft_rules(rules):
function get_enabled_rules (line 576) | def get_enabled_rules(rules):
FILE: api/cluster_manager.py
class ClusterManager (line 10) | class ClusterManager:
method try_add_cluster (line 12) | def try_add_cluster(
FILE: api/date_time_extractor.py
function _regexp_group_range (line 14) | def _regexp_group_range(a, b):
function _extract_no_tz_datetime_from_str (line 64) | def _extract_no_tz_datetime_from_str(x, regexp=REGEXP_NO_TZ, group_mappi...
class RuleTypes (line 112) | class RuleTypes:
class TimeExtractionRule (line 119) | class TimeExtractionRule:
method __init__ (line 224) | def __init__(self, params):
method get_required_exif_tags (line 228) | def get_required_exif_tags(self):
method _get_no_tz_dt_from_tag (line 237) | def _get_no_tz_dt_from_tag(self, tag_name, exif_tags):
method _check_condition_path (line 244) | def _check_condition_path(self, path):
method _check_condition_filename (line 250) | def _check_condition_filename(self, path):
method _get_condition_exif (line 259) | def _get_condition_exif(self):
method _check_condition_exif (line 271) | def _check_condition_exif(self, exif_tags):
method _check_conditions (line 281) | def _check_conditions(self, path, exif_tags, gps_lat, gps_lon):
method apply (line 288) | def apply(
method _get_tz (line 304) | def _get_tz(self, description, gps_lat, gps_lon, user_default_tz):
method _transform_tz (line 328) | def _transform_tz(self, dt, gps_lat, gps_lon, user_default_tz):
method _apply_exif (line 349) | def _apply_exif(self, exif_tags, gps_lat, gps_lon, user_default_tz):
method _apply_path (line 353) | def _apply_path(self, path, gps_lat, gps_lon, user_default_tz):
method _apply_filesystem (line 374) | def _apply_filesystem(self, path, gps_lat, gps_lon, user_default_tz):
function _check_gps_ok (line 385) | def _check_gps_ok(lat, lon):
function set_as_default_rule (line 510) | def set_as_default_rule(rule):
function set_as_other_rule (line 515) | def set_as_other_rule(rule):
function _as_json (line 525) | def _as_json(configs):
function as_rules (line 534) | def as_rules(configs):
function extract_local_date_time (line 538) | def extract_local_date_time(
FILE: api/directory_watcher/file_grouping.py
function get_file_grouping_key (line 27) | def get_file_grouping_key(path: str) -> tuple[str, str]:
function select_main_file (line 45) | def select_main_file(files: list[File]) -> File | None:
function find_matching_jpeg_photo (line 64) | def find_matching_jpeg_photo(raw_path: str, user) -> Photo | None:
function find_matching_image_for_video (line 97) | def find_matching_image_for_video(video_path: str, user) -> Photo | None:
FILE: api/directory_watcher/file_handlers.py
function create_file_record (line 31) | def create_file_record(user, path) -> File | None:
function group_files_into_photo (line 64) | def group_files_into_photo(user, files: list[File], job_id) -> Photo | N...
function create_new_image (line 150) | def create_new_image(user, path) -> Photo | None:
function handle_new_image (line 248) | def handle_new_image(user, path, job_id, photo=None):
function handle_file_group (line 281) | def handle_file_group(user, file_paths: list[str], job_id):
function _process_photo (line 332) | def _process_photo(photo: Photo, path: str, job_id, start: datetime.date...
FILE: api/directory_watcher/processing_jobs.py
function generate_face_embeddings (line 23) | def generate_face_embeddings(user, job_id: UUID):
function generate_tags (line 66) | def generate_tags(user, job_id: UUID, full_scan=False):
function generate_tag_job (line 120) | def generate_tag_job(photo: Photo, job_id: str):
function add_geolocation (line 143) | def add_geolocation(user, job_id: UUID, full_scan=False):
function geolocation_job (line 185) | def geolocation_job(photo: Photo, job_id: UUID):
function scan_faces (line 207) | def scan_faces(user, job_id: UUID, full_scan=False):
FILE: api/directory_watcher/repair_jobs.py
function repair_ungrouped_file_variants (line 16) | def repair_ungrouped_file_variants(user, job_id: UUID):
FILE: api/directory_watcher/scan_jobs.py
function _file_was_modified_after (line 43) | def _file_was_modified_after(filepath, time):
function wait_for_group_and_process_metadata (line 52) | def wait_for_group_and_process_metadata(
function photo_scanner (line 150) | def photo_scanner(user, last_scan, full_scan, path, job_id):
function scan_photos (line 173) | def scan_photos(user, full_scan, job_id, scan_directory="", scan_files=[]):
function scan_missing_photos (line 374) | def scan_missing_photos(user, job_id: UUID):
FILE: api/directory_watcher/utils.py
function should_skip (line 15) | def should_skip(path):
function is_hidden (line 30) | def is_hidden(path):
function _has_hidden_attribute (line 35) | def _has_hidden_attribute(path):
function is_hidden (line 44) | def is_hidden(path):
function walk_directory (line 49) | def walk_directory(directory, callback):
function walk_files (line 66) | def walk_files(scan_files, callback):
function update_scan_counter (line 79) | def update_scan_counter(job_id, failed=False, error=None):
FILE: api/drf_optimize.py
class OptimizeRelatedModelViewSetMetaclass (line 8) | class OptimizeRelatedModelViewSetMetaclass(type):
method get_many_to_many_rel (line 21) | def get_many_to_many_rel(cls, info, meta_fields):
method get_lookups (line 35) | def get_lookups(cls, fields, strict=False):
method get_many_to_one_rel (line 42) | def get_many_to_one_rel(cls, info, meta_fields):
method get_forward_rel (line 61) | def get_forward_rel(cls, info, meta_fields):
method __new__ (line 68) | def __new__(cls, name, bases, attrs):
FILE: api/duplicate_detection.py
class BKTree (line 37) | class BKTree:
method __init__ (line 44) | def __init__(self, distance_func):
method add (line 49) | def add(self, item_id, item_hash):
method search (line 69) | def search(self, query_hash, threshold):
class UnionFind (line 94) | class UnionFind:
method __init__ (line 97) | def __init__(self):
method find (line 101) | def find(self, x):
method union (line 110) | def union(self, x, y):
method get_groups (line 120) | def get_groups(self):
function detect_exact_copies (line 127) | def detect_exact_copies(user, progress_callback=None):
function detect_visual_duplicates (line 274) | def detect_visual_duplicates(
function batch_detect_duplicates (line 452) | def batch_detect_duplicates(user, options=None):
FILE: api/face_classify.py
function cluster_faces (line 31) | def cluster_faces(user, inferred=True):
function cluster_all_faces (line 83) | def cluster_all_faces(user, job_id) -> bool:
function create_all_clusters (line 117) | def create_all_clusters(user: User, lrj: LongRunningJob = None) -> int:
function delete_persons_without_faces (line 216) | def delete_persons_without_faces():
function delete_clusters (line 222) | def delete_clusters(user: User):
function delete_clustered_people (line 230) | def delete_clustered_people(user: User):
function filter_data (line 240) | def filter_data(encodings, ids):
function train_faces (line 259) | def train_faces(user: User, job_id) -> bool:
FILE: api/face_extractor.py
class RuleTypes (line 10) | class RuleTypes:
function extract_from_exif (line 15) | def extract_from_exif(image_path, big_thumbnail_image_path):
function extract_from_dlib (line 97) | def extract_from_dlib(image_path, big_thumbnail_path, owner):
function extract (line 113) | def extract(image_path, big_thumbnail_path, owner):
FILE: api/face_recognition.py
function get_face_encodings (line 5) | def get_face_encodings(image_path, known_face_locations):
function get_face_locations (line 20) | def get_face_locations(image_path, model="hog"):
FILE: api/feature/embedded_media.py
function _locate_embedded_video_google (line 16) | def _locate_embedded_video_google(data):
function _locate_embedded_video_samsung (line 25) | def _locate_embedded_video_samsung(data):
function has_embedded_media (line 32) | def has_embedded_media(path: str) -> bool:
function extract_embedded_media (line 45) | def extract_embedded_media(path: str, hash: str) -> str | None:
FILE: api/feature/tests/test_embedded_media.py
function create_test_file (line 17) | def create_test_file(path: str, user: User, content: bytes):
class EmbeddedMediaTest (line 32) | class EmbeddedMediaTest(APITestCase):
method setUp (line 33) | def setUp(self):
method test_should_not_process_non_jpeg_files (line 39) | def test_should_not_process_non_jpeg_files(self):
method test_google_pixel_motion_photo_signatures (line 44) | def test_google_pixel_motion_photo_signatures(self):
method test_samsung_motion_photo_signature (line 51) | def test_samsung_motion_photo_signature(self):
method test_other_content_should_not_report_as_having_embedded_media (line 57) | def test_other_content_should_not_report_as_having_embedded_media(self):
method test_extract_embedded_media_from_google_motion_photo (line 62) | def test_extract_embedded_media_from_google_motion_photo(self):
method test_extract_embedded_media_from_samsung_motion_photo (line 73) | def test_extract_embedded_media_from_samsung_motion_photo(self):
method test_fetch_embedded_media_as_owner (line 83) | def test_fetch_embedded_media_as_owner(self):
method test_fetch_embedded_media_as_anonymous_when_photo_is_public (line 91) | def test_fetch_embedded_media_as_anonymous_when_photo_is_public(self):
method test_fetch_embedded_media_as_anonymous_when_photo_is_private (line 100) | def test_fetch_embedded_media_as_anonymous_when_photo_is_private(self):
method test_fetch_embedded_media_when_photo_does_not_have_embedded_media (line 109) | def test_fetch_embedded_media_when_photo_does_not_have_embedded_media(...
FILE: api/filters.py
class SemanticSearchFilter (line 13) | class SemanticSearchFilter(filters.SearchFilter):
method filter_queryset (line 14) | def filter_queryset(self, request, queryset, view):
FILE: api/geocode/config.py
function _get_config (line 9) | def _get_config():
function get_provider_config (line 36) | def get_provider_config(provider) -> dict:
function get_provider_parser (line 43) | def get_provider_parser(provider) -> callable:
FILE: api/geocode/geocode.py
class Geocode (line 11) | class Geocode:
method __init__ (line 12) | def __init__(self, provider):
method reverse (line 19) | def reverse(self, lat: float, lon: float) -> dict:
method search (line 32) | def search(self, query: str, limit: int = 5) -> List[dict]:
function reverse_geocode (line 55) | def reverse_geocode(lat: float, lon: float) -> dict:
function search_location (line 63) | def search_location(query: str, limit: int = 5) -> List[dict]:
FILE: api/geocode/parsers/mapbox.py
function parse (line 4) | def parse(location):
FILE: api/geocode/parsers/nominatim.py
function parse (line 4) | def parse(location):
FILE: api/geocode/parsers/opencage.py
function parse (line 4) | def parse(location):
FILE: api/geocode/parsers/tomtom.py
function _dedup (line 6) | def _dedup(iterable):
function parse (line 18) | def parse(location):
FILE: api/image_captioning.py
function generate_caption (line 5) | def generate_caption(image_path, blip=False, prompt=None):
function unload_model (line 53) | def unload_model():
FILE: api/image_similarity.py
function search_similar_embedding (line 13) | def search_similar_embedding(user, emb, result_count=100, threshold=27):
function search_similar_image (line 35) | def search_similar_image(user, photo, threshold=27):
function build_image_similarity_index (line 62) | def build_image_similarity_index(user):
FILE: api/llm.py
function image_to_base64_data_uri (line 8) | def image_to_base64_data_uri(image_path):
function generate_prompt (line 31) | def generate_prompt(prompt, image_path=None):
FILE: api/management/commands/build_similarity_index.py
class Command (line 8) | class Command(BaseCommand):
method handle (line 11) | def handle(self, *args, **kwargs):
FILE: api/management/commands/clear_cache.py
class Command (line 6) | class Command(BaseCommand):
method handle (line 11) | def handle(self, *args, **kwargs):
FILE: api/management/commands/createadmin.py
class Command (line 10) | class Command(BaseCommand):
method add_arguments (line 13) | def add_arguments(self, parser):
method handle (line 33) | def handle(self, *args, **options):
FILE: api/management/commands/createuser.py
class Command (line 10) | class Command(BaseCommand):
method add_arguments (line 13) | def add_arguments(self, parser):
method handle (line 42) | def handle(self, *args, **options):
FILE: api/management/commands/save_metadata.py
class Command (line 7) | class Command(BaseCommand):
method add_arguments (line 10) | def add_arguments(self, parser):
method handle (line 40) | def handle(self, *args, **options):
FILE: api/management/commands/scan.py
class Command (line 12) | class Command(BaseCommand):
method add_arguments (line 15) | def add_arguments(self, parser):
method handle (line 30) | def handle(self, *args, **options):
method nextcloud_scan (line 59) | def nextcloud_scan(self):
FILE: api/management/commands/start_cleaning_service.py
class Command (line 8) | class Command(BaseCommand):
method handle (line 11) | def handle(self, *args, **kwargs):
FILE: api/management/commands/start_job_cleanup_service.py
class Command (line 8) | class Command(BaseCommand):
method handle (line 11) | def handle(self, *args, **kwargs):
FILE: api/management/commands/start_service.py
class Command (line 8) | class Command(BaseCommand):
method add_arguments (line 12) | def add_arguments(self, parser):
method handle (line 23) | def handle(self, *args, **kwargs):
FILE: api/metadata/face_regions.py
function thumbnail_coords_to_normalized (line 10) | def thumbnail_coords_to_normalized(top, right, bottom, left, thumb_width...
function reverse_orientation_transform (line 20) | def reverse_orientation_transform(x, y, w, h, orientation):
function _escape_exiftool_value (line 70) | def _escape_exiftool_value(value):
function build_face_region_exiftool_args (line 82) | def build_face_region_exiftool_args(face_regions, image_width=None, imag...
function get_face_region_tags (line 127) | def get_face_region_tags(photo):
FILE: api/metadata/reader.py
function get_sidecar_files_in_priority_order (line 7) | def get_sidecar_files_in_priority_order(media_file):
function _get_existing_metadata_files_reversed (line 21) | def _get_existing_metadata_files_reversed(media_file, include_sidecar_fi...
function get_metadata (line 33) | def get_metadata(media_file, tags, try_sidecar=True, struct=False):
FILE: api/metadata/tags.py
class Tags (line 1) | class Tags:
FILE: api/metadata/writer.py
function write_metadata (line 9) | def write_metadata(media_file, tags, use_sidecar=True):
FILE: api/middleware.py
class FingerPrintMiddleware (line 1) | class FingerPrintMiddleware:
method __init__ (line 2) | def __init__(self, get_response):
method __call__ (line 6) | def __call__(self, request):
FILE: api/migrations/0001_initial.py
class Migration (line 16) | class Migration(migrations.Migration):
FILE: api/migrations/0002_add_confidence.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0003_remove_unused_thumbs.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0004_fix_album_thing_constraint.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0005_add_video_to_photo.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0006_migrate_to_boolean_field.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0007_migrate_to_json_field.py
class Migration (line 6) | class Migration(migrations.Migration):
method forwards_func (line 11) | def forwards_func(apps, schema):
FILE: api/migrations/0008_remove_image_path.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0009_add_aspect_ratio.py
class Migration (line 5) | class Migration(migrations.Migration):
method forwards_func (line 10) | def forwards_func(apps, schema):
FILE: api/migrations/0009_add_clip_embedding_field.py
class Migration (line 5) | class Migration(migrations.Migration):
FILE: api/migrations/0010_merge_20210725_1547.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0011_a_add_rating.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0011_b_migrate_favorited_to_rating.py
function favorited_to_rating (line 6) | def favorited_to_rating(apps, schema_editor):
function rating_to_favorited (line 13) | def rating_to_favorited(apps, schema_editor):
class Migration (line 20) | class Migration(migrations.Migration):
FILE: api/migrations/0011_c_remove_favorited.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0012_add_favorite_min_rating.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0013_add_image_scale_and_misc.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0014_add_save_metadata_to_disk.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0015_add_dominant_color.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0016_add_transcode_videos.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0017_add_cover_photo.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0018_user_config_datetime_rules.py
class Migration (line 8) | class Migration(migrations.Migration):
FILE: api/migrations/0019_change_config_datetime_rules.py
class Migration (line 8) | class Migration(migrations.Migration):
FILE: api/migrations/0020_add_default_timezone.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0021_remove_photo_image.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0022_photo_video_length.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0023_photo_deleted.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0024_photo_timestamp.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0025_add_cover_photo.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0026_add_cluster_info.py
class Migration (line 11) | class Migration(migrations.Migration):
FILE: api/migrations/0027_rename_unknown_person.py
function migrate_unknown (line 8) | def migrate_unknown(apps, schema_editor):
function unmigrate_unknown (line 25) | def unmigrate_unknown(apps, schema_editor):
class Migration (line 36) | class Migration(migrations.Migration):
FILE: api/migrations/0028_add_metadata_fields.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0029_change_to_text_field.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0030_user_confidence_person.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0031_remove_account.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0032_always_have_owner.py
function add_cluster_owner (line 6) | def add_cluster_owner(apps, schema_editor):
function remove_cluster_owner (line 14) | def remove_cluster_owner(apps, schema_editor):
class Migration (line 20) | class Migration(migrations.Migration):
FILE: api/migrations/0033_add_post_delete_person.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0034_allow_deleting_person.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0035_add_files_model.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0036_handle_missing_files.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0037_migrate_to_files.py
function find_out_type (line 14) | def find_out_type(path):
function migrate_to_files (line 24) | def migrate_to_files(apps, schema_editor):
function remove_files (line 56) | def remove_files(apps, schema_editor):
class Migration (line 62) | class Migration(migrations.Migration):
FILE: api/migrations/0038_add_main_file.py
function find_out_type (line 12) | def find_out_type(path):
function add_main_file (line 22) | def add_main_file(apps, schema_editor):
function remove_main_file (line 30) | def remove_main_file(apps, schema_editor):
class Migration (line 37) | class Migration(migrations.Migration):
FILE: api/migrations/0039_remove_photo_image_paths.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0040_add_user_public_sharing_flag.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0041_apply_user_enum_for_person.py
class Migration (line 4) | class Migration(migrations.Migration):
method apply_enum (line 5) | def apply_enum(apps, schema_editor):
method remove_enum (line 11) | def remove_enum(apps, schema_editor):
FILE: api/migrations/0042_alter_albumuser_cover_photo_alter_photo_main_file.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0043_alter_photo_size.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0044_alter_cluster_person_alter_person_cluster_owner.py
class Migration (line 8) | class Migration(migrations.Migration):
FILE: api/migrations/0045_alter_face_cluster.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0046_add_embedded_media.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0047_alter_file_embedded_media.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0048_fix_null_height.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0049_fix_metadata_files_as_main_files.py
function delete_photos_with_metadata_as_main (line 4) | def delete_photos_with_metadata_as_main(apps, schema_editor):
class Migration (line 10) | class Migration(migrations.Migration):
FILE: api/migrations/0050_person_face_count.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0051_set_person_defaults.py
class Migration (line 4) | class Migration(migrations.Migration):
method apply_default (line 5) | def apply_default(apps, schema_editor):
method remove_default (line 38) | def remove_default(apps, schema_editor):
FILE: api/migrations/0052_alter_person_name.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0053_user_confidence_unknown_face_and_more.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0054_user_cluster_selection_epsilon_user_min_samples.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0055_alter_longrunningjob_job_type.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0056_user_llm_settings_alter_longrunningjob_job_type.py
class Migration (line 8) | class Migration(migrations.Migration):
FILE: api/migrations/0057_remove_face_image_path_and_more.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0058_alter_user_avatar_alter_user_nextcloud_app_password_and_more.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0059_person_cover_face.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0060_apply_default_face_cover.py
class Migration (line 4) | class Migration(migrations.Migration):
method apply_default (line 5) | def apply_default(apps, schema_editor):
method remove_default (line 22) | def remove_default(apps, schema_editor):
FILE: api/migrations/0061_alter_person_name.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0062_albumthing_cover_photos.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0063_apply_default_album_things_cover.py
class Migration (line 4) | class Migration(migrations.Migration):
method apply_default (line 5) | def apply_default(apps, schema_editor):
method remove_default (line 13) | def remove_default(apps, schema_editor):
FILE: api/migrations/0064_albumthing_photo_count.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0065_apply_default_photo_count.py
class Migration (line 4) | class Migration(migrations.Migration):
method apply_default (line 5) | def apply_default(apps, schema_editor):
method remove_default (line 12) | def remove_default(apps, schema_editor):
FILE: api/migrations/0066_photo_last_modified_alter_longrunningjob_job_type.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0067_alter_longrunningjob_job_type.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0068_remove_longrunningjob_result_and_more.py
function copy_progress_data (line 5) | def copy_progress_data(apps, schema_editor):
class Migration (line 14) | class Migration(migrations.Migration):
FILE: api/migrations/0069_rename_to_in_trashcan.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/migrations/0070_photo_removed.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0071_rename_person_label_probability_face_cluster_probability_and_more.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0072_alter_face_person.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0073_remove_unknown_person.py
function delete_unknown_person_and_update_faces (line 4) | def delete_unknown_person_and_update_faces(apps, schema_editor):
function recreate_unknown_person_and_restore_faces (line 24) | def recreate_unknown_person_and_restore_faces(apps, schema_editor):
class Migration (line 48) | class Migration(migrations.Migration):
FILE: api/migrations/0074_migrate_cluster_person.py
function move_person_to_cluster_if_kind_cluster (line 4) | def move_person_to_cluster_if_kind_cluster(apps, schema_editor):
function restore_person_from_cluster (line 24) | def restore_person_from_cluster(apps, schema_editor):
class Migration (line 38) | class Migration(migrations.Migration):
FILE: api/migrations/0075_alter_face_cluster_person.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0076_alter_file_path_alter_longrunningjob_job_type_and_more.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0077_alter_albumdate_title.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0078_create_photo_thumbnail.py
class Migration (line 5) | class Migration(migrations.Migration):
FILE: api/migrations/0079_alter_albumauto_title.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0080_create_photo_caption.py
function migrate_caption_data (line 7) | def migrate_caption_data(apps, schema_editor):
function reverse_migrate_caption_data (line 40) | def reverse_migrate_caption_data(apps, schema_editor):
class Migration (line 58) | class Migration(migrations.Migration):
FILE: api/migrations/0081_remove_caption_fields_from_photo.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0082_create_photo_search.py
function migrate_search_data (line 7) | def migrate_search_data(apps, schema_editor):
function reverse_migrate_search_data (line 45) | def reverse_migrate_search_data(apps, schema_editor):
class Migration (line 68) | class Migration(migrations.Migration):
FILE: api/migrations/0083_remove_search_fields.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0084_convert_arrayfield_to_json.py
function copy_arrayfield_to_json (line 5) | def copy_arrayfield_to_json(apps, schema_editor):
function copy_json_to_arrayfield (line 19) | def copy_json_to_arrayfield(apps, schema_editor):
class Migration (line 31) | class Migration(migrations.Migration):
FILE: api/migrations/0085_albumuser_public_expires_at_albumuser_public_slug.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0086_remove_albumuser_public_and_more.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0087_add_folder_album.py
class Migration (line 8) | class Migration(migrations.Migration):
FILE: api/migrations/0088_remove_folder_album.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0089_add_text_alignment.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0090_add_header_size.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0091_alter_user_scan_directory.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0092_add_skip_raw_files_field.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0093_migrate_photon_to_nominatim.py
function migrate_photon_to_nominatim (line 11) | def migrate_photon_to_nominatim(apps, schema_editor):
function reverse_migration (line 24) | def reverse_migration(apps, schema_editor):
class Migration (line 36) | class Migration(migrations.Migration):
FILE: api/migrations/0094_add_slideshow_interval.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0095_photo_perceptual_hash_alter_longrunningjob_job_type_and_more.py
class Migration (line 9) | class Migration(migrations.Migration):
FILE: api/migrations/0096_add_progress_step_and_result_to_longrunningjob.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0097_add_duplicate_detection_settings_to_user.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0098_add_photo_stack.py
function get_deleted_user (line 8) | def get_deleted_user():
class Migration (line 14) | class Migration(migrations.Migration):
FILE: api/migrations/0099_photo_uuid_primary_key.py
function migrate_forward (line 268) | def migrate_forward(apps, schema_editor):
function migrate_reverse (line 282) | def migrate_reverse(apps, schema_editor):
function _migrate_postgresql (line 295) | def _migrate_postgresql(schema_editor):
function _migrate_sqlite (line 320) | def _migrate_sqlite(schema_editor):
function _sqlite_recreate_table (line 381) | def _sqlite_recreate_table(cursor, table_name, pk_column, column_overrid...
function _sqlite_update_fk_table (line 431) | def _sqlite_update_fk_table(cursor, table_name, fk_column, mapping):
function _sqlite_create_indexes (line 504) | def _sqlite_create_indexes(cursor):
function _sqlite_column_info (line 526) | def _sqlite_column_info(cursor, table_name):
function _sqlite_index_info (line 532) | def _sqlite_index_info(cursor, table_name):
class Migration (line 546) | class Migration(migrations.Migration):
FILE: api/migrations/0100_metadataedit_metadatafile_photometadata_stackreview_and_more.py
class Migration (line 10) | class Migration(migrations.Migration):
FILE: api/migrations/0101_populate_photo_metadata.py
function populate_photo_metadata (line 10) | def populate_photo_metadata(apps, schema_editor):
function reverse_populate (line 103) | def reverse_populate(apps, schema_editor):
class Migration (line 112) | class Migration(migrations.Migration):
FILE: api/migrations/0102_photo_stacks_manytomany.py
function migrate_fk_to_m2m (line 14) | def migrate_fk_to_m2m(apps, schema_editor):
function reverse_m2m_to_fk (line 31) | def reverse_m2m_to_fk(apps, schema_editor):
class Migration (line 47) | class Migration(migrations.Migration):
FILE: api/migrations/0103_remove_photo_metadata_fields.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0104_remove_photostack_potential_savings_and_more.py
class Migration (line 10) | class Migration(migrations.Migration):
FILE: api/migrations/0105_alter_photo_image_hash.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0106_alter_longrunningjob_options.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0107_add_burst_detection_rules.py
class Migration (line 8) | class Migration(migrations.Migration):
FILE: api/migrations/0108_add_stack_raw_jpeg_field.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0109_migrate_skip_raw_to_stack_raw_jpeg.py
function migrate_skip_raw_to_stack_raw_jpeg (line 8) | def migrate_skip_raw_to_stack_raw_jpeg(apps, schema_editor):
function reverse_migration (line 17) | def reverse_migration(apps, schema_editor):
class Migration (line 24) | class Migration(migrations.Migration):
FILE: api/migrations/0110_fix_file_embedded_media_self_reference.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0111_alter_file_embedded_media.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0112_convert_file_stacks_to_variants.py
function convert_raw_jpeg_stacks_to_file_variants (line 36) | def convert_raw_jpeg_stacks_to_file_variants(apps, schema_editor):
function _flush_raw_jpeg_batch (line 150) | def _flush_raw_jpeg_batch(PhotoFiles, PhotoStacks, Photo, PhotoStack,
function convert_live_photo_stacks_to_file_variants (line 172) | def convert_live_photo_stacks_to_file_variants(apps, schema_editor):
function _flush_live_photo_batch (line 288) | def _flush_live_photo_batch(PhotoFiles, PhotoStacks, Photo, PhotoStack,
function forward_migration (line 310) | def forward_migration(apps, schema_editor):
function reverse_migration (line 316) | def reverse_migration(apps, schema_editor):
class Migration (line 327) | class Migration(migrations.Migration):
FILE: api/migrations/0113_alter_photostack_stack_type.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0114_add_file_path_unique.py
function deduplicate_file_paths (line 11) | def deduplicate_file_paths(apps, schema_editor):
function reverse_deduplicate (line 152) | def reverse_deduplicate(apps, schema_editor):
class Migration (line 160) | class Migration(migrations.Migration):
FILE: api/migrations/0115_cleanup_duplicate_photos.py
function cleanup_duplicate_photos (line 11) | def cleanup_duplicate_photos(apps, schema_editor):
function reverse_cleanup (line 196) | def reverse_cleanup(apps, schema_editor):
class Migration (line 203) | class Migration(migrations.Migration):
FILE: api/migrations/0116_cleanup_duplicate_groups_removed_photos.py
function cleanup_duplicate_groups (line 7) | def cleanup_duplicate_groups(apps, schema_editor):
function reverse_cleanup (line 44) | def reverse_cleanup(apps, schema_editor):
class Migration (line 53) | class Migration(migrations.Migration):
FILE: api/migrations/0117_delete_removed_photos.py
function delete_removed_photos (line 7) | def delete_removed_photos(apps, schema_editor):
function reverse_delete (line 32) | def reverse_delete(apps, schema_editor):
class Migration (line 39) | class Migration(migrations.Migration):
FILE: api/migrations/0118_alter_longrunningjob_job_type.py
class Migration (line 6) | class Migration(migrations.Migration):
FILE: api/migrations/0119_add_public_sharing_options.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: api/migrations/0120_rename_thumbnails_uuid_to_hash.py
function rename_thumbnails_uuid_to_hash (line 10) | def rename_thumbnails_uuid_to_hash(apps, schema_editor):
function reverse_rename_thumbnails (line 103) | def reverse_rename_thumbnails(apps, schema_editor):
class Migration (line 114) | class Migration(migrations.Migration):
FILE: api/migrations/0121_add_default_tagging_model.py
function add_default_tagging_model (line 13) | def add_default_tagging_model(apps, schema_editor):
function reverse_migration (line 24) | def reverse_migration(apps, schema_editor):
class Migration (line 33) | class Migration(migrations.Migration):
FILE: api/migrations/0121_user_save_face_tags_to_disk.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: api/ml_models.py
class MlTypes (line 14) | class MlTypes:
function download_model (line 109) | def download_model(model):
function _download_file (line 204) | def _download_file(url, target_path, model_name):
function download_models (line 235) | def download_models(user):
function do_all_models_exist (line 253) | def do_all_models_exist():
FILE: api/models/album_auto.py
class AlbumAuto (line 11) | class AlbumAuto(models.Model):
class Meta (line 27) | class Meta:
method _generate_title (line 30) | def _generate_title(self):
method __str__ (line 111) | def __str__(self):
FILE: api/models/album_date.py
class AlbumDate (line 7) | class AlbumDate(models.Model):
class Meta (line 19) | class Meta:
method __str__ (line 22) | def __str__(self):
method ordered_photos (line 25) | def ordered_photos(self):
function get_or_create_album_date (line 29) | def get_or_create_album_date(date, owner):
function get_album_date (line 36) | def get_album_date(date, owner):
function get_album_nodate (line 43) | def get_album_nodate(owner):
FILE: api/models/album_place.py
class AlbumPlace (line 7) | class AlbumPlace(models.Model):
class Meta (line 18) | class Meta:
method __str__ (line 21) | def __str__(self):
function get_album_place (line 25) | def get_album_place(title, owner):
FILE: api/models/album_thing.py
function update_default_cover_photo (line 9) | def update_default_cover_photo(instance):
class AlbumThing (line 17) | class AlbumThing(models.Model):
class Meta (line 31) | class Meta:
method save (line 38) | def save(self, *args, **kwargs):
method update_default_cover_photo (line 41) | def update_default_cover_photo(self):
method __str__ (line 44) | def __str__(self):
function update_photo_count (line 49) | def update_photo_count(sender, instance, action, reverse, model, pk_set,...
function get_album_thing (line 57) | def get_album_thing(title, owner, thing_type=None):
FILE: api/models/album_user.py
class AlbumUser (line 7) | class AlbumUser(models.Model):
method __str__ (line 25) | def __str__(self):
class Meta (line 28) | class Meta:
FILE: api/models/album_user_share.py
class AlbumUserShare (line 8) | class AlbumUserShare(models.Model):
method ensure_slug (line 25) | def ensure_slug(self) -> None:
method is_active (line 39) | def is_active(self) -> bool:
method save (line 46) | def save(self, *args, **kwargs):
method get_effective_sharing_settings (line 51) | def get_effective_sharing_settings(self) -> dict:
FILE: api/models/cluster.py
class Cluster (line 13) | class Cluster(models.Model):
method __str__ (line 29) | def __str__(self):
method get_mean_encoding_array (line 32) | def get_mean_encoding_array(self) -> np.ndarray:
method set_metadata (line 35) | def set_metadata(self, all_vectors):
method get_or_create_cluster_by_name (line 41) | def get_or_create_cluster_by_name(user: User, name):
method get_or_create_cluster_by_id (line 45) | def get_or_create_cluster_by_id(user: User, cluster_id: int):
method calculate_mean_face_encoding (line 55) | def calculate_mean_face_encoding(all_encodings):
function get_unknown_cluster (line 59) | def get_unknown_cluster(user: User) -> Cluster:
FILE: api/models/duplicate.py
class Duplicate (line 26) | class Duplicate(models.Model):
class DuplicateType (line 34) | class DuplicateType(models.TextChoices):
class ReviewStatus (line 40) | class ReviewStatus(models.TextChoices):
class Meta (line 96) | class Meta:
method __str__ (line 105) | def __str__(self):
method photo_count (line 109) | def photo_count(self):
method get_photos_ordered_by_quality (line 113) | def get_photos_ordered_by_quality(self):
method auto_select_best_photo (line 122) | def auto_select_best_photo(self):
method calculate_potential_savings (line 151) | def calculate_potential_savings(self):
method resolve (line 172) | def resolve(self, kept_photo, trash_others: bool = True):
method dismiss (line 193) | def dismiss(self):
method revert (line 205) | def revert(self):
method merge_with (line 224) | def merge_with(self, other_duplicate: "Duplicate"):
method create_or_merge (line 244) | def create_or_merge(cls, owner, duplicate_type, photos, similarity_sco...
FILE: api/models/face.py
class Face (line 13) | class Face(models.Model):
method timestamp (line 58) | def timestamp(self):
method __str__ (line 61) | def __str__(self):
method generate_encoding (line 64) | def generate_encoding(self):
method get_encoding_array (line 82) | def get_encoding_array(self):
function reset_person (line 87) | def reset_person(sender, instance, **kwargs):
function auto_delete_file_on_delete (line 93) | def auto_delete_file_on_delete(sender, instance, **kwargs):
FILE: api/models/file.py
class File (line 16) | class File(models.Model):
method __str__ (line 40) | def __str__(self):
method create (line 44) | def create(path: str, user):
method _find_out_type (line 95) | def _find_out_type(self):
function is_video (line 106) | def is_video(path):
function is_raw (line 116) | def is_raw(path):
function is_metadata (line 160) | def is_metadata(path):
function is_valid_media (line 168) | def is_valid_media(path, user) -> bool:
function calculate_hash (line 181) | def calculate_hash(user, path):
function calculate_hash_b64 (line 193) | def calculate_hash_b64(user, content):
FILE: api/models/long_running_job.py
class LongRunningJob (line 10) | class LongRunningJob(models.Model):
class Meta (line 66) | class Meta:
method __str__ (line 71) | def __str__(self):
method is_running (line 76) | def is_running(self):
method duration (line 81) | def duration(self):
method start (line 88) | def start(self):
method complete (line 93) | def complete(self, result=None):
method fail (line 101) | def fail(self, error=None):
method update_progress (line 110) | def update_progress(self, current, target=None, step=None):
method set_result (line 122) | def set_result(self, result):
method create_job (line 128) | def create_job(cls, user, job_type, job_id=None, start_now=False):
method get_or_create_job (line 154) | def get_or_create_job(cls, user, job_type, job_id):
method cleanup_stuck_jobs (line 176) | def cleanup_stuck_jobs(cls, hours=24):
method cleanup_old_jobs (line 205) | def cleanup_old_jobs(cls, days=30):
FILE: api/models/person.py
class Person (line 14) | class Person(models.Model):
method __str__ (line 47) | def __str__(self):
method _calculate_face_count (line 61) | def _calculate_face_count(self):
method _set_default_cover_photo (line 69) | def _set_default_cover_photo(self):
method get_photos (line 75) | def get_photos(self, owner):
function get_unknown_person (line 105) | def get_unknown_person(owner: User = None):
function get_or_create_person (line 115) | def get_or_create_person(name, owner: User = None, kind: str = Person.KI...
FILE: api/models/photo.py
class VisiblePhotoManager (line 27) | class VisiblePhotoManager(models.Manager):
method get_queryset (line 28) | def get_queryset(self):
class Photo (line 41) | class Photo(models.Model):
method get_clip_embeddings (line 127) | def get_clip_embeddings(self):
method set_clip_embeddings (line 143) | def set_clip_embeddings(self, embeddings):
method from_db (line 148) | def from_db(cls, db, field_names, values):
method save (line 157) | def save(
method _save_metadata (line 183) | def _save_metadata(
method _find_album_place (line 216) | def _find_album_place(self):
method _find_album_date (line 221) | def _find_album_date(self):
method _extract_date_time_from_exif (line 247) | def _extract_date_time_from_exif(self, commit=True):
method _geolocate (line 287) | def _geolocate(self, commit=True):
method _add_location_to_album_dates (line 353) | def _add_location_to_album_dates(self):
method _extract_faces (line 374) | def _extract_faces(self, second_try=False):
method _add_to_album_thing (line 455) | def _add_to_album_thing(self):
method _check_files (line 489) | def _check_files(self):
method manual_delete (line 497) | def manual_delete(self):
method _set_embedded_media (line 566) | def _set_embedded_media(self, obj):
method __str__ (line 569) | def __str__(self):
FILE: api/models/photo_caption.py
class PhotoCaption (line 11) | class PhotoCaption(models.Model):
class Meta (line 25) | class Meta:
method __str__ (line 28) | def __str__(self):
method generate_captions_im2txt (line 31) | def generate_captions_im2txt(self, commit=True):
method _generate_captions_moondream (line 115) | def _generate_captions_moondream(self, commit=True):
method save_user_caption (line 197) | def save_user_caption(self, caption, commit=True):
method recreate_search_captions (line 261) | def recreate_search_captions(self):
method generate_tag_captions (line 269) | def generate_tag_captions(self, commit=True):
method _update_places365_album_things (line 330) | def _update_places365_album_things(self, res_places365):
method _update_siglip2_album_things (line 364) | def _update_siglip2_album_things(self, siglip2_result):
method generate_places365_captions (line 387) | def generate_places365_captions(self, commit=True):
FILE: api/models/photo_metadata.py
class MetadataFile (line 27) | class MetadataFile(models.Model):
class FileType (line 38) | class FileType(models.TextChoices):
class Source (line 44) | class Source(models.TextChoices):
class Meta (line 92) | class Meta:
method __str__ (line 97) | def __str__(self):
class PhotoMetadata (line 101) | class PhotoMetadata(models.Model):
class Source (line 115) | class Source(models.TextChoices):
class Meta (line 238) | class Meta:
method __str__ (line 247) | def __str__(self):
method resolution (line 251) | def resolution(self):
method megapixels (line 258) | def megapixels(self):
method has_location (line 265) | def has_location(self):
method camera_display (line 270) | def camera_display(self):
method lens_display (line 280) | def lens_display(self):
method extract_exif_data (line 289) | def extract_exif_data(cls, photo, commit=True):
class MetadataEdit (line 403) | class MetadataEdit(models.Model):
class Meta (line 438) | class Meta:
method __str__ (line 446) | def __str__(self):
FILE: api/models/photo_search.py
class PhotoSearch (line 7) | class PhotoSearch(models.Model):
class Meta (line 22) | class Meta:
method __str__ (line 25) | def __str__(self):
method recreate_search_captions (line 28) | def recreate_search_captions(self):
method update_search_location (line 99) | def update_search_location(self, geolocation_json):
FILE: api/models/photo_stack.py
class PhotoStack (line 29) | class PhotoStack(models.Model):
class StackType (line 41) | class StackType(models.TextChoices):
class Meta (line 101) | class Meta:
method __str__ (line 109) | def __str__(self):
method photo_count (line 113) | def photo_count(self):
method get_photos_ordered_by_quality (line 117) | def get_photos_ordered_by_quality(self):
method auto_select_primary (line 124) | def auto_select_primary(self):
method merge_with (line 172) | def merge_with(self, other_stack: "PhotoStack"):
method create_or_merge (line 196) | def create_or_merge(cls, owner, stack_type, photos, sequence_start=Non...
FILE: api/models/stack_review.py
class StackReview (line 23) | class StackReview(models.Model):
class Decision (line 39) | class Decision(models.TextChoices):
class Meta (line 87) | class Meta:
method __str__ (line 95) | def __str__(self):
method is_reviewable_type (line 99) | def is_reviewable_type(cls, stack_type: str) -> bool:
method create_for_stack (line 116) | def create_for_stack(cls, stack: PhotoStack) -> "StackReview | None":
method resolve (line 133) | def resolve(self, kept_photo, trash_others: bool = True):
method dismiss (line 160) | def dismiss(self):
method revert (line 174) | def revert(self):
FILE: api/models/thumbnail.py
class Thumbnail (line 19) | class Thumbnail(models.Model):
method _generate_thumbnail (line 29) | def _generate_thumbnail(self):
method _calculate_aspect_ratio (line 110) | def _calculate_aspect_ratio(self):
method _get_dominant_color (line 127) | def _get_dominant_color(self, palette_size=16):
FILE: api/models/user.py
function get_default_config_datetime_rules (line 11) | def get_default_config_datetime_rules(): # This is a callable
function get_default_config_burst_detection_rules (line 15) | def get_default_config_burst_detection_rules(): # This is a callable
function get_default_llm_settings (line 19) | def get_default_llm_settings():
function get_default_public_sharing_settings (line 34) | def get_default_public_sharing_settings():
class User (line 49) | class User(AbstractUser):
class SaveMetadata (line 72) | class SaveMetadata(models.TextChoices):
class FaceRecogniton (line 95) | class FaceRecogniton(models.TextChoices):
class TextAlignment (line 107) | class TextAlignment(models.TextChoices):
class HeaderSize (line 115) | class HeaderSize(models.TextChoices):
class DuplicateSensitivity (line 128) | class DuplicateSensitivity(models.TextChoices):
function get_admin_user (line 139) | def get_admin_user():
function get_deleted_user (line 143) | def get_deleted_user():
FILE: api/nextcloud.py
function login (line 6) | def login(user):
function list_dir (line 24) | def list_dir(user, path):
FILE: api/perceptual_hash.py
function calculate_perceptual_hash (line 21) | def calculate_perceptual_hash(image_path: str, hash_size: int = 8) -> st...
function calculate_hash_from_thumbnail (line 47) | def calculate_hash_from_thumbnail(thumbnail_path: str) -> str | None:
function hamming_distance (line 61) | def hamming_distance(hash1: str, hash2: str) -> int:
function are_duplicates (line 81) | def are_duplicates(hash1: str, hash2: str, threshold: int = DEFAULT_HAMM...
function find_similar_hashes (line 98) | def find_similar_hashes(
FILE: api/permissions.py
class IsAdminOrSelf (line 7) | class IsAdminOrSelf(permissions.BasePermission):
method has_object_permission (line 8) | def has_object_permission(self, request, view, obj):
class IsAdminOrFirstTimeSetupOrRegistrationAllowed (line 15) | class IsAdminOrFirstTimeSetupOrRegistrationAllowed(permissions.BasePermi...
method has_permission (line 16) | def has_permission(self, request, view):
class IsOwnerOrReadOnly (line 27) | class IsOwnerOrReadOnly(permissions.BasePermission):
method has_object_permission (line 30) | def has_object_permission(self, request, view, obj):
class IsUserOrReadOnly (line 40) | class IsUserOrReadOnly(permissions.BasePermission):
method has_object_permission (line 43) | def has_object_permission(self, request, view, obj):
class IsPhotoOrAlbumSharedTo (line 53) | class IsPhotoOrAlbumSharedTo(permissions.BasePermission):
method has_object_permission (line 56) | def has_object_permission(self, request, view, obj):
class IsRegistrationAllowed (line 70) | class IsRegistrationAllowed(permissions.BasePermission):
method has_permission (line 73) | def has_permission(self, request, view):
FILE: api/semantic_search.py
function create_clip_embeddings (line 8) | def create_clip_embeddings(imgs):
function calculate_query_embeddings (line 26) | def calculate_query_embeddings(query):
FILE: api/serializers/PhotosGroupedByDate.py
class PhotosGroupedByDate (line 7) | class PhotosGroupedByDate:
method __init__ (line 8) | def __init__(self, location, date, photos):
function get_photos_ordered_by_date (line 14) | def get_photos_ordered_by_date(photos):
FILE: api/serializers/album_auto.py
class AlbumAutoSerializer (line 9) | class AlbumAutoSerializer(serializers.ModelSerializer):
class Meta (line 13) | class Meta:
method get_people (line 27) | def get_people(self, obj) -> PersonSerializer(many=True):
method delete (line 37) | def delete(self, validated_data, id):
class AlbumAutoListSerializer (line 42) | class AlbumAutoListSerializer(serializers.ModelSerializer):
class Meta (line 46) | class Meta:
method get_photo_count (line 57) | def get_photo_count(self, obj) -> int:
method get_photos (line 63) | def get_photos(self, obj) -> PhotoHashListSerializer:
FILE: api/serializers/album_date.py
class IncompleteAlbumDateSerializer (line 6) | class IncompleteAlbumDateSerializer(serializers.ModelSerializer):
class Meta (line 14) | class Meta:
method get_id (line 18) | def get_id(self, obj) -> str:
method get_date (line 21) | def get_date(self, obj) -> str:
method get_items (line 27) | def get_items(self, obj) -> list:
method get_incomplete (line 30) | def get_incomplete(self, obj) -> bool:
method get_number_of_items (line 33) | def get_number_of_items(self, obj) -> int:
method get_location (line 39) | def get_location(self, obj) -> str:
class AlbumDateSerializer (line 46) | class AlbumDateSerializer(serializers.ModelSerializer):
class Meta (line 54) | class Meta:
method get_id (line 58) | def get_id(self, obj) -> str:
method get_date (line 61) | def get_date(self, obj) -> str:
method get_items (line 67) | def get_items(self, obj) -> dict:
method get_incomplete (line 71) | def get_incomplete(self, obj) -> bool:
method get_number_of_items (line 74) | def get_number_of_items(self, obj) -> int:
method get_location (line 78) | def get_location(self, obj) -> str:
FILE: api/serializers/album_place.py
class GroupedPlacePhotosSerializer (line 9) | class GroupedPlacePhotosSerializer(serializers.ModelSerializer):
class Meta (line 13) | class Meta:
method get_id (line 22) | def get_id(self, obj) -> str:
method get_grouped_photos (line 25) | def get_grouped_photos(self, obj) -> GroupedPhotosSerializer(many=True):
class AlbumPlaceSerializer (line 31) | class AlbumPlaceSerializer(serializers.ModelSerializer):
class Meta (line 34) | class Meta:
class AlbumPlaceListSerializer (line 39) | class AlbumPlaceListSerializer(serializers.ModelSerializer):
class Meta (line 43) | class Meta:
method get_photo_count (line 47) | def get_photo_count(self, obj) -> int:
FILE: api/serializers/album_thing.py
class GroupedThingPhotosSerializer (line 9) | class GroupedThingPhotosSerializer(serializers.ModelSerializer):
class Meta (line 13) | class Meta:
method get_id (line 21) | def get_id(self, obj) -> str:
method get_grouped_photos (line 24) | def get_grouped_photos(self, obj) -> GroupedPhotosSerializer(many=True):
class AlbumThingSerializer (line 30) | class AlbumThingSerializer(serializers.ModelSerializer):
class Meta (line 33) | class Meta:
class AlbumThingListSerializer (line 38) | class AlbumThingListSerializer(serializers.ModelSerializer):
class Meta (line 42) | class Meta:
method get_photo_count (line 53) | def get_photo_count(self, obj) -> int:
FILE: api/serializers/album_user.py
class AlbumUserSerializer (line 10) | class AlbumUserSerializer(serializers.ModelSerializer):
class Meta (line 22) | class Meta:
method get_id (line 39) | def get_id(self, obj) -> str:
method get_grouped_photos (line 42) | def get_grouped_photos(self, obj) -> GroupedPhotosSerializer(many=True):
method get_location (line 49) | def get_location(self, obj) -> str:
method get_date (line 60) | def get_date(self, obj) -> str:
method get_public (line 66) | def get_public(self, obj) -> bool:
method get_public_slug (line 69) | def get_public_slug(self, obj) -> str:
method get_public_expires_at (line 72) | def get_public_expires_at(self, obj):
method get_public_sharing_options (line 75) | def get_public_sharing_options(self, obj) -> dict | None:
class AlbumUserEditSerializer (line 89) | class AlbumUserEditSerializer(serializers.ModelSerializer):
class Meta (line 99) | class Meta:
method create (line 111) | def create(self, validated_data):
method update (line 131) | def update(self, instance, validated_data):
class AlbumUserListSerializer (line 168) | class AlbumUserListSerializer(serializers.ModelSerializer):
class Meta (line 178) | class Meta:
method get_cover_photo (line 195) | def get_cover_photo(self, obj) -> PhotoSuperSimpleSerializer:
method get_photo_count (line 200) | def get_photo_count(self, obj) -> int:
method get_public (line 206) | def get_public(self, obj) -> bool:
method get_public_slug (line 209) | def get_public_slug(self, obj) -> str:
method get_public_expires_at (line 212) | def get_public_expires_at(self, obj):
method get_public_sharing_options (line 215) | def get_public_sharing_options(self, obj) -> dict | None:
class AlbumUserPublicSerializer (line 229) | class AlbumUserPublicSerializer(serializers.ModelSerializer):
class Meta (line 243) | class Meta:
method get_id (line 256) | def get_id(self, obj) -> str:
method _filtered_photos (line 259) | def _filtered_photos(self, obj):
method get_grouped_photos (line 265) | def get_grouped_photos(self, obj) -> GroupedPhotosSerializer(many=True):
method get_location (line 269) | def get_location(self, obj) -> str:
method get_date (line 280) | def get_date(self, obj) -> str:
FILE: api/serializers/face.py
class PersonFaceListSerializer (line 6) | class PersonFaceListSerializer(serializers.ModelSerializer):
class Meta (line 11) | class Meta:
method get_person_label_probability (line 23) | def get_person_label_probability(self, obj):
method get_face_url (line 29) | def get_face_url(self, obj):
method get_photo_image_hash (line 32) | def get_photo_image_hash(self, obj):
class IncompletePersonFaceListSerializer (line 36) | class IncompletePersonFaceListSerializer(serializers.ModelSerializer):
class Meta (line 39) | class Meta:
method get_face_count (line 43) | def get_face_count(self, obj) -> int:
class FaceListSerializer (line 50) | class FaceListSerializer(serializers.ModelSerializer):
class Meta (line 55) | class Meta:
method get_person_label_probability (line 68) | def get_person_label_probability(self, obj) -> float:
method get_face_url (line 71) | def get_face_url(self, obj) -> str:
method get_person_name (line 74) | def get_person_name(self, obj) -> str:
FILE: api/serializers/job.py
class LongRunningJobSerializer (line 7) | class LongRunningJobSerializer(serializers.ModelSerializer):
class Meta (line 11) | class Meta:
method get_job_type_str (line 30) | def get_job_type_str(self, obj) -> str:
FILE: api/serializers/person.py
class GroupedPersonPhotosSerializer (line 10) | class GroupedPersonPhotosSerializer(serializers.ModelSerializer):
class Meta (line 14) | class Meta:
method get_id (line 22) | def get_id(self, obj) -> str:
method get_grouped_photos (line 25) | def get_grouped_photos(self, obj) -> GroupedPhotosSerializer(many=True):
class PersonSerializer (line 35) | class PersonSerializer(serializers.ModelSerializer):
class Meta (line 42) | class Meta:
method get_face_url (line 55) | def get_face_url(self, obj) -> str:
method get_face_photo_url (line 62) | def get_face_photo_url(self, obj) -> str:
method get_video (line 69) | def get_video(self, obj) -> str:
method create (line 76) | def create(self, validated_data):
method update (line 90) | def update(self, instance, validated_data):
method delete (line 105) | def delete(self, validated_data, id):
class AlbumPersonListSerializer (line 110) | class AlbumPersonListSerializer(serializers.ModelSerializer):
class Meta (line 114) | class Meta:
method get_photo_count (line 123) | def get_photo_count(self, obj) -> int:
method get_cover_photo_url (line 126) | def get_cover_photo_url(self, obj) -> str:
method get_face_photo_url (line 133) | def get_face_photo_url(self, obj) -> str:
FILE: api/serializers/photo_metadata.py
class MetadataFileSerializer (line 16) | class MetadataFileSerializer(serializers.ModelSerializer):
class Meta (line 19) | class Meta:
class MetadataEditSerializer (line 33) | class MetadataEditSerializer(serializers.ModelSerializer):
class Meta (line 38) | class Meta:
method get_user_name (line 53) | def get_user_name(self, obj) -> str:
class PhotoMetadataSerializer (line 59) | class PhotoMetadataSerializer(serializers.ModelSerializer):
class Meta (line 77) | class Meta:
method get_edit_history (line 150) | def get_edit_history(self, obj) -> list:
method get_sidecar_files (line 155) | def get_sidecar_files(self, obj) -> list:
class PhotoMetadataUpdateSerializer (line 161) | class PhotoMetadataUpdateSerializer(serializers.ModelSerializer):
class Meta (line 169) | class Meta:
method update (line 191) | def update(self, instance, validated_data):
class PhotoMetadataSummarySerializer (line 220) | class PhotoMetadataSummarySerializer(serializers.Serializer):
method get_has_edits (line 249) | def get_has_edits(self, obj) -> bool:
function get_backwards_compatible_metadata (line 254) | def get_backwards_compatible_metadata(photo: Photo) -> dict:
FILE: api/serializers/photos.py
class PhotoSummarySerializer (line 16) | class PhotoSummarySerializer(serializers.ModelSerializer):
class Meta (line 35) | class Meta:
method get_aspectRatio (line 59) | def get_aspectRatio(self, obj) -> float:
method get_url (line 63) | def get_url(self, obj) -> str:
method get_location (line 66) | def get_location(self, obj) -> str:
method get_date (line 76) | def get_date(self, obj) -> str:
method get_video_length (line 82) | def get_video_length(self, obj) -> int:
method get_birthTime (line 89) | def get_birthTime(self, obj) -> str:
method get_dominantColor (line 95) | def get_dominantColor(self, obj) -> str:
method get_type (line 102) | def get_type(self, obj) -> str:
method get_stacks (line 110) | def get_stacks(self, obj) -> list | None:
method get_has_raw_variant (line 148) | def get_has_raw_variant(self, obj) -> bool:
class GroupedPhotosSerializer (line 161) | class GroupedPhotosSerializer(serializers.ModelSerializer):
class Meta (line 166) | class Meta:
method get_date (line 170) | def get_date(self, obj) -> str:
method get_location (line 173) | def get_location(self, obj) -> str:
method get_items (line 176) | def get_items(self, obj) -> PhotoSummarySerializer(many=True):
class PhotoEditSerializer (line 180) | class PhotoEditSerializer(serializers.ModelSerializer):
class Meta (line 181) | class Meta:
method update (line 197) | def update(self, instance, validated_data):
class PhotoHashListSerializer (line 275) | class PhotoHashListSerializer(serializers.ModelSerializer):
class Meta (line 276) | class Meta:
class PhotoDetailsSummarySerializer (line 281) | class PhotoDetailsSummarySerializer(serializers.ModelSerializer):
class Meta (line 286) | class Meta:
method get_photo_summary (line 290) | def get_photo_summary(self, obj) -> PhotoSummarySerializer:
method get_processing (line 293) | def get_processing(self, obj) -> bool:
method get_album_date_id (line 296) | def get_album_date_id(self, obj) -> int:
class PhotoSerializer (line 304) | class PhotoSerializer(serializers.ModelSerializer):
class Meta (line 338) | class Meta:
method _get_metadata (line 383) | def _get_metadata(self, obj) -> PhotoMetadata | None:
method get_height (line 392) | def get_height(self, obj) -> int:
method get_width (line 396) | def get_width(self, obj) -> int:
method get_focal_length (line 400) | def get_focal_length(self, obj) -> float | None:
method get_fstop (line 404) | def get_fstop(self, obj) -> float | None:
method get_iso (line 408) | def get_iso(self, obj) -> int | None:
method get_shutter_speed (line 412) | def get_shutter_speed(self, obj) -> str | None:
method get_lens (line 416) | def get_lens(self, obj) -> str | None:
method get_camera (line 420) | def get_camera(self, obj) -> str | None:
method get_focalLength35Equivalent (line 424) | def get_focalLength35Equivalent(self, obj) -> int | None:
method get_digitalZoomRatio (line 428) | def get_digitalZoomRatio(self, obj) -> float | None:
method get_subjectDistance (line 432) | def get_subjectDistance(self, obj) -> float | None:
method get_similar_photos (line 436) | def get_similar_photos(self, obj) -> list:
method get_captions_json (line 452) | def get_captions_json(self, obj) -> dict:
method get_search_captions (line 467) | def get_search_captions(self, obj) -> str:
method get_search_location (line 472) | def get_search_location(self, obj) -> str:
method get_image_path (line 477) | def get_image_path(self, obj) -> list[str]:
method get_square_thumbnail_url (line 486) | def get_square_thumbnail_url(self, obj) -> str:
method get_small_square_thumbnail_url (line 491) | def get_small_square_thumbnail_url(self, obj) -> str:
method get_big_thumbnail_url (line 498) | def get_big_thumbnail_url(self, obj) -> str:
method get_geolocation (line 501) | def get_geolocation(self, obj) -> dict:
method get_people (line 507) | def get_people(self, obj) -> list:
method get_embedded_media (line 557) | def get_embedded_media(self, obj: Photo) -> list[dict]:
method get_metadata (line 575) | def get_metadata(self, obj: Photo) -> dict | None:
method get_file_variants (line 593) | def get_file_variants(self, obj: Photo) -> list | None:
method get_stacks (line 632) | def get_stacks(self, obj: Photo) -> list | None:
class SharedFromMePhotoThroughSerializer (line 686) | class SharedFromMePhotoThroughSerializer(serializers.ModelSerializer):
class Meta (line 690) | class Meta:
method get_photo (line 694) | def get_photo(self, obj) -> PhotoSummarySerializer:
class PublicPhotoDetailSerializer (line 698) | class PublicPhotoDetailSerializer(serializers.ModelSerializer):
class Meta (line 741) | class Meta:
method _get_sharing_settings (line 767) | def _get_sharing_settings(self) -> dict:
method _get_metadata (line 771) | def _get_metadata(self, obj) -> PhotoMetadata | None:
method get_square_thumbnail_url (line 781) | def get_square_thumbnail_url(self, obj) -> str:
method get_small_square_thumbnail_url (line 784) | def get_small_square_thumbnail_url(self, obj) -> str:
method get_big_thumbnail_url (line 787) | def get_big_thumbnail_url(self, obj) -> str:
method get_exif_timestamp (line 791) | def get_exif_timestamp(self, obj):
method get_exif_gps_lat (line 797) | def get_exif_gps_lat(self, obj):
method get_exif_gps_lon (line 802) | def get_exif_gps_lon(self, obj):
method get_geolocation_json (line 807) | def get_geolocation_json(self, obj):
method get_search_location (line 812) | def get_search_location(self, obj) -> str:
method get_camera (line 819) | def get_camera(self, obj) -> str | None:
method get_lens (line 825) | def get_lens(self, obj) -> str | None:
method get_focal_length (line 831) | def get_focal_length(self, obj) -> float | None:
method get_fstop (line 837) | def get_fstop(self, obj) -> float | None:
method get_iso (line 843) | def get_iso(self, obj) -> int | None:
method get_shutter_speed (line 849) | def get_shutter_speed(self, obj) -> str | None:
method get_width (line 855) | def get_width(self, obj) -> int:
method get_height (line 861) | def get_height(self, obj) -> int:
method get_search_captions (line 868) | def get_search_captions(self, obj) -> str:
method get_captions_json (line 874) | def get_captions_json(self, obj) -> dict:
method get_people (line 886) | def get_people(self, obj) -> list:
FILE: api/serializers/simple.py
class PhotoSuperSimpleSerializer (line 6) | class PhotoSuperSimpleSerializer(serializers.ModelSerializer):
class Meta (line 7) | class Meta:
class PhotoSimpleSerializer (line 12) | class PhotoSimpleSerializer(serializers.ModelSerializer):
class Meta (line 15) | class Meta:
method get_square_thumbnail (line 29) | def get_square_thumbnail(self, obj) -> str:
class SimpleUserSerializer (line 37) | class SimpleUserSerializer(serializers.ModelSerializer):
class Meta (line 38) | class Meta:
FILE: api/serializers/user.py
class UserSerializer (line 17) | class UserSerializer(serializers.ModelSerializer):
class Meta (line 23) | class Meta:
method validate_nextcloud_app_password (line 95) | def validate_nextcloud_app_password(self, value):
method create (line 98) | def create(self, validated_data):
method update (line 119) | def update(self, instance, validated_data):
method get_photo_count (line 295) | def get_photo_count(self, obj) -> int:
method get_public_photo_count (line 298) | def get_public_photo_count(self, obj) -> int:
method get_public_photo_samples (line 301) | def get_public_photo_samples(self, obj) -> PhotoSuperSimpleSerializer(...
method get_avatar_url (line 306) | def get_avatar_url(self, obj) -> str or None:
class PublicUserSerializer (line 313) | class PublicUserSerializer(serializers.ModelSerializer):
class Meta (line 318) | class Meta:
method get_public_photo_count (line 330) | def get_public_photo_count(self, obj) -> int:
method get_public_photo_samples (line 333) | def get_public_photo_samples(self, obj) -> PhotoSuperSimpleSerializer(...
method get_avatar_url (line 338) | def get_avatar_url(self, obj) -> str or None:
class SignupUserSerializer (line 345) | class SignupUserSerializer(serializers.ModelSerializer):
class Meta (line 346) | class Meta:
method create (line 369) | def create(self, validated_data):
class DeleteUserSerializer (line 379) | class DeleteUserSerializer(serializers.ModelSerializer):
class Meta (line 380) | class Meta:
class ManageUserSerializer (line 385) | class ManageUserSerializer(serializers.ModelSerializer):
class Meta (line 388) | class Meta:
method get_photo_count (line 416) | def get_photo_count(self, obj) -> int:
method update (line 419) | def update(self, instance: User, validated_data):
FILE: api/services.py
function check_services (line 39) | def check_services():
function is_healthy (line 51) | def is_healthy(service):
function start_service (line 66) | def start_service(service):
function stop_service (line 96) | def stop_service(service):
function _is_arm_architecture (line 128) | def _is_arm_architecture():
function check_cpu_features (line 138) | def check_cpu_features():
function has_required_cpu_features (line 169) | def has_required_cpu_features(service):
function is_service_compatible (line 217) | def is_service_compatible(service):
function cleanup_deleted_photos (line 227) | def cleanup_deleted_photos():
FILE: api/social_graph.py
function build_social_graph (line 8) | def build_social_graph(user):
function build_ego_graph (line 46) | def build_ego_graph(person_id):
FILE: api/stack_detection.py
function clear_stacks_of_type (line 41) | def clear_stacks_of_type(user, stack_type):
function detect_burst_sequences (line 71) | def detect_burst_sequences(
function _detect_bursts_hard_criteria (line 132) | def _detect_bursts_hard_criteria(user, hard_rules, progress_callback=None):
function _detect_bursts_soft_criteria (line 200) | def _detect_bursts_soft_criteria(
function _create_burst_stack (line 282) | def _create_burst_stack(user, photos):
function batch_detect_stacks (line 317) | def batch_detect_stacks(user, options=None):
FILE: api/stacks/live_photo.py
function _locate_google_embedded_video (line 38) | def _locate_google_embedded_video(data: bytes) -> int:
function _locate_samsung_embedded_video (line 48) | def _locate_samsung_embedded_video(data: bytes) -> int:
function has_embedded_motion_video (line 57) | def has_embedded_motion_video(path: str) -> bool:
function extract_embedded_motion_video (line 88) | def extract_embedded_motion_video(path: str, output_hash: str) -> str | ...
function find_apple_live_photo_video (line 129) | def find_apple_live_photo_video(image_path: str) -> str | None:
function detect_live_photo (line 154) | def detect_live_photo(photo: "Photo", user: "User") -> PhotoStack | None:
function _create_embedded_live_photo_stack (line 186) | def _create_embedded_live_photo_stack(photo: "Photo", user: "User") -> P...
function _create_apple_live_photo_stack (line 223) | def _create_apple_live_photo_stack(
function process_live_photos_batch (line 266) | def process_live_photos_batch(user: "User", photos: list["Photo"]) -> dict:
FILE: api/stats.py
function _is_sqlite (line 30) | def _is_sqlite() -> bool:
function jump_by_month (line 34) | def jump_by_month(start_date, end_date, month_step=1):
function median_value (line 46) | def median_value(queryset, term):
function calc_megabytes (line 59) | def calc_megabytes(bytes):
function get_server_stats (line 65) | def get_server_stats():
function get_count_stats (line 380) | def get_count_stats(user):
function get_photo_month_counts (line 444) | def get_photo_month_counts(user):
function get_searchterms_wordcloud (line 487) | def get_searchterms_wordcloud(user):
function get_location_sunburst (line 603) | def get_location_sunburst(user):
function get_location_clusters (line 665) | def get_location_clusters(user):
function get_location_timeline (line 707) | def get_location_timeline(user):
FILE: api/tests/test_api_robustness.py
class DuplicatesAPIRobustnessTestCase (line 28) | class DuplicatesAPIRobustnessTestCase(TestCase):
method setUp (line 31) | def setUp(self):
method test_resolve_with_empty_body (line 40) | def test_resolve_with_empty_body(self):
method test_resolve_with_invalid_photo_id (line 56) | def test_resolve_with_invalid_photo_id(self):
method test_resolve_with_nonexistent_photo (line 70) | def test_resolve_with_nonexistent_photo(self):
method test_access_nonexistent_duplicate (line 85) | def test_access_nonexistent_duplicate(self):
method test_delete_nonexistent_duplicate (line 92) | def test_delete_nonexistent_duplicate(self):
method test_list_with_invalid_status_filter (line 100) | def test_list_with_invalid_status_filter(self):
method test_list_with_invalid_type_filter (line 106) | def test_list_with_invalid_type_filter(self):
method test_extremely_long_string_in_query (line 111) | def test_extremely_long_string_in_query(self):
method test_special_characters_in_query (line 118) | def test_special_characters_in_query(self):
method test_unicode_in_query (line 123) | def test_unicode_in_query(self):
method test_null_bytes_in_request (line 128) | def test_null_bytes_in_request(self):
class StacksAPIRobustnessTestCase (line 143) | class StacksAPIRobustnessTestCase(TestCase):
method setUp (line 146) | def setUp(self):
method _create_photo (line 155) | def _create_photo(self, suffix):
method test_create_manual_stack_with_empty_photos (line 169) | def test_create_manual_stack_with_empty_photos(self):
method test_create_manual_stack_with_single_photo (line 179) | def test_create_manual_stack_with_single_photo(self):
method test_create_manual_stack_with_invalid_photo_ids (line 191) | def test_create_manual_stack_with_invalid_photo_ids(self):
method test_create_manual_stack_with_nonexistent_photos (line 200) | def test_create_manual_stack_with_nonexistent_photos(self):
method test_create_manual_stack_with_other_user_photos (line 211) | def test_create_manual_stack_with_other_user_photos(self):
method test_set_primary_with_photo_not_in_stack (line 238) | def test_set_primary_with_photo_not_in_stack(self):
method test_add_photo_to_nonexistent_stack (line 260) | def test_add_photo_to_nonexistent_stack(self):
method test_remove_all_photos_from_stack (line 273) | def test_remove_all_photos_from_stack(self):
method test_merge_stacks_with_empty_list (line 294) | def test_merge_stacks_with_empty_list(self):
method test_merge_single_stack (line 303) | def test_merge_single_stack(self):
method test_list_with_invalid_stack_type_filter (line 322) | def test_list_with_invalid_stack_type_filter(self):
class PhotoMetadataAPIRobustnessTestCase (line 328) | class PhotoMetadataAPIRobustnessTestCase(TestCase):
method setUp (line 331) | def setUp(self):
method test_update_with_invalid_field_types (line 352) | def test_update_with_invalid_field_types(self):
method test_update_with_negative_values (line 362) | def test_update_with_negative_values(self):
method test_update_with_extremely_large_numbers (line 372) | def test_update_with_extremely_large_numbers(self):
method test_update_nonexistent_photo_metadata (line 381) | def test_update_nonexistent_photo_metadata(self):
method test_update_other_user_photo_metadata (line 392) | def test_update_other_user_photo_metadata(self):
method test_revert_nonexistent_edit (line 417) | def test_revert_nonexistent_edit(self):
class AuthenticationRobustnessTestCase (line 427) | class AuthenticationRobustnessTestCase(TestCase):
method setUp (line 430) | def setUp(self):
method test_unauthenticated_access_to_duplicates (line 438) | def test_unauthenticated_access_to_duplicates(self):
method test_unauthenticated_access_to_stacks (line 443) | def test_unauthenticated_access_to_stacks(self):
method test_unauthenticated_detect_duplicates (line 448) | def test_unauthenticated_detect_duplicates(self):
method test_unauthenticated_detect_stacks (line 453) | def test_unauthenticated_detect_stacks(self):
class ConcurrentOperationsTestCase (line 459) | class ConcurrentOperationsTestCase(TestCase):
method setUp (line 462) | def setUp(self):
method _create_photo (line 471) | def _create_photo(self, suffix):
method test_delete_already_deleted_duplicate (line 485) | def test_delete_already_deleted_duplicate(self):
method test_delete_already_deleted_stack (line 500) | def test_delete_already_deleted_stack(self):
method test_resolve_already_resolved_duplicate (line 517) | def test_resolve_already_resolved_duplicate(self):
class BoundaryConditionsTestCase (line 542) | class BoundaryConditionsTestCase(TestCase):
method setUp (line 545) | def setUp(self):
method test_pagination_with_zero_page (line 554) | def test_pagination_with_zero_page(self):
method test_pagination_with_negative_page (line 559) | def test_pagination_with_negative_page(self):
method test_pagination_with_very_large_page (line 564) | def test_pagination_with_very_large_page(self):
method test_pagination_with_invalid_page_size (line 570) | def test_pagination_with_invalid_page_size(self):
method test_pagination_with_extremely_large_page_size (line 577) | def test_pagination_with_extremely_large_page_size(self):
method test_stacks_pagination_with_zero_page (line 583) | def test_stacks_pagination_with_zero_page(self):
class MalformedRequestTestCase (line 589) | class MalformedRequestTestCase(TestCase):
method setUp (line 592) | def setUp(self):
method test_post_with_invalid_json (line 601) | def test_post_with_invalid_json(self):
method test_post_with_wrong_content_type (line 610) | def test_post_with_wrong_content_type(self):
method test_get_with_duplicate_query_params (line 619) | def test_get_with_duplicate_query_params(self):
FILE: api/tests/test_api_util.py
function create_photos (line 11) | def create_photos(user):
function compare_objects_with_ignored_props (line 16) | def compare_objects_with_ignored_props(result, expectation, ignore):
class TestApiUtil (line 34) | class TestApiUtil(TestCase):
method setUp (line 35) | def setUp(self) -> None:
method test_wordcloud (line 40) | def test_wordcloud(self):
method test_photo_month_count (line 56) | def test_photo_month_count(self):
method test_photo_month_count_no_photos (line 69) | def test_photo_month_count_no_photos(self):
method test_location_sunburst (line 74) | def test_location_sunburst(self):
FILE: api/tests/test_auto_select_and_savings.py
class PhotoStackAutoSelectPrimaryTestCase (line 21) | class PhotoStackAutoSelectPrimaryTestCase(TestCase):
method setUp (line 24) | def setUp(self):
method test_auto_select_empty_stack (line 27) | def test_auto_select_empty_stack(self):
method test_auto_select_single_photo (line 38) | def test_auto_select_single_photo(self):
method test_auto_select_raw_jpeg_prefers_jpeg (line 52) | def test_auto_select_raw_jpeg_prefers_jpeg(self):
method test_auto_select_raw_jpeg_only_raw (line 73) | def test_auto_select_raw_jpeg_only_raw(self):
method test_auto_select_burst_picks_middle (line 92) | def test_auto_select_burst_picks_middle(self):
method test_auto_select_burst_no_timestamps (line 115) | def test_auto_select_burst_no_timestamps(self):
method test_auto_select_manual_highest_resolution (line 135) | def test_auto_select_manual_highest_resolution(self):
method test_auto_select_manual_no_metadata (line 167) | def test_auto_select_manual_no_metadata(self):
class DuplicateAutoSelectBestTestCase (line 185) | class DuplicateAutoSelectBestTestCase(TestCase):
method setUp (line 188) | def setUp(self):
method test_auto_select_empty_duplicate (line 191) | def test_auto_select_empty_duplicate(self):
method test_auto_select_exact_copy_shortest_path (line 202) | def test_auto_select_exact_copy_shortest_path(self):
method test_auto_select_visual_duplicate_highest_resolution (line 230) | def test_auto_select_visual_duplicate_highest_resolution(self):
method test_auto_select_visual_no_metadata (line 255) | def test_auto_select_visual_no_metadata(self):
method test_auto_select_visual_partial_metadata (line 272) | def test_auto_select_visual_partial_metadata(self):
class DuplicatePotentialSavingsTestCase (line 294) | class DuplicatePotentialSavingsTestCase(TestCase):
method setUp (line 297) | def setUp(self):
method test_savings_empty_duplicate (line 300) | def test_savings_empty_duplicate(self):
method test_savings_single_photo (line 311) | def test_savings_single_photo(self):
method test_savings_two_photos (line 328) | def test_savings_two_photos(self):
method test_savings_many_photos (line 353) | def test_savings_many_photos(self):
method test_savings_zero_sizes (line 380) | def test_savings_zero_sizes(self):
method test_savings_updates_model_field (line 404) | def test_savings_updates_model_field(self):
class DuplicateResolveRevertTestCase (line 434) | class DuplicateResolveRevertTestCase(TestCase):
method setUp (line 437) | def setUp(self):
method test_resolve_marks_status (line 440) | def test_resolve_marks_status(self):
method test_resolve_trash_others (line 458) | def test_resolve_trash_others(self):
method test_resolve_no_trash (line 484) | def test_resolve_no_trash(self):
method test_revert_restores_trashed (line 502) | def test_revert_restores_trashed(self):
method test_revert_clears_kept_photo (line 531) | def test_revert_clears_kept_photo(self):
class DuplicateDismissTestCase (line 551) | class DuplicateDismissTestCase(TestCase):
method setUp (line 554) | def setUp(self):
method test_dismiss_sets_status (line 557) | def test_dismiss_sets_status(self):
method test_dismiss_doesnt_trash (line 574) | def test_dismiss_doesnt_trash(self):
FILE: api/tests/test_background_tasks.py
class _FakeQuerySet (line 9) | class _FakeQuerySet(list):
method count (line 10) | def count(self):
class GeolocateLoggingTests (line 14) | class GeolocateLoggingTests(unittest.TestCase):
method test_geolocate_logs_exception_without_crash (line 15) | def test_geolocate_logs_exception_without_crash(self):
FILE: api/tests/test_bktree_and_duplicate_detection.py
class BKTreeTestCase (line 26) | class BKTreeTestCase(TestCase):
method test_empty_tree_search (line 29) | def test_empty_tree_search(self):
method test_add_single_item (line 37) | def test_add_single_item(self):
method test_add_multiple_items (line 45) | def test_add_multiple_items(self):
method test_search_exact_match (line 54) | def test_search_exact_match(self):
method test_search_within_threshold (line 66) | def test_search_within_threshold(self):
method test_search_no_matches (line 81) | def test_search_no_matches(self):
method test_hamming_distance_search (line 91) | def test_hamming_distance_search(self):
class UnionFindTestCase (line 111) | class UnionFindTestCase(TestCase):
method test_initial_state (line 114) | def test_initial_state(self):
method test_union (line 122) | def test_union(self):
method test_transitive_union (line 129) | def test_transitive_union(self):
method test_get_groups (line 137) | def test_get_groups(self):
class ExactCopyDetectionTestCase (line 151) | class ExactCopyDetectionTestCase(TestCase):
method setUp (line 154) | def setUp(self):
method _create_photo_with_hash (line 158) | def _create_photo_with_hash(self, file_hash, **kwargs):
method test_no_duplicates (line 176) | def test_no_duplicates(self):
method test_detect_exact_copies (line 186) | def test_detect_exact_copies(self):
method test_excludes_trashed_photos (line 207) | def test_excludes_trashed_photos(self):
method test_excludes_hidden_photos (line 228) | def test_excludes_hidden_photos(self):
class VisualDuplicateDetectionTestCase (line 249) | class VisualDuplicateDetectionTestCase(TestCase):
method setUp (line 252) | def setUp(self):
method test_no_visual_duplicates (line 255) | def test_no_visual_duplicates(self):
method test_detect_visual_duplicates (line 270) | def test_detect_visual_duplicates(self):
method test_threshold_affects_detection (line 286) | def test_threshold_affects_detection(self):
method test_skips_photos_without_phash (line 305) | def test_skips_photos_without_phash(self):
method test_excludes_trashed_photos (line 319) | def test_excludes_trashed_photos(self):
class BatchDetectionTestCase (line 335) | class BatchDetectionTestCase(TestCase):
method setUp (line 338) | def setUp(self):
method test_batch_detection_all_enabled (line 341) | def test_batch_detection_all_enabled(self):
method test_batch_detection_exact_only (line 359) | def test_batch_detection_exact_only(self):
method test_batch_detection_visual_only (line 374) | def test_batch_detection_visual_only(self):
method test_batch_detection_with_clear_pending (line 390) | def test_batch_detection_with_clear_pending(self):
method test_batch_detection_with_null_options (line 416) | def test_batch_detection_with_null_options(self):
method test_batch_detection_with_empty_options (line 426) | def test_batch_detection_with_empty_options(self):
class MultiUserDuplicateIsolationTestCase (line 437) | class MultiUserDuplicateIsolationTestCase(TestCase):
method setUp (line 440) | def setUp(self):
method test_detection_only_affects_own_photos (line 444) | def test_detection_only_affects_own_photos(self):
method test_clearing_pending_only_affects_own (line 470) | def test_clearing_pending_only_affects_own(self):
class DuplicateCreationEdgeCasesTestCase (line 494) | class DuplicateCreationEdgeCasesTestCase(TestCase):
method setUp (line 497) | def setUp(self):
method test_three_way_duplicates (line 500) | def test_three_way_duplicates(self):
method test_many_duplicates_same_hash (line 520) | def test_many_duplicates_same_hash(self):
FILE: api/tests/test_bulk_operations.py
class BuildPhotoQuerysetTest (line 20) | class BuildPhotoQuerysetTest(TestCase):
method setUp (line 23) | def setUp(self):
method test_filters_by_owner (line 27) | def test_filters_by_owner(self):
method test_filters_by_video (line 35) | def test_filters_by_video(self):
method test_filters_by_photo (line 43) | def test_filters_by_photo(self):
method test_filters_hidden (line 51) | def test_filters_hidden(self):
method test_filters_in_trashcan (line 64) | def test_filters_in_trashcan(self):
class BulkSetPhotosPublicTest (line 78) | class BulkSetPhotosPublicTest(TestCase):
method setUp (line 81) | def setUp(self):
method test_select_all_make_public (line 87) | def test_select_all_make_public(self):
method test_select_all_make_private (line 109) | def test_select_all_make_private(self):
method test_select_all_with_exclusions (line 131) | def test_select_all_with_exclusions(self):
method test_select_all_only_affects_own_photos (line 161) | def test_select_all_only_affects_own_photos(self):
class BulkSetPhotosHiddenTest (line 187) | class BulkSetPhotosHiddenTest(TestCase):
method setUp (line 190) | def setUp(self):
method test_select_all_hide_photos (line 195) | def test_select_all_hide_photos(self):
method test_select_all_unhide_photos (line 217) | def test_select_all_unhide_photos(self):
class BulkSetPhotosFavoriteTest (line 240) | class BulkSetPhotosFavoriteTest(TestCase):
method setUp (line 243) | def setUp(self):
method test_select_all_favorite_photos (line 248) | def test_select_all_favorite_photos(self):
method test_select_all_unfavorite_photos (line 270) | def test_select_all_unfavorite_photos(self):
class BulkSetPhotosDeletedTest (line 297) | class BulkSetPhotosDeletedTest(TestCase):
method setUp (line 300) | def setUp(self):
method test_select_all_move_to_trash (line 305) | def test_select_all_move_to_trash(self):
method test_select_all_restore_from_trash (line 329) | def test_select_all_restore_from_trash(self):
class BulkSharePhotosTest (line 354) | class BulkSharePhotosTest(TestCase):
method setUp (line 357) | def setUp(self):
method test_select_all_share_photos (line 363) | def test_select_all_share_photos(self):
method test_select_all_unshare_photos (line 386) | def test_select_all_unshare_photos(self):
method test_select_all_share_with_exclusions (line 417) | def test_select_all_share_with_exclusions(self):
FILE: api/tests/test_burst_detection_rules.py
class BurstRuleTypesTestCase (line 37) | class BurstRuleTypesTestCase(TestCase):
method test_hard_criteria_types (line 40) | def test_hard_criteria_types(self):
method test_soft_criteria_types (line 46) | def test_soft_criteria_types(self):
class BurstRuleCategoryTestCase (line 52) | class BurstRuleCategoryTestCase(TestCase):
method test_categories (line 55) | def test_categories(self):
class BurstFilenamePatternTestCase (line 61) | class BurstFilenamePatternTestCase(TestCase):
method test_burst_suffix_pattern (line 64) | def test_burst_suffix_pattern(self):
method test_sequence_suffix_pattern (line 72) | def test_sequence_suffix_pattern(self):
method test_bracketed_sequence_pattern (line 80) | def test_bracketed_sequence_pattern(self):
method test_samsung_burst_pattern (line 88) | def test_samsung_burst_pattern(self):
method test_iphone_burst_pattern (line 95) | def test_iphone_burst_pattern(self):
class BurstDetectionRuleTestCase (line 103) | class BurstDetectionRuleTestCase(TestCase):
method test_create_rule_with_minimal_params (line 106) | def test_create_rule_with_minimal_params(self):
method test_create_rule_with_all_params (line 120) | def test_create_rule_with_all_params(self):
method test_get_required_exif_tags_burst_mode (line 141) | def test_get_required_exif_tags_burst_mode(self):
method test_get_required_exif_tags_sequence_number (line 155) | def test_get_required_exif_tags_sequence_number(self):
method test_get_required_exif_tags_with_condition (line 169) | def test_get_required_exif_tags_with_condition(self):
class RuleConditionTestCase (line 182) | class RuleConditionTestCase(TestCase):
method test_check_condition_path_matches (line 185) | def test_check_condition_path_matches(self):
method test_check_condition_path_no_condition (line 197) | def test_check_condition_path_no_condition(self):
method test_check_condition_filename_matches (line 207) | def test_check_condition_filename_matches(self):
method test_check_condition_exif_matches (line 219) | def test_check_condition_exif_matches(self):
method test_check_condition_exif_invalid_format (line 232) | def test_check_condition_exif_invalid_format(self):
method test_check_all_conditions_combined (line 243) | def test_check_all_conditions_combined(self):
class ExifBurstModeRuleTestCase (line 276) | class ExifBurstModeRuleTestCase(TestCase):
method _create_mock_photo (line 279) | def _create_mock_photo(self, path="/photos/test.jpg", timestamp=None):
method test_burst_mode_on (line 287) | def test_burst_mode_on(self):
method test_burst_mode_on_string (line 306) | def test_burst_mode_on_string(self):
method test_continuous_drive_on (line 322) | def test_continuous_drive_on(self):
method test_burst_mode_off (line 338) | def test_burst_mode_off(self):
method test_disabled_rule_returns_false (line 354) | def test_disabled_rule_returns_false(self):
class ExifSequenceNumberRuleTestCase (line 371) | class ExifSequenceNumberRuleTestCase(TestCase):
method _create_mock_photo (line 374) | def _create_mock_photo(self, path="/photos/test.jpg", timestamp=None):
method test_sequence_number_detected (line 382) | def test_sequence_number_detected(self):
method test_sequence_number_zero (line 401) | def test_sequence_number_zero(self):
method test_invalid_sequence_number (line 417) | def test_invalid_sequence_number(self):
method test_no_sequence_number (line 433) | def test_no_sequence_number(self):
class FilenamePatternRuleTestCase (line 448) | class FilenamePatternRuleTestCase(TestCase):
method _create_mock_photo (line 451) | def _create_mock_photo(self, path):
method test_burst_suffix_detected (line 459) | def test_burst_suffix_detected(self):
method test_sequence_suffix_detected (line 477) | def test_sequence_suffix_detected(self):
method test_bracketed_sequence_detected (line 492) | def test_bracketed_sequence_detected(self):
method test_custom_pattern (line 507) | def test_custom_pattern(self):
method test_specific_pattern_type (line 522) | def test_specific_pattern_type(self):
method test_no_pattern_match (line 543) | def test_no_pattern_match(self):
method test_no_main_file (line 558) | def test_no_main_file(self):
method test_group_key_contains_directory (line 573) | def test_group_key_contains_directory(self):
class GroupPhotosByTimestampTestCase (line 594) | class GroupPhotosByTimestampTestCase(TestCase):
method _create_mock_photo (line 597) | def _create_mock_photo(self, timestamp, camera_make="Canon", camera_mo...
method test_group_consecutive_photos (line 606) | def test_group_consecutive_photos(self):
method test_separate_groups_by_time_gap (line 620) | def test_separate_groups_by_time_gap(self):
method test_single_photo_not_grouped (line 637) | def test_single_photo_not_grouped(self):
method test_photos_without_timestamp_skipped (line 645) | def test_photos_without_timestamp_skipped(self):
method test_empty_photo_list (line 660) | def test_empty_photo_list(self):
method test_require_same_camera (line 665) | def test_require_same_camera(self):
method test_without_camera_requirement (line 684) | def test_without_camera_requirement(self):
method test_custom_interval (line 699) | def test_custom_interval(self):
class GroupPhotosByVisualSimilarityTestCase (line 716) | class GroupPhotosByVisualSimilarityTestCase(TestCase):
method _create_mock_photo_with_hash (line 719) | def _create_mock_photo_with_hash(self, phash):
method test_group_similar_photos (line 726) | def test_group_similar_photos(self, mock_hamming):
method test_separate_dissimilar_photos (line 743) | def test_separate_dissimilar_photos(self, mock_hamming):
method test_photos_without_hash_filtered (line 760) | def test_photos_without_hash_filtered(self):
method test_empty_list (line 775) | def test_empty_list(self):
method test_single_photo_with_hash (line 780) | def test_single_photo_with_hash(self):
class DefaultRulesTestCase (line 788) | class DefaultRulesTestCase(TestCase):
method test_default_hard_rules_count (line 791) | def test_default_hard_rules_count(self):
method test_default_soft_rules_count (line 795) | def test_default_soft_rules_count(self):
method test_default_hard_rules_enabled (line 799) | def test_default_hard_rules_enabled(self):
method test_default_soft_rules_disabled (line 804) | def test_default_soft_rules_disabled(self):
method test_all_default_rules_have_ids (line 809) | def test_all_default_rules_have_ids(self):
method test_get_default_burst_detection_rules (line 815) | def test_get_default_burst_detection_rules(self):
method test_get_all_predefined_burst_rules (line 820) | def test_get_all_predefined_burst_rules(self):
class RuleFilteringTestCase (line 826) | class RuleFilteringTestCase(TestCase):
method test_as_rules (line 829) | def test_as_rules(self):
method test_get_hard_rules (line 841) | def test_get_hard_rules(self):
method test_get_soft_rules (line 871) | def test_get_soft_rules(self):
method test_get_enabled_rules (line 895) | def test_get_enabled_rules(self):
class EdgeCasesTestCase (line 920) | class EdgeCasesTestCase(TestCase):
method test_rule_with_none_timestamp (line 923) | def test_rule_with_none_timestamp(self):
method test_group_key_consistency (line 945) | def test_group_key_consistency(self):
method test_empty_exif_tags (line 966) | def test_empty_exif_tags(self):
method test_special_characters_in_filename (line 983) | def test_special_characters_in_filename(self):
method test_case_insensitive_pattern_matching (line 1002) | def test_case_insensitive_pattern_matching(self):
FILE: api/tests/test_burst_filename_patterns.py
class BurstFilenamePatternMatchingTestCase (line 27) | class BurstFilenamePatternMatchingTestCase(TestCase):
method test_burst_suffix_pattern (line 30) | def test_burst_suffix_pattern(self):
method test_sequence_suffix_pattern (line 43) | def test_sequence_suffix_pattern(self):
method test_bracketed_sequence_pattern (line 58) | def test_bracketed_sequence_pattern(self):
method test_samsung_burst_pattern (line 73) | def test_samsung_burst_pattern(self):
method test_iphone_burst_pattern (line 83) | def test_iphone_burst_pattern(self):
class CheckFilenamePatternTestCase (line 95) | class CheckFilenamePatternTestCase(TestCase):
method setUp (line 98) | def setUp(self):
method test_check_any_pattern_burst_suffix (line 101) | def test_check_any_pattern_burst_suffix(self):
method test_check_any_pattern_sequence (line 112) | def test_check_any_pattern_sequence(self):
method test_check_any_pattern_bracketed (line 121) | def test_check_any_pattern_bracketed(self):
method test_check_specific_pattern (line 130) | def test_check_specific_pattern(self):
method test_no_match (line 144) | def test_no_match(self):
method test_group_key_includes_directory (line 154) | def test_group_key_includes_directory(self):
method test_same_directory_same_base_grouped (line 172) | def test_same_directory_same_base_grouped(self):
class GroupPhotosByTimestampTestCase (line 191) | class GroupPhotosByTimestampTestCase(TestCase):
method setUp (line 194) | def setUp(self):
method test_group_consecutive_timestamps (line 197) | def test_group_consecutive_timestamps(self):
method test_separate_groups_by_gap (line 217) | def test_separate_groups_by_gap(self):
method test_single_photo_no_group (line 245) | def test_single_photo_no_group(self):
method test_empty_queryset (line 258) | def test_empty_queryset(self):
method test_photos_without_timestamp (line 266) | def test_photos_without_timestamp(self):
class BurstDetectionIntegrationTestCase (line 283) | class BurstDetectionIntegrationTestCase(TestCase):
method setUp (line 286) | def setUp(self):
method test_detect_burst_creates_stack (line 289) | def test_detect_burst_creates_stack(self):
method test_case_insensitive_pattern_matching (line 316) | def test_case_insensitive_pattern_matching(self):
class FilenamePatternEdgeCasesTestCase (line 333) | class FilenamePatternEdgeCasesTestCase(TestCase):
method setUp (line 336) | def setUp(self):
method test_no_extension (line 339) | def test_no_extension(self):
method test_multiple_extensions (line 349) | def test_multiple_extensions(self):
method test_unicode_filename (line 358) | def test_unicode_filename(self):
method test_very_long_filename (line 367) | def test_very_long_filename(self):
method test_special_characters_in_path (line 377) | def test_special_characters_in_path(self):
method test_invalid_pattern_type (line 386) | def test_invalid_pattern_type(self):
FILE: api/tests/test_delete_photos.py
class DeletePhotosTest (line 9) | class DeletePhotosTest(TestCase):
method setUp (line 10) | def setUp(self):
method test_tag_my_photos_for_removal (line 16) | def test_tag_my_photos_for_removal(self):
method test_untag_my_photos_for_removal (line 32) | def test_untag_my_photos_for_removal(self):
method test_tag_photos_of_other_user_for_removal (line 51) | def test_tag_photos_of_other_user_for_removal(self):
method test_tag_for_removal_nonexistent_photo (line 69) | def test_tag_for_removal_nonexistent_photo(self, logger):
method test_delete_tagged_photos_for_removal (line 85) | def test_delete_tagged_photos_for_removal(self):
method test_delete_tagged_photos_of_other_user_for_removal (line 104) | def test_delete_tagged_photos_of_other_user_for_removal(self):
FILE: api/tests/test_detection_edge_cases.py
class BurstRuleParsingTestCase (line 32) | class BurstRuleParsingTestCase(TestCase):
method test_parse_valid_rule (line 35) | def test_parse_valid_rule(self):
method test_parse_rule_with_missing_optional_fields (line 51) | def test_parse_rule_with_missing_optional_fields(self):
method test_parse_disabled_rule (line 64) | def test_parse_disabled_rule(self):
method test_default_rules_are_valid (line 75) | def test_default_rules_are_valid(self):
class BurstFilenamePatternTestCase (line 84) | class BurstFilenamePatternTestCase(TestCase):
method test_burst_suffix_pattern (line 87) | def test_burst_suffix_pattern(self):
method test_sequence_suffix_pattern (line 100) | def test_sequence_suffix_pattern(self):
method test_bracketed_sequence_pattern (line 112) | def test_bracketed_sequence_pattern(self):
class UserBurstRulesTestCase (line 126) | class UserBurstRulesTestCase(TestCase):
method setUp (line 129) | def setUp(self):
method test_as_rules_with_default_rules (line 132) | def test_as_rules_with_default_rules(self):
method test_as_rules_with_custom_rules (line 140) | def test_as_rules_with_custom_rules(self):
method test_as_rules_with_empty_list (line 155) | def test_as_rules_with_empty_list(self):
method test_user_rules_stored_as_json (line 161) | def test_user_rules_stored_as_json(self):
class RawJpegDetectionEdgeCasesTestCase (line 182) | class RawJpegDetectionEdgeCasesTestCase(TestCase):
method setUp (line 189) | def setUp(self):
method test_detection_with_no_raw_photos (line 192) | def test_detection_with_no_raw_photos(self):
method test_detection_with_raw_no_matching_jpeg (line 206) | def test_detection_with_raw_no_matching_jpeg(self):
method test_detection_case_insensitive_extensions (line 226) | def test_detection_case_insensitive_extensions(self):
method test_detection_with_photo_no_main_file (line 247) | def test_detection_with_photo_no_main_file(self):
method test_detection_clears_existing_stacks (line 261) | def test_detection_clears_existing_stacks(self):
class BurstDetectionEdgeCasesTestCase (line 288) | class BurstDetectionEdgeCasesTestCase(TestCase):
method setUp (line 291) | def setUp(self):
method test_detection_with_no_photos (line 294) | def test_detection_with_no_photos(self):
method test_detection_with_all_rules_disabled (line 302) | def test_detection_with_all_rules_disabled(self):
method test_detection_with_trashed_photos_excluded (line 326) | def test_detection_with_trashed_photos_excluded(self):
class TimestampProximityRuleTestCase (line 347) | class TimestampProximityRuleTestCase(TestCase):
method setUp (line 350) | def setUp(self):
method test_photos_within_threshold_grouped (line 353) | def test_photos_within_threshold_grouped(self):
method test_photos_beyond_threshold_not_grouped (line 389) | def test_photos_beyond_threshold_not_grouped(self):
method test_photos_without_timestamp_skipped (line 421) | def test_photos_without_timestamp_skipped(self):
class LivePhotoDetectionEdgeCasesTestCase (line 450) | class LivePhotoDetectionEdgeCasesTestCase(TestCase):
method setUp (line 457) | def setUp(self):
method test_detection_with_no_live_photos (line 460) | def test_detection_with_no_live_photos(self):
class DetectionProgressCallbackTestCase (line 473) | class DetectionProgressCallbackTestCase(TestCase):
method setUp (line 476) | def setUp(self):
method test_raw_jpeg_detection_calls_progress (line 480) | def test_raw_jpeg_detection_calls_progress(self):
method test_burst_detection_calls_progress (line 504) | def test_burst_detection_calls_progress(self):
class BatchDetectionEdgeCasesTestCase (line 524) | class BatchDetectionEdgeCasesTestCase(TestCase):
method setUp (line 527) | def setUp(self):
method test_batch_detection_all_types (line 530) | def test_batch_detection_all_types(self):
method test_batch_detection_none_enabled (line 549) | def test_batch_detection_none_enabled(self):
method test_batch_detection_with_null_options (line 568) | def test_batch_detection_with_null_options(self):
method test_batch_detection_with_empty_options (line 581) | def test_batch_detection_with_empty_options(self):
class MultiUserDetectionIsolationTestCase (line 595) | class MultiUserDetectionIsolationTestCase(TestCase):
method setUp (line 598) | def setUp(self):
method test_detection_only_affects_own_photos (line 602) | def test_detection_only_affects_own_photos(self):
method test_clearing_stacks_only_affects_own (line 618) | def test_clearing_stacks_only_affects_own(self):
FILE: api/tests/test_directory_watcher_fix.py
class DirectoryWatcherFixTest (line 8) | class DirectoryWatcherFixTest(TestCase):
method setUp (line 9) | def setUp(self):
method test_generate_tags_query_works (line 12) | def test_generate_tags_query_works(self):
method test_generate_tags_query_excludes_photos_with_places365 (line 41) | def test_generate_tags_query_excludes_photos_with_places365(self):
FILE: api/tests/test_dirtree.py
class DirTreeTest (line 9) | class DirTreeTest(TestCase):
method setUp (line 10) | def setUp(self):
method test_admin_should_allow_to_retrieve_dirtree (line 17) | def test_admin_should_allow_to_retrieve_dirtree(self):
method test_should_retrieve_dir_listing_by_path (line 22) | def test_should_retrieve_dir_listing_by_path(self):
method test_should_fail_when_listing_with_invalid_path (line 27) | def test_should_fail_when_listing_with_invalid_path(self):
method test_children_list_should_be_alphabetical_case_insensitive (line 36) | def test_children_list_should_be_alphabetical_case_insensitive(self):
method test_regular_user_is_not_allowed_to_retrieve_dirtree (line 53) | def test_regular_user_is_not_allowed_to_retrieve_dirtree(self):
method test_anonymous_user_is_not_allower_to_retrieve_dirtree (line 58) | def test_anonymous_user_is_not_allower_to_retrieve_dirtree(self):
FILE: api/tests/test_duplicate_api_edge_cases.py
class DuplicateResolveEdgeCasesTestCase (line 21) | class DuplicateResolveEdgeCasesTestCase(TestCase):
method setUp (line 24) | def setUp(self):
method test_resolve_already_resolved_duplicate (line 29) | def test_resolve_already_resolved_duplicate(self):
method test_resolve_with_photo_already_trashed (line 54) | def test_resolve_with_photo_already_trashed(self):
method test_resolve_with_trash_others_false (line 77) | def test_resolve_with_trash_others_false(self):
method test_resolve_with_nonexistent_photo_hash (line 109) | def test_resolve_with_nonexistent_photo_hash(self):
method test_resolve_empty_request (line 128) | def test_resolve_empty_request(self):
class DuplicateRevertEdgeCasesTestCase (line 147) | class DuplicateRevertEdgeCasesTestCase(TestCase):
method setUp (line 150) | def setUp(self):
method test_revert_pending_duplicate (line 155) | def test_revert_pending_duplicate(self):
method test_revert_dismissed_duplicate (line 172) | def test_revert_dismissed_duplicate(self):
method test_revert_when_photos_permanently_deleted (line 189) | def test_revert_when_photos_permanently_deleted(self):
method test_revert_multiple_times (line 220) | def test_revert_multiple_times(self):
class DuplicateDeleteEdgeCasesTestCase (line 243) | class DuplicateDeleteEdgeCasesTestCase(TestCase):
method setUp (line 250) | def setUp(self):
method test_delete_duplicate_unlinks_all_photos (line 255) | def test_delete_duplicate_unlinks_all_photos(self):
method test_delete_duplicate_with_many_photos (line 282) | def test_delete_duplicate_with_many_photos(self):
method test_delete_resolved_duplicate (line 298) | def test_delete_resolved_duplicate(self):
method test_delete_nonexistent_duplicate (line 316) | def test_delete_nonexistent_duplicate(self):
method test_delete_other_users_duplicate (line 321) | def test_delete_other_users_duplicate(self):
class DuplicateDetailEdgeCasesTestCase (line 339) | class DuplicateDetailEdgeCasesTestCase(TestCase):
method setUp (line 342) | def setUp(self):
method test_detail_with_photos_without_metadata (line 347) | def test_detail_with_photos_without_metadata(self):
method test_detail_with_photos_without_main_file (line 370) | def test_detail_with_photos_without_main_file(self):
method test_detail_with_deleted_kept_photo (line 388) | def test_detail_with_deleted_kept_photo(self):
class DuplicateListEdgeCasesTestCase (line 417) | class DuplicateListEdgeCasesTestCase(TestCase):
method setUp (line 420) | def setUp(self):
method test_list_excludes_single_photo_duplicates (line 425) | def test_list_excludes_single_photo_duplicates(self):
method test_list_with_kept_photo_deleted (line 441) | def test_list_with_kept_photo_deleted(self):
method test_list_pagination_edge_cases (line 468) | def test_list_pagination_edge_cases(self):
class DuplicateAutoSelectBestEdgeCasesTestCase (line 493) | class DuplicateAutoSelectBestEdgeCasesTestCase(TestCase):
method setUp (line 496) | def setUp(self):
method test_auto_select_with_no_photos (line 499) | def test_auto_select_with_no_photos(self):
method test_auto_select_exact_copy_all_null_paths (line 509) | def test_auto_select_exact_copy_all_null_paths(self):
method test_auto_select_visual_duplicate_no_metadata (line 528) | def test_auto_select_visual_duplicate_no_metadata(self):
class DuplicateDismissEdgeCasesTestCase (line 547) | class DuplicateDismissEdgeCasesTestCase(TestCase):
method setUp (line 550) | def setUp(self):
method test_dismiss_already_dismissed (line 555) | def test_dismiss_already_dismissed(self):
method test_dismiss_resolved_duplicate (line 573) | def test_dismiss_resolved_duplicate(self):
class DuplicateStatsEdgeCasesTestCase (line 596) | class DuplicateStatsEdgeCasesTestCase(TestCase):
method setUp (line 599) | def setUp(self):
method test_stats_with_no_duplicates (line 604) | def test_stats_with_no_duplicates(self):
method test_stats_counts_by_type (line 614) | def test_stats_counts_by_type(self):
method test_stats_counts_by_status (line 642) | def test_stats_counts_by_status(self):
method test_stats_potential_savings (line 677) | def test_stats_potential_savings(self):
method test_stats_photos_in_duplicates (line 696) | def test_stats_photos_in_duplicates(self):
method test_stats_other_users_not_included (line 720) | def test_stats_other_users_not_included(self):
FILE: api/tests/test_duplicate_detection.py
class DuplicateModelTest (line 20) | class DuplicateModelTest(TestCase):
method setUp (line 23) | def setUp(self):
method test_create_duplicate_group (line 27) | def test_create_duplicate_group(self):
method test_create_duplicate_with_less_than_2_photos_returns_none (line 39) | def test_create_duplicate_with_less_than_2_photos_returns_none(self):
method test_create_or_merge_creates_new_duplicate (line 48) | def test_create_or_merge_creates_new_duplicate(self):
method test_create_or_merge_merges_existing (line 60) | def test_create_or_merge_merges_existing(self):
method test_resolve_duplicate_trashes_others (line 78) | def test_resolve_duplicate_trashes_others(self):
method test_resolve_duplicate_without_trashing (line 98) | def test_resolve_duplicate_without_trashing(self):
method test_dismiss_duplicate (line 115) | def test_dismiss_duplicate(self):
method test_revert_resolved_duplicate (line 129) | def test_revert_resolved_duplicate(self):
method test_revert_non_resolved_duplicate_returns_zero (line 152) | def test_revert_non_resolved_duplicate_returns_zero(self):
method test_auto_select_best_photo_exact_copy (line 162) | def test_auto_select_best_photo_exact_copy(self):
method test_calculate_potential_savings (line 172) | def test_calculate_potential_savings(self):
method test_merge_duplicates (line 190) | def test_merge_duplicates(self):
class DuplicateAPITest (line 215) | class DuplicateAPITest(TestCase):
method setUp (line 218) | def setUp(self):
method test_list_duplicates_empty (line 225) | def test_list_duplicates_empty(self):
method test_list_duplicates_with_results (line 233) | def test_list_duplicates_with_results(self):
method test_list_duplicates_excludes_other_users (line 245) | def test_list_duplicates_excludes_other_users(self):
method test_list_duplicates_filter_by_type (line 259) | def test_list_duplicates_filter_by_type(self):
method test_list_duplicates_filter_by_status (line 278) | def test_list_duplicates_filter_by_status(self):
method test_get_duplicate_detail (line 297) | def test_get_duplicate_detail(self):
method test_get_duplicate_detail_not_found (line 310) | def test_get_duplicate_detail_not_found(self):
method test_get_duplicate_detail_wrong_user (line 316) | def test_get_duplicate_detail_wrong_user(self):
method test_resolve_duplicate (line 327) | def test_resolve_duplicate(self):
method test_resolve_duplicate_missing_photo_hash (line 348) | def test_resolve_duplicate_missing_photo_hash(self):
method test_resolve_duplicate_invalid_photo (line 362) | def test_resolve_duplicate_invalid_photo(self):
method test_dismiss_duplicate (line 380) | def test_dismiss_duplicate(self):
method test_revert_duplicate (line 392) | def test_revert_duplicate(self):
method test_revert_non_resolved_duplicate_fails (line 407) | def test_revert_non_resolved_duplicate_fails(self):
method test_delete_duplicate (line 417) | def test_delete_duplicate(self):
method test_get_duplicate_stats (line 435) | def test_get_duplicate_stats(self):
method test_detect_duplicates (line 450) | def test_detect_duplicates(self):
class DuplicateEdgeCasesTest (line 465) | class DuplicateEdgeCasesTest(TestCase):
method setUp (line 468) | def setUp(self):
method test_duplicate_with_single_photo_excluded_from_list (line 474) | def test_duplicate_with_single_photo_excluded_from_list(self):
method test_resolve_already_resolved_duplicate (line 487) | def test_resolve_already_resolved_duplicate(self):
method test_photo_in_multiple_duplicate_groups (line 506) | def test_photo_in_multiple_duplicate_groups(self):
method test_delete_photo_removes_from_duplicate (line 523) | def test_delete_photo_removes_from_duplicate(self):
method test_pagination_works_correctly (line 539) | def test_pagination_works_correctly(self):
method test_invalid_uuid_format (line 563) | def test_invalid_uuid_format(self):
method test_concurrent_resolve_same_duplicate (line 575) | def test_concurrent_resolve_same_duplicate(self):
class BKTreeTest (line 610) | class BKTreeTest(TestCase):
method test_bk_tree_basic_operations (line 613) | def test_bk_tree_basic_operations(self):
method test_bk_tree_empty_search (line 630) | def test_bk_tree_empty_search(self):
class UnionFindTest (line 640) | class UnionFindTest(TestCase):
method test_union_find_basic (line 643) | def test_union_find_basic(self):
method test_union_find_get_groups (line 655) | def test_union_find_get_groups(self):
method test_union_find_single_elements_not_returned (line 670) | def test_union_find_single_elements_not_returned(self):
FILE: api/tests/test_duplicate_detection_logic.py
class BKTreeTestCase (line 31) | class BKTreeTestCase(TestCase):
method setUp (line 34) | def setUp(self):
method test_empty_tree_search_returns_empty (line 40) | def test_empty_tree_search_returns_empty(self):
method test_add_single_item (line 45) | def test_add_single_item(self):
method test_add_multiple_items (line 54) | def test_add_multiple_items(self):
method test_search_exact_match (line 62) | def test_search_exact_match(self):
method test_search_within_threshold (line 73) | def test_search_within_threshold(self):
method test_search_threshold_excludes_distant (line 87) | def test_search_threshold_excludes_distant(self):
method test_search_returns_correct_distances (line 97) | def test_search_returns_correct_distances(self):
method test_add_duplicate_hash (line 110) | def test_add_duplicate_hash(self):
method test_large_tree_performance (line 122) | def test_large_tree_performance(self):
class UnionFindTestCase (line 136) | class UnionFindTestCase(TestCase):
method test_initial_find_creates_entry (line 139) | def test_initial_find_creates_entry(self):
method test_find_same_element_returns_itself (line 148) | def test_find_same_element_returns_itself(self):
method test_union_links_elements (line 157) | def test_union_links_elements(self):
method test_union_multiple_elements (line 165) | def test_union_multiple_elements(self):
method test_union_separate_groups (line 179) | def test_union_separate_groups(self):
method test_get_groups_returns_groups (line 190) | def test_get_groups_returns_groups(self):
method test_get_groups_excludes_singletons (line 207) | def test_get_groups_excludes_singletons(self):
method test_path_compression (line 219) | def test_path_compression(self):
class DetectExactCopiesTestCase (line 233) | class DetectExactCopiesTestCase(TestCase):
method setUp (line 236) | def setUp(self):
method _create_photo_with_hash (line 239) | def _create_photo_with_hash(self, image_hash, file_hash=None, **kwargs):
method test_no_duplicates_returns_zero (line 256) | def test_no_duplicates_returns_zero(self):
method test_detects_duplicate_image_hash (line 266) | def test_detects_duplicate_image_hash(self):
method test_detects_duplicate_file_hash (line 281) | def test_detects_duplicate_file_hash(self):
method test_skips_hidden_photos (line 294) | def test_skips_hidden_photos(self):
method test_skips_trashed_photos (line 304) | def test_skips_trashed_photos(self):
method test_multiple_duplicate_groups (line 313) | def test_multiple_duplicate_groups(self):
method test_progress_callback_called (line 328) | def test_progress_callback_called(self):
class DetectVisualDuplicatesTestCase (line 343) | class DetectVisualDuplicatesTestCase(TestCase):
method setUp (line 346) | def setUp(self):
method _create_photo_with_phash (line 349) | def _create_photo_with_phash(self, perceptual_hash, **kwargs):
method test_no_photos_returns_zero (line 356) | def test_no_photos_returns_zero(self):
method test_single_photo_returns_zero (line 362) | def test_single_photo_returns_zero(self):
method test_detects_identical_phash (line 370) | def test_detects_identical_phash(self):
method test_detects_similar_phash_within_threshold (line 379) | def test_detects_similar_phash_within_threshold(self):
method test_threshold_excludes_dissimilar (line 389) | def test_threshold_excludes_dissimilar(self):
method test_skips_photos_without_phash (line 398) | def test_skips_photos_without_phash(self):
method test_skips_hidden_photos (line 409) | def test_skips_hidden_photos(self):
method test_creates_visual_duplicate_type (line 418) | def test_creates_visual_duplicate_type(self):
class BatchDetectDuplicatesTestCase (line 429) | class BatchDetectDuplicatesTestCase(TestCase):
method setUp (line 432) | def setUp(self):
method test_calls_both_detectors_by_default (line 437) | def test_calls_both_detectors_by_default(self, mock_visual, mock_exact):
method test_respects_options (line 449) | def test_respects_options(self, mock_visual, mock_exact):
method test_passes_visual_threshold (line 464) | def test_passes_visual_threshold(self, mock_visual, mock_exact):
method test_creates_job (line 479) | def test_creates_job(self, mock_visual, mock_exact):
method test_clear_pending_option (line 494) | def test_clear_pending_option(self, mock_visual, mock_exact):
method test_handles_exception (line 515) | def test_handles_exception(self, mock_exact):
class EdgeCasesTestCase (line 527) | class EdgeCasesTestCase(TestCase):
method setUp (line 530) | def setUp(self):
method test_empty_photo_library (line 533) | def test_empty_photo_library(self):
method test_photo_without_files (line 541) | def test_photo_without_files(self):
method test_photo_with_short_file_hash (line 551) | def test_photo_with_short_file_hash(self):
method test_different_users_isolated (line 566) | def test_different_users_isolated(self):
method test_metadata_files_excluded (line 584) | def test_metadata_files_excluded(self):
method test_bktree_with_empty_hash (line 601) | def test_bktree_with_empty_hash(self):
method test_union_find_with_same_element_union (line 615) | def test_union_find_with_same_element_union(self):
method test_three_way_duplicate (line 625) | def test_three_way_duplicate(self):
method test_transitive_duplicates_merged (line 640) | def test_transitive_duplicates_merged(self):
FILE: api/tests/test_duplicate_filtering.py
class DuplicateFilterByTypeTestCase (line 19) | class DuplicateFilterByTypeTestCase(TestCase):
method setUp (line 22) | def setUp(self):
method test_filter_exact_copies (line 52) | def test_filter_exact_copies(self):
method test_filter_visual_duplicates (line 63) | def test_filter_visual_duplicates(self):
method test_filter_invalid_type (line 76) | def test_filter_invalid_type(self):
method test_no_filter_returns_all (line 84) | def test_no_filter_returns_all(self):
class DuplicateFilterByStatusTestCase (line 92) | class DuplicateFilterByStatusTestCase(TestCase):
method setUp (line 95) | def setUp(self):
method test_filter_pending (line 127) | def test_filter_pending(self):
method test_filter_resolved (line 140) | def test_filter_resolved(self):
method test_filter_dismissed (line 149) | def test_filter_dismissed(self):
method test_combined_type_and_status_filter (line 158) | def test_combined_type_and_status_filter(self):
class PhotosWithoutPerceptualHashTestCase (line 168) | class PhotosWithoutPerceptualHashTestCase(TestCase):
method setUp (line 171) | def setUp(self):
method test_detection_handles_null_perceptual_hash (line 176) | def test_detection_handles_null_perceptual_hash(self):
method test_visual_duplicate_detection_endpoint (line 195) | def test_visual_duplicate_detection_endpoint(self):
class DuplicateDetectionJobTestCase (line 217) | class DuplicateDetectionJobTestCase(TestCase):
method setUp (line 220) | def setUp(self):
method test_detect_with_all_options (line 225) | def test_detect_with_all_options(self):
method test_detect_exact_only (line 240) | def test_detect_exact_only(self):
method test_detect_visual_only (line 250) | def test_detect_visual_only(self):
method test_detect_nothing_returns_error (line 260) | def test_detect_nothing_returns_error(self):
method test_detect_with_clear_pending (line 271) | def test_detect_with_clear_pending(self):
class VisualThresholdTestCase (line 294) | class VisualThresholdTestCase(TestCase):
method setUp (line 297) | def setUp(self):
method test_strict_threshold (line 302) | def test_strict_threshold(self):
method test_loose_threshold (line 312) | def test_loose_threshold(self):
method test_zero_threshold (line 322) | def test_zero_threshold(self):
method test_negative_threshold_handled (line 332) | def test_negative_threshold_handled(self):
class DuplicateListSortingTestCase (line 344) | class DuplicateListSortingTestCase(TestCase):
method setUp (line 347) | def setUp(self):
method test_default_sorting (line 368) | def test_default_sorting(self):
method test_sort_by_created_at (line 375) | def test_sort_by_created_at(self):
class DuplicateBulkActionsTestCase (line 382) | class DuplicateBulkActionsTestCase(TestCase):
method setUp (line 385) | def setUp(self):
method test_bulk_dismiss (line 390) | def test_bulk_dismiss(self):
class EmptyDuplicateGroupTestCase (line 414) | class EmptyDuplicateGroupTestCase(TestCase):
method setUp (line 417) | def setUp(self):
method test_duplicate_with_no_photos (line 422) | def test_duplicate_with_no_photos(self):
method test_duplicate_with_one_photo (line 435) | def test_duplicate_with_one_photo(self):
method test_resolve_single_photo_duplicate (line 450) | def test_resolve_single_photo_duplicate(self):
FILE: api/tests/test_edge_cases_integration.py
class PhotoNoExifDataTestCase (line 21) | class PhotoNoExifDataTestCase(TestCase):
method setUp (line 24) | def setUp(self):
method test_photo_without_exif_timestamp (line 27) | def test_photo_without_exif_timestamp(self):
method test_photo_without_gps_data (line 37) | def test_photo_without_gps_data(self):
method test_photo_without_perceptual_hash (line 47) | def test_photo_without_perceptual_hash(self):
method test_stack_with_no_exif_photos (line 57) | def test_stack_with_no_exif_photos(self):
method test_duplicate_with_no_metadata_photos (line 77) | def test_duplicate_with_no_metadata_photos(self):
method test_burst_detection_no_timestamps (line 96) | def test_burst_detection_no_timestamps(self):
class MissingFileTestCase (line 109) | class MissingFileTestCase(TestCase):
method setUp (line 112) | def setUp(self):
method test_photo_with_null_main_file (line 115) | def test_photo_with_null_main_file(self):
method test_stack_photos_with_missing_metadata (line 124) | def test_stack_photos_with_missing_metadata(self):
class ConcurrentDetectionTestCase (line 141) | class ConcurrentDetectionTestCase(TransactionTestCase):
method setUp (line 144) | def setUp(self):
method test_concurrent_duplicate_detection_requests (line 149) | def test_concurrent_duplicate_detection_requests(self):
method test_concurrent_stack_detection_requests (line 176) | def test_concurrent_stack_detection_requests(self):
class EmptyDataTestCase (line 193) | class EmptyDataTestCase(APITestCase):
method setUp (line 196) | def setUp(self):
method test_duplicate_list_empty (line 201) | def test_duplicate_list_empty(self):
method test_stack_list_empty (line 207) | def test_stack_list_empty(self):
method test_duplicate_stats_empty (line 213) | def test_duplicate_stats_empty(self):
method test_stack_stats_empty (line 218) | def test_stack_stats_empty(self):
method test_detection_with_no_photos (line 223) | def test_detection_with_no_photos(self):
method test_stack_detection_with_no_photos (line 229) | def test_stack_detection_with_no_photos(self):
class InvalidDataTestCase (line 236) | class InvalidDataTestCase(APITestCase):
method setUp (line 239) | def setUp(self):
method test_resolve_with_invalid_photo_id (line 244) | def test_resolve_with_invalid_photo_id(self):
method test_add_to_stack_with_invalid_photo_ids (line 260) | def test_add_to_stack_with_invalid_photo_ids(self):
method test_set_primary_with_invalid_photo_id (line 277) | def test_set_primary_with_invalid_photo_id(self):
method test_detection_with_invalid_options (line 293) | def test_detection_with_invalid_options(self):
class SinglePhotoGroupTestCase (line 304) | class SinglePhotoGroupTestCase(TestCase):
method setUp (line 307) | def setUp(self):
method test_duplicate_with_single_photo_deleted (line 310) | def test_duplicate_with_single_photo_deleted(self):
method test_stack_with_single_photo (line 325) | def test_stack_with_single_photo(self):
method test_auto_select_with_single_photo (line 337) | def test_auto_select_with_single_photo(self):
class PhotoDeletionEdgeCasesTestCase (line 353) | class PhotoDeletionEdgeCasesTestCase(TestCase):
method setUp (line 356) | def setUp(self):
method test_delete_photo_in_multiple_stacks (line 359) | def test_delete_photo_in_multiple_stacks(self):
method test_delete_photo_in_multiple_duplicate_groups (line 386) | def test_delete_photo_in_multiple_duplicate_groups(self):
method test_delete_primary_photo_from_stack (line 410) | def test_delete_primary_photo_from_stack(self):
class MetadataEdgeCasesTestCase (line 430) | class MetadataEdgeCasesTestCase(TestCase):
method setUp (line 433) | def setUp(self):
method test_photo_with_extreme_dimensions (line 436) | def test_photo_with_extreme_dimensions(self):
method test_photo_with_zero_dimensions (line 449) | def test_photo_with_zero_dimensions(self):
method test_duplicate_savings_with_zero_size (line 461) | def test_duplicate_savings_with_zero_size(self):
FILE: api/tests/test_edit_photo_details.py
class EditPhotoDetailsTest (line 9) | class EditPhotoDetailsTest(TestCase):
method setUp (line 10) | def setUp(self):
method test_should_update_timestamp (line 17) | def test_should_update_timestamp(self, extract_date_time_from_exif_mock):
method test_should_not_update_other_properties (line 41) | def test_should_not_update_other_properties(self, extract_date_time_fr...
FILE: api/tests/test_face_extractor.py
class FaceExtractorTest (line 10) | class FaceExtractorTest(TestCase):
method test_extract_from_dlib_handles_exception (line 14) | def test_extract_from_dlib_handles_exception(self, mock_get_face_locat...
method test_extract_from_dlib_success (line 34) | def test_extract_from_dlib_success(self, mock_get_face_locations):
method test_extract_prefers_exif (line 57) | def test_extract_prefers_exif(self, mock_dlib, mock_exif):
method test_extract_fallback_to_dlib (line 78) | def test_extract_fallback_to_dlib(self, mock_dlib, mock_exif):
FILE: api/tests/test_face_writeback.py
class TestThumbnailCoordsToNormalized (line 20) | class TestThumbnailCoordsToNormalized(TestCase):
method test_basic_conversion (line 21) | def test_basic_conversion(self):
method test_corner_face (line 38) | def test_corner_face(self):
class TestReverseOrientationTransform (line 54) | class TestReverseOrientationTransform(TestCase):
method test_identity_for_normal_orientation (line 55) | def test_identity_for_normal_orientation(self):
method test_identity_for_none_orientation (line 65) | def test_identity_for_none_orientation(self):
method test_round_trip_rotate_90_cw (line 73) | def test_round_trip_rotate_90_cw(self):
method test_round_trip_mirror_horizontal (line 77) | def test_round_trip_mirror_horizontal(self):
method test_round_trip_rotate_180 (line 80) | def test_round_trip_rotate_180(self):
method test_round_trip_mirror_vertical (line 83) | def test_round_trip_mirror_vertical(self):
method test_round_trip_rotate_270_cw (line 86) | def test_round_trip_rotate_270_cw(self):
method test_round_trip_mirror_horizontal_rotate_90_cw (line 89) | def test_round_trip_mirror_horizontal_rotate_90_cw(self):
method _test_round_trip (line 92) | def _test_round_trip(self, orientation):
class TestBuildFaceRegionExiftoolArgs (line 145) | class TestBuildFaceRegionExiftoolArgs(TestCase):
method test_single_face (line 146) | def test_single_face(self):
method test_multiple_faces (line 157) | def test_multiple_faces(self):
method test_special_characters_in_name (line 170) | def test_special_characters_in_name(self):
method test_escape_braces_and_equals (line 178) | def test_escape_braces_and_equals(self):
method test_applied_to_dimensions (line 185) | def test_applied_to_dimensions(self):
method test_no_applied_to_dimensions_when_missing (line 192) | def test_no_applied_to_dimensions_when_missing(self):
method test_subject_keywords_for_named_faces (line 199) | def test_subject_keywords_for_named_faces(self):
method test_no_subject_keywords_when_all_unnamed (line 210) | def test_no_subject_keywords_when_all_unnamed(self):
class TestRoundTripCoordinates (line 219) | class TestRoundTripCoordinates(TestCase):
method test_round_trip_no_orientation (line 220) | def test_round_trip_no_orientation(self):
class TestGetFaceRegionTags (line 251) | class TestGetFaceRegionTags(TestCase):
method setUp (line 252) | def setUp(self):
method test_returns_tags_for_labeled_faces (line 257) | def test_returns_tags_for_labeled_faces(self, mock_pil_open, mock_get_...
method test_returns_all_faces (line 294) | def test_returns_all_faces(self, mock_pil_open, mock_get_metadata):
method test_unlabeled_faces_written_with_empty_name (line 326) | def test_unlabeled_faces_written_with_empty_name(
method test_faces_with_no_person_written_with_empty_name (line 360) | def test_faces_with_no_person_written_with_empty_name(
method test_mixed_labeled_and_unlabeled_faces (line 389) | def test_mixed_labeled_and_unlabeled_faces(
method test_skips_deleted_faces (line 434) | def test_skips_deleted_faces(self, mock_pil_open, mock_get_metadata):
class TestSaveMetadataIntegration (line 475) | class TestSaveMetadataIntegration(TestCase):
method setUp (line 476) | def setUp(self):
method test_save_metadata_with_face_tags (line 482) | def test_save_metadata_with_face_tags(
method test_save_metadata_default_does_not_write_face_tags (line 514) | def test_save_metadata_default_does_not_write_face_tags(self, mock_wri...
method test_save_metadata_combined_types (line 541) | def test_save_metadata_combined_types(
FILE: api/tests/test_favorite_photos.py
class FavoritePhotosTest (line 9) | class FavoritePhotosTest(TestCase):
method setUp (line 10) | def setUp(self):
method test_tag_my_photos_as_favorite (line 16) | def test_tag_my_photos_as_favorite(self):
method test_untag_my_photos_as_favorite (line 32) | def test_untag_my_photos_as_favorite(self):
method test_tag_photos_of_other_user_as_favorite (line 51) | def test_tag_photos_of_other_user_as_favorite(self):
method test_tag_nonexistent_photo_as_favorite (line 69) | def test_tag_nonexistent_photo_as_favorite(self, logger):
FILE: api/tests/test_file_model.py
function _ensure_stub_modules (line 9) | def _ensure_stub_modules():
function _load_file_module (line 132) | def _load_file_module():
class TestIsVideo (line 147) | class TestIsVideo(unittest.TestCase):
method setUpClass (line 149) | def setUpClass(cls):
method test_is_video_returns_false_when_magic_raises (line 157) | def test_is_video_returns_false_when_magic_raises(self):
FILE: api/tests/test_file_path_uniqueness.py
class FilePathUniqueConstraintTestCase (line 22) | class FilePathUniqueConstraintTestCase(TestCase):
method setUp (line 25) | def setUp(self):
method test_unique_constraint_prevents_duplicate_paths (line 28) | def test_unique_constraint_prevents_duplicate_paths(self):
method test_unique_constraint_allows_different_paths (line 48) | def test_unique_constraint_allows_different_paths(self):
method test_empty_paths_are_unique (line 64) | def test_empty_paths_are_unique(self):
class FileCreateMethodTestCase (line 83) | class FileCreateMethodTestCase(TestCase):
method setUp (line 86) | def setUp(self):
method tearDown (line 91) | def tearDown(self):
method _create_test_file (line 96) | def _create_test_file(self, filename, content=b"test content"):
method test_create_returns_existing_file_for_same_path (line 103) | def test_create_returns_existing_file_for_same_path(self):
method test_create_creates_new_file_for_different_path (line 120) | def test_create_creates_new_file_for_different_path(self):
method test_create_returns_existing_even_if_content_changed (line 132) | def test_create_returns_existing_even_if_content_changed(self):
method test_create_determines_correct_file_type (line 151) | def test_create_determines_correct_file_type(self):
class MigrationDeduplicationTestCase (line 169) | class MigrationDeduplicationTestCase(TestCase):
method setUp (line 172) | def setUp(self):
method test_deduplication_prefers_non_missing_file (line 175) | def test_deduplication_prefers_non_missing_file(self):
method test_deduplication_keeps_file_with_more_photos (line 204) | def test_deduplication_keeps_file_with_more_photos(self):
class ConcurrentScanTestCase (line 236) | class ConcurrentScanTestCase(TransactionTestCase):
method setUp (line 239) | def setUp(self):
method tearDown (line 243) | def tearDown(self):
method _create_test_file (line 247) | def _create_test_file(self, filename, content=b"test content"):
method test_concurrent_create_same_path_no_duplicates (line 254) | def test_concurrent_create_same_path_no_duplicates(self):
method test_concurrent_create_different_paths_succeeds (line 289) | def test_concurrent_create_different_paths_succeeds(self):
class FilePathLookupTestCase (line 317) | class FilePathLookupTestCase(TestCase):
method setUp (line 320) | def setUp(self):
method test_filter_by_path_is_exact (line 323) | def test_filter_by_path_is_exact(self):
method test_photo_files_path_lookup (line 341) | def test_photo_files_path_lookup(self):
class PhotoFileAssociationTestCase (line 362) | class PhotoFileAssociationTestCase(TestCase):
method setUp (line 365) | def setUp(self):
method test_multiple_photos_can_share_same_file (line 368) | def test_multiple_photos_can_share_same_file(self):
method test_photo_with_multiple_file_variants (line 391) | def test_photo_with_multiple_file_variants(self):
FILE: api/tests/test_geocode.py
class MapboxLocation (line 33) | class MapboxLocation:
method __init__ (line 34) | def __init__(self, raw):
class TomTomLocation (line 39) | class TomTomLocation:
method __init__ (line 40) | def __init__(self, raw):
class NominatimLocation (line 45) | class NominatimLocation:
method __init__ (line 46) | def __init__(self, raw):
class OpenCageLocation (line 51) | class OpenCageLocation:
method __init__ (line 52) | def __init__(self, raw):
class TestGeocodeParsers (line 57) | class TestGeocodeParsers(TestCase):
method test_mapbox_parser (line 58) | def test_mapbox_parser(self):
method test_tomtom_parser (line 64) | def test_tomtom_parser(self):
method test_nominatim_parser (line 70) | def test_nominatim_parser(self):
method test_opencage_parser (line 76) | def test_opencage_parser(self):
class FakeLocation (line 83) | class FakeLocation:
method __init__ (line 87) | def __init__(self, location):
class FakeProvider (line 92) | class FakeProvider:
method __init__ (line 93) | def __init__(self, response):
method reverse (line 96) | def reverse(self, _):
function fake_geocoder (line 100) | def fake_geocoder(response):
class TestGeocoder (line 104) | class TestGeocoder(TestCase):
method test_reverse_geocode (line 107) | def test_reverse_geocode(self, get_geocoder_for_service_mock):
method test_reverse_geocode_no_api_key (line 114) | def test_reverse_geocode_no_api_key(self):
FILE: api/tests/test_get_faces.py
class IncompleteFacesTest (line 13) | class IncompleteFacesTest(TestCase):
method setUp (line 14) | def setUp(self):
method test_if_classification_person_is_ignored_if_below_threshold (line 20) | def test_if_classification_person_is_ignored_if_below_threshold(self):
method test_if_min_confidence_and_prob_are_compared_correctly (line 44) | def test_if_min_confidence_and_prob_are_compared_correctly(
method test_incomplete_faces_with_clustering (line 77) | def test_incomplete_faces_with_clustering(self):
method test_no_inferred_faces (line 108) | def test_no_inferred_faces(self):
class FaceListViewTest (line 123) | class FaceListViewTest(TestCase):
method setUp (line 124) | def setUp(self):
method test_min_confidence_when_classification (line 130) | def test_min_confidence_when_classification(self):
method test_min_confidence_but_for_unknown_other (line 156) | def test_min_confidence_but_for_unknown_other(self):
method test_min_confidence_when_clustering (line 178) | def test_min_confidence_when_clustering(self):
method test_min_confidence_when_clustering_and_unknown (line 200) | def test_min_confidence_when_clustering_and_unknown(self):
method test_face_list_classification_order_by_probability (line 220) | def test_face_list_classification_order_by_probability(self):
method test_face_list_clustering_order_by_probability (line 249) | def test_face_list_clustering_order_by_probability(self):
method test_face_list_order_by_date (line 274) | def test_face_list_order_by_date(self):
FILE: api/tests/test_hide_photos.py
class FavoritePhotosTest (line 9) | class FavoritePhotosTest(TestCase):
method setUp (line 10) | def setUp(self):
method test_hide_my_photos (line 16) | def test_hide_my_photos(self):
method test_untag_my_photos_as_favorite (line 32) | def test_untag_my_photos_as_favorite(self):
method test_tag_photos_of_other_user_as_favorite (line 49) | def test_tag_photos_of_other_user_as_favorite(self):
method test_tag_nonexistent_photo_as_favorite (line 67) | def test_tag_nonexistent_photo_as_favorite(self, logger):
FILE: api/tests/test_im2txt.py
function test_coco (line 18) | def test_coco(testcase, device="cpu", model="im2txt"):
class Im2TxtBenchmark (line 90) | class Im2TxtBenchmark(TestCase):
method setUp (line 101) | def setUp(self) -> None:
method tearDown (line 139) | def tearDown(self) -> None:
method monitor_ram_usage (line 161) | def monitor_ram_usage(self, process, ram_usages, duration):
method test_im2txt_cpu (line 170) | def test_im2txt_cpu(self):
method test_im2txt_gpu (line 182) | def test_im2txt_gpu(self):
method test_im2txt_cpu_100 (line 193) | def test_im2txt_cpu_100(self):
method test_im2txt_gpu_100 (line 205) | def test_im2txt_gpu_100(self):
method test_im2txt_coco_cpu (line 216) | def test_im2txt_coco_cpu(self):
method test_im2txt_coco_gpu (line 220) | def test_im2txt_coco_gpu(self):
method test_blip_coco_cpu (line 224) | def test_blip_coco_cpu(self):
method test_blip_coco_gpu (line 228) | def test_blip_coco_gpu(self):
FILE: api/tests/test_live_photo.py
class LocateGoogleEmbeddedVideoTestCase (line 40) | class LocateGoogleEmbeddedVideoTestCase(TestCase):
method test_finds_ftypmp42_signature (line 43) | def test_finds_ftypmp42_signature(self):
method test_finds_ftypisom_signature (line 51) | def test_finds_ftypisom_signature(self):
method test_finds_ftypiso2_signature (line 58) | def test_finds_ftypiso2_signature(self):
method test_returns_minus_one_when_not_found (line 65) | def test_returns_minus_one_when_not_found(self):
method test_empty_data (line 71) | def test_empty_data(self):
method test_finds_first_signature_if_multiple (line 76) | def test_finds_first_signature_if_multiple(self):
class LocateSamsungEmbeddedVideoTestCase (line 84) | class LocateSamsungEmbeddedVideoTestCase(TestCase):
method test_finds_samsung_marker (line 87) | def test_finds_samsung_marker(self):
method test_returns_minus_one_when_not_found (line 94) | def test_returns_minus_one_when_not_found(self):
method test_empty_data (line 100) | def test_empty_data(self):
class HasEmbeddedMotionVideoTestCase (line 106) | class HasEmbeddedMotionVideoTestCase(TestCase):
method setUp (line 109) | def setUp(self):
method tearDown (line 113) | def tearDown(self):
method test_returns_false_for_non_jpeg (line 119) | def test_returns_false_for_non_jpeg(self, mock_magic_class):
method test_returns_true_for_google_motion_photo (line 131) | def test_returns_true_for_google_motion_photo(self, mock_mmap, mock_op...
method test_returns_false_on_exception (line 154) | def test_returns_false_on_exception(self, mock_magic_class):
class FindAppleLivePhotoVideoTestCase (line 164) | class FindAppleLivePhotoVideoTestCase(TestCase):
method setUp (line 167) | def setUp(self):
method tearDown (line 171) | def tearDown(self):
method test_finds_lowercase_mov_companion (line 176) | def test_finds_lowercase_mov_companion(self):
method test_finds_uppercase_mov_companion (line 187) | def test_finds_uppercase_mov_companion(self):
method test_returns_none_when_no_companion (line 197) | def test_returns_none_when_no_companion(self):
method test_prefers_lowercase_mov (line 205) | def test_prefers_lowercase_mov(self):
method test_handles_different_image_extensions (line 218) | def test_handles_different_image_extensions(self):
class ExtractEmbeddedMotionVideoTestCase (line 234) | class ExtractEmbeddedMotionVideoTestCase(TestCase):
method setUp (line 237) | def setUp(self):
method tearDown (line 241) | def tearDown(self):
method test_extracts_google_motion_video (line 247) | def test_extracts_google_motion_video(self):
method test_returns_none_for_no_embedded_video (line 275) | def test_returns_none_for_no_embedded_video(self):
method test_returns_none_on_file_error (line 286) | def test_returns_none_on_file_error(self):
class DetectLivePhotoTestCase (line 294) | class DetectLivePhotoTestCase(TestCase):
method setUp (line 297) | def setUp(self):
method tearDown (line 302) | def tearDown(self):
method test_returns_none_for_photo_without_main_file (line 307) | def test_returns_none_for_photo_without_main_file(self):
method test_detects_embedded_motion_video (line 317) | def test_detects_embedded_motion_video(self, mock_create, mock_has_emb...
method test_detects_apple_live_photo (line 334) | def test_detects_apple_live_photo(self, mock_create, mock_find, mock_h...
method test_returns_none_for_regular_photo (line 351) | def test_returns_none_for_regular_photo(self, mock_find, mock_has_embe...
class CreateEmbeddedLivePhotoStackTestCase (line 363) | class CreateEmbeddedLivePhotoStackTestCase(TestCase):
method setUp (line 366) | def setUp(self):
method tearDown (line 371) | def tearDown(self):
method test_returns_none_if_feature_disabled (line 377) | def test_returns_none_if_feature_disabled(self):
method test_returns_none_if_extraction_fails (line 388) | def test_returns_none_if_extraction_fails(self, mock_extract):
method test_returns_existing_stack_if_present (line 402) | def test_returns_existing_stack_if_present(self, mock_file_create, moc...
class CreateAppleLivePhotoStackTestCase (line 420) | class CreateAppleLivePhotoStackTestCase(TestCase):
method setUp (line 423) | def setUp(self):
method tearDown (line 449) | def tearDown(self):
method test_creates_new_stack_for_apple_live_photo (line 454) | def test_creates_new_stack_for_apple_live_photo(self):
method test_returns_existing_stack_if_present (line 478) | def test_returns_existing_stack_if_present(self):
class ProcessLivePhotosBatchTestCase (line 507) | class ProcessLivePhotosBatchTestCase(TestCase):
method setUp (line 510) | def setUp(self):
method test_processes_all_photos (line 515) | def test_processes_all_photos(self, mock_detect):
method test_counts_detected_live_photos (line 527) | def test_counts_detected_live_photos(self, mock_detect):
method test_counts_new_stacks_created (line 539) | def test_counts_new_stacks_created(self, mock_detect):
method test_handles_exceptions_gracefully (line 552) | def test_handles_exceptions_gracefully(self, mock_detect):
method test_empty_list_returns_zero_counts (line 565) | def test_empty_list_returns_zero_counts(self):
class ConstantsTestCase (line 572) | class ConstantsTestCase(TestCase):
method test_jpeg_eoi_marker (line 575) | def test_jpeg_eoi_marker(self):
method test_google_signatures_list (line 579) | def test_google_signatures_list(self):
method test_samsung_marker (line 585) | def test_samsung_marker(self):
method test_apple_extensions (line 589) | def test_apple_extensions(self):
class EdgeCasesTestCase (line 595) | class EdgeCasesTestCase(TestCase):
method setUp (line 598) | def setUp(self):
method tearDown (line 602) | def tearDown(self):
method test_unicode_filename_apple_live_photo (line 607) | def test_unicode_filename_apple_live_photo(self):
method test_special_characters_in_path (line 617) | def test_special_characters_in_path(self):
method test_locate_video_at_start_of_data (line 627) | def test_locate_video_at_start_of_data(self):
method test_multiple_samsung_markers (line 633) | def test_multiple_samsung_markers(self):
method test_partial_signature_not_matched (line 642) | def test_partial_signature_not_matched(self):
method test_very_large_file_simulation (line 649) | def test_very_large_file_simulation(self):
method test_binary_data_with_nulls (line 658) | def test_binary_data_with_nulls(self):
FILE: api/tests/test_location_timeline.py
function prepare_database (line 12) | def prepare_database(user):
class LocationTimelineTest (line 97) | class LocationTimelineTest(TestCase):
method setUp (line 98) | def setUp(self) -> None:
method test_location_timeline_endpoint (line 105) | def test_location_timeline_endpoint(self):
method test_get_location_timeline (line 110) | def test_get_location_timeline(self):
method test_get_photo_month_counts_endpoint (line 114) | def test_get_photo_month_counts_endpoint(self):
method test_get_photo_month_count (line 119) | def test_get_photo_month_count(self):
FILE: api/tests/test_metadata_ordering_sentinel.py
function create_unique_png (line 13) | def create_unique_png(seed=0):
class DummyAsyncTask (line 53) | class DummyAsyncTask:
method __init__ (line 62) | def __init__(self, func, *args, **kwargs):
method run (line 69) | def run(self):
class DummyChain (line 83) | class DummyChain:
method __init__ (line 84) | def __init__(self, *args, **kwargs):
method append (line 87) | def append(self, *args, **kwargs):
method run (line 91) | def run(self):
class MetadataOrderingSentinelTest (line 95) | class MetadataOrderingSentinelTest(TestCase):
method test_random_order_images_and_xmp_are_consistently_linked (line 96) | def test_random_order_images_and_xmp_are_consistently_linked(self):
FILE: api/tests/test_migration_0099.py
function _sqlite_table_exists (line 35) | def _sqlite_table_exists(cursor, table_name):
function _sqlite_column_names (line 43) | def _sqlite_column_names(cursor, table_name):
function _sqlite_pk_columns (line 48) | def _sqlite_pk_columns(cursor, table_name):
function _sqlite_index_exists (line 53) | def _sqlite_index_exists(cursor, index_name):
function _build_test_db (line 170) | def _build_test_db():
function _run_migration_on (line 223) | def _run_migration_on(sqlite_conn):
class TestSQLiteMigration0099 (line 260) | class TestSQLiteMigration0099(TestCase):
method setUpClass (line 269) | def setUpClass(cls):
method tearDownClass (line 275) | def tearDownClass(cls):
method _cursor (line 279) | def _cursor(self):
method test_photo_has_id_column (line 284) | def test_photo_has_id_column(self):
method test_photo_has_image_hash_column (line 289) | def test_photo_has_image_hash_column(self):
method test_photo_pk_is_id (line 294) | def test_photo_pk_is_id(self):
method test_image_hash_unique_index (line 299) | def test_image_hash_unique_index(self):
method test_performance_indexes (line 303) | def test_performance_indexes(self):
method test_all_photos_have_valid_uuids (line 321) | def test_all_photos_have_valid_uuids(self):
method test_image_hashes_preserved (line 329) | def test_image_hashes_preserved(self):
method test_each_photo_has_distinct_uuid (line 335) | def test_each_photo_has_distinct_uuid(self):
method test_face_fk_translated (line 341) | def test_face_fk_translated(self):
method test_no_orphan_faces (line 354) | def test_no_orphan_faces(self):
method test_thumbnail_fk_translated (line 363) | def test_thumbnail_fk_translated(self):
method test_photo_caption_fk_translated (line 373) | def test_photo_caption_fk_translated(self):
method test_photo_search_fk_translated (line 382) | def test_photo_search_fk_translated(self):
method test_person_cover_photo_translated (line 391) | def test_person_cover_photo_translated(self):
method test_albumuser_cover_photo_translated (line 401) | def test_albumuser_cover_photo_translated(self):
method test_m2m_shared_to_translated (line 410) | def test_m2m_shared_to_translated(self):
method test_m2m_albumuser_photos_translated (line 418) | def test_m2m_albumuser_photos_translated(self):
method test_m2m_albumthing_photos_translated (line 426) | def test_m2m_albumthing_photos_translated(self):
method test_m2m_albumplace_photos_translated (line 434) | def test_m2m_albumplace_photos_translated(self):
method test_m2m_albumdate_photos_translated (line 442) | def test_m2m_albumdate_photos_translated(self):
method test_m2m_albumauto_photos_translated (line 450) | def test_m2m_albumauto_photos_translated(self):
method test_albumthing_cover_photos_translated (line 458) | def test_albumthing_cover_photos_translated(self):
method test_photostack_primary_photo_translated (line 466) | def test_photostack_primary_photo_translated(self):
class TestPostMigrationSchema (line 481) | class TestPostMigrationSchema(TestCase):
method test_photo_table_has_id_and_image_hash (line 490) | def test_photo_table_has_id_and_image_hash(self):
method test_photo_pk_is_uuid_field (line 504) | def test_photo_pk_is_uuid_field(self):
class TestMigrationDispatch (line 516) | class TestMigrationDispatch(TestCase):
method test_dispatches_to_sqlite (line 519) | def test_dispatches_to_sqlite(self):
method test_dispatches_to_postgresql (line 526) | def test_dispatches_to_postgresql(self):
method test_rejects_unknown_backend (line 533) | def test_rejects_unknown_backend(self):
method test_reverse_raises_runtime_error (line 539) | def test_reverse_raises_runtime_error(self):
class TestSQLiteHelpers (line 548) | class TestSQLiteHelpers(TestCase):
method test_column_info (line 551) | def test_column_info(self):
method test_index_info (line 563) | def test_index_info(self):
method test_recreate_table_changes_pk (line 573) | def test_recreate_table_changes_pk(self):
method test_update_fk_table_translates_values (line 596) | def test_update_fk_table_translates_values(self):
method test_update_fk_table_skips_missing_table (line 615) | def test_update_fk_table_skips_missing_table(self):
FILE: api/tests/test_migration_0101.py
class Migration0101TestCase (line 23) | class Migration0101TestCase(TestCase):
method setUp (line 26) | def setUp(self):
method test_subquery_with_uuid_primary_key (line 30) | def test_subquery_with_uuid_primary_key(self):
method test_migration_logic_creates_metadata (line 65) | def test_migration_logic_creates_metadata(self):
method test_batch_processing_without_iterator (line 122) | def test_batch_processing_without_iterator(self):
method test_batch_processing_is_idempotent (line 205) | def test_batch_processing_is_idempotent(self):
method test_bulk_create_ignore_conflicts_on_duplicate (line 245) | def test_bulk_create_ignore_conflicts_on_duplicate(self):
FILE: api/tests/test_multi_user_isolation.py
class DuplicateUserIsolationTestCase (line 19) | class DuplicateUserIsolationTestCase(APITestCase):
method setUp (line 22) | def setUp(self):
method test_user_cannot_see_other_user_duplicates (line 31) | def test_user_cannot_see_other_user_duplicates(self):
method test_user_cannot_access_other_user_duplicate_detail (line 52) | def test_user_cannot_access_other_user_duplicate_detail(self):
method test_user_cannot_resolve_other_user_duplicate (line 66) | def test_user_cannot_resolve_other_user_duplicate(self):
method test_user_cannot_delete_other_user_duplicate (line 84) | def test_user_cannot_delete_other_user_duplicate(self):
method test_admin_can_see_duplicate_stats (line 101) | def test_admin_can_see_duplicate_stats(self):
class StackUserIsolationTestCase (line 118) | class StackUserIsolationTestCase(APITestCase):
method setUp (line 121) | def setUp(self):
method test_user_cannot_see_other_user_stacks (line 126) | def test_user_cannot_see_other_user_stacks(self):
method test_user_cannot_access_other_user_stack_detail (line 144) | def test_user_cannot_access_other_user_stack_detail(self):
method test_user_cannot_modify_other_user_stack (line 158) | def test_user_cannot_modify_other_user_stack(self):
method test_user_cannot_delete_other_user_stack (line 177) | def test_user_cannot_delete_other_user_stack(self):
method test_user_cannot_create_stack_with_other_user_photos (line 194) | def test_user_cannot_create_stack_with_other_user_photos(self):
class SharedPhotoIsolationTestCase (line 216) | class SharedPhotoIsolationTestCase(TestCase):
method setUp (line 219) | def setUp(self):
method test_shared_photo_not_in_receiver_stacks (line 223) | def test_shared_photo_not_in_receiver_stacks(self):
method test_shared_photo_not_in_receiver_duplicates (line 243) | def test_shared_photo_not_in_receiver_duplicates(self):
method test_user_stack_unaffected_by_shared_photos (line 262) | def test_user_stack_unaffected_by_shared_photos(self):
class DetectionUserIsolationTestCase (line 280) | class DetectionUserIsolationTestCase(APITestCase):
method setUp (line 283) | def setUp(self):
method test_duplicate_detection_only_affects_own_photos (line 288) | def test_duplicate_detection_only_affects_own_photos(self):
method test_stack_detection_only_affects_own_photos (line 314) | def test_stack_detection_only_affects_own_photos(self):
class CrossUserOperationTestCase (line 332) | class CrossUserOperationTestCase(APITestCase):
method setUp (line 335) | def setUp(self):
method test_cannot_add_other_user_photo_to_own_stack (line 340) | def test_cannot_add_other_user_photo_to_own_stack(self):
method test_cannot_resolve_duplicate_with_other_user_photo (line 363) | def test_cannot_resolve_duplicate_with_other_user_photo(self):
method test_cannot_set_other_user_photo_as_stack_primary (line 386) | def test_cannot_set_other_user_photo_as_stack_primary(self):
class AdminAccessTestCase (line 410) | class AdminAccessTestCase(APITestCase):
method setUp (line 413) | def setUp(self):
method test_admin_can_view_stats_for_all_users (line 420) | def test_admin_can_view_stats_for_all_users(self):
method test_regular_user_sees_only_own_stats (line 435) | def test_regular_user_sees_only_own_stats(self):
FILE: api/tests/test_only_photos_or_only_videos.py
class OnlyPhotosOrOnlyVideosTest (line 9) | class OnlyPhotosOrOnlyVideosTest(TestCase):
method setUp (line 10) | def setUp(self):
method test_only_photos (line 15) | def test_only_photos(self):
FILE: api/tests/test_perceptual_hash.py
class HammingDistanceTestCase (line 29) | class HammingDistanceTestCase(TestCase):
method test_identical_hashes_return_zero (line 32) | def test_identical_hashes_return_zero(self):
method test_completely_different_hashes (line 37) | def test_completely_different_hashes(self):
method test_one_bit_difference (line 45) | def test_one_bit_difference(self):
method test_half_bits_different (line 53) | def test_half_bits_different(self):
method test_invalid_hash_returns_max_distance (line 61) | def test_invalid_hash_returns_max_distance(self):
method test_empty_strings_return_max_distance (line 66) | def test_empty_strings_return_max_distance(self):
method test_mixed_valid_invalid_returns_max_distance (line 71) | def test_mixed_valid_invalid_returns_max_distance(self):
method test_different_length_hashes (line 77) | def test_different_length_hashes(self):
method test_real_phash_values (line 85) | def test_real_phash_values(self):
method test_case_insensitive_hashes (line 93) | def test_case_insensitive_hashes(self):
class AreDuplicatesTestCase (line 101) | class AreDuplicatesTestCase(TestCase):
method test_identical_hashes_are_duplicates (line 104) | def test_identical_hashes_are_duplicates(self):
method test_distance_under_threshold_is_duplicate (line 109) | def test_distance_under_threshold_is_duplicate(self):
method test_distance_at_threshold_is_duplicate (line 116) | def test_distance_at_threshold_is_duplicate(self):
method test_distance_over_threshold_not_duplicate (line 121) | def test_distance_over_threshold_not_duplicate(self):
method test_empty_hash1_not_duplicate (line 126) | def test_empty_hash1_not_duplicate(self):
method test_empty_hash2_not_duplicate (line 130) | def test_empty_hash2_not_duplicate(self):
method test_none_hash1_not_duplicate (line 134) | def test_none_hash1_not_duplicate(self):
method test_none_hash2_not_duplicate (line 138) | def test_none_hash2_not_duplicate(self):
method test_both_none_not_duplicate (line 142) | def test_both_none_not_duplicate(self):
method test_both_empty_not_duplicate (line 146) | def test_both_empty_not_duplicate(self):
method test_custom_threshold_strict (line 150) | def test_custom_threshold_strict(self):
method test_custom_threshold_loose (line 156) | def test_custom_threshold_loose(self):
method test_default_threshold_value (line 162) | def test_default_threshold_value(self):
class FindSimilarHashesTestCase (line 167) | class FindSimilarHashesTestCase(TestCase):
method test_empty_target_hash_returns_empty (line 170) | def test_empty_target_hash_returns_empty(self):
method test_none_target_hash_returns_empty (line 176) | def test_none_target_hash_returns_empty(self):
method test_empty_hash_list_returns_empty (line 182) | def test_empty_hash_list_returns_empty(self):
method test_finds_similar_hashes (line 187) | def test_finds_similar_hashes(self):
method test_excludes_distant_hashes (line 198) | def test_excludes_distant_hashes(self):
method test_skips_identical_hash (line 206) | def test_skips_identical_hash(self):
method test_skips_none_hash_in_list (line 213) | def test_skips_none_hash_in_list(self):
method test_skips_empty_hash_in_list (line 222) | def test_skips_empty_hash_in_list(self):
method test_sorted_by_distance (line 231) | def test_sorted_by_distance(self):
method test_custom_threshold (line 244) | def test_custom_threshold(self):
method test_returns_correct_tuple_format (line 257) | def test_returns_correct_tuple_format(self):
method test_multiple_similar_all_returned (line 268) | def test_multiple_similar_all_returned(self):
class CalculatePerceptualHashTestCase (line 277) | class CalculatePerceptualHashTestCase(TestCase):
method setUp (line 280) | def setUp(self):
method tearDown (line 284) | def tearDown(self):
method _create_test_image (line 290) | def _create_test_image(self, filename, size=(100, 100), mode="RGB", co...
method test_valid_rgb_image (line 297) | def test_valid_rgb_image(self):
method test_valid_rgba_image_converted (line 305) | def test_valid_rgba_image_converted(self):
method test_valid_grayscale_image (line 312) | def test_valid_grayscale_image(self):
method test_valid_palette_image_converted (line 319) | def test_valid_palette_image_converted(self):
method test_nonexistent_file_returns_none (line 327) | def test_nonexistent_file_returns_none(self):
method test_corrupted_file_returns_none (line 332) | def test_corrupted_file_returns_none(self):
method test_empty_file_returns_none (line 340) | def test_empty_file_returns_none(self):
method test_directory_instead_of_file_returns_none (line 348) | def test_directory_instead_of_file_returns_none(self):
method test_custom_hash_size (line 353) | def test_custom_hash_size(self):
method test_small_hash_size (line 361) | def test_small_hash_size(self):
method test_similar_images_similar_hashes (line 369) | def test_similar_images_similar_hashes(self):
method test_different_images_different_hashes (line 382) | def test_different_images_different_hashes(self):
method test_deterministic_hash (line 411) | def test_deterministic_hash(self):
method test_very_small_image (line 418) | def test_very_small_image(self):
method test_very_large_image (line 425) | def test_very_large_image(self):
method test_jpeg_vs_png_same_content (line 432) | def test_jpeg_vs_png_same_content(self):
class CalculateHashFromThumbnailTestCase (line 446) | class CalculateHashFromThumbnailTestCase(TestCase):
method setUp (line 449) | def setUp(self):
method tearDown (line 453) | def tearDown(self):
method test_delegates_to_calculate_perceptual_hash (line 459) | def test_delegates_to_calculate_perceptual_hash(self):
method test_returns_none_on_failure (line 467) | def test_returns_none_on_failure(self):
class EdgeCasesTestCase (line 473) | class EdgeCasesTestCase(TestCase):
method setUp (line 476) | def setUp(self):
method tearDown (line 480) | def tearDown(self):
method test_unicode_filename (line 486) | def test_unicode_filename(self):
method test_special_characters_in_path (line 494) | def test_special_characters_in_path(self):
method test_hash_only_contains_hex_chars (line 502) | def test_hash_only_contains_hex_chars(self):
method test_webp_format (line 513) | def test_webp_format(self):
method test_gif_format (line 521) | def test_gif_format(self):
method test_bmp_format (line 529) | def test_bmp_format(self):
method test_hamming_distance_with_newlines_in_hash (line 537) | def test_hamming_distance_with_newlines_in_hash(self):
method test_find_similar_with_large_list (line 545) | def test_find_similar_with_large_list(self):
method test_are_duplicates_with_whitespace_only_hash (line 554) | def test_are_duplicates_with_whitespace_only_hash(self):
method test_cmyk_image_converted (line 559) | def test_cmyk_image_converted(self):
method test_1bit_image (line 568) | def test_1bit_image(self):
method test_concurrent_hash_calculation (line 576) | def test_concurrent_hash_calculation(self):
class PerformanceTestCase (line 614) | class PerformanceTestCase(TestCase):
method setUp (line 617) | def setUp(self):
method tearDown (line 621) | def tearDown(self):
method test_hamming_distance_performance (line 627) | def test_hamming_distance_performance(self):
method test_find_similar_performance (line 642) | def test_find_similar_performance(self):
FILE: api/tests/test_photo_caption_model.py
class PhotoCaptionModelTest (line 7) | class PhotoCaptionModelTest(TestCase):
method setUp (line 8) | def setUp(self):
method test_create_photo_caption (line 12) | def test_create_photo_caption(self):
method test_photo_caption_one_to_one_relationship (line 21) | def test_photo_caption_one_to_one_relationship(self):
method test_generate_captions_im2txt (line 33) | def test_generate_captions_im2txt(self):
method test_save_user_caption (line 42) | def test_save_user_caption(self):
method test_generate_tag_captions_skips_existing (line 51) | def test_generate_tag_captions_skips_existing(self):
method test_recreate_search_captions_delegates_to_photo_search (line 71) | def test_recreate_search_captions_delegates_to_photo_search(self):
method test_captions_json_default_empty_dict (line 87) | def test_captions_json_default_empty_dict(self):
method test_str_representation (line 93) | def test_str_representation(self):
method test_cascade_delete_with_photo (line 102) | def test_cascade_delete_with_photo(self):
method test_multiple_caption_types (line 112) | def test_multiple_caption_types(self):
method test_update_existing_captions (line 131) | def test_update_existing_captions(self):
method test_empty_captions_json_handling (line 144) | def test_empty_captions_json_handling(self):
FILE: api/tests/test_photo_captions.py
class PhotoCaptionsTest (line 9) | class PhotoCaptionsTest(TestCase):
method setUp (line 10) | def setUp(self):
method test_generate_captions_for_my_photo (line 19) | def test_generate_captions_for_my_photo(self, generate_caption_mock):
method test_fail_to_generate_captions_for_my_photo (line 38) | def test_fail_to_generate_captions_for_my_photo(self, generate_caption...
method test_generate_captions_for_my_photo_of_another_user (line 54) | def test_generate_captions_for_my_photo_of_another_user(self):
FILE: api/tests/test_photo_lifecycle.py
class PhotoDeletionStackCleanupTestCase (line 19) | class PhotoDeletionStackCleanupTestCase(TestCase):
method setUp (line 22) | def setUp(self):
method test_manual_delete_clears_stack_membership (line 25) | def test_manual_delete_clears_stack_membership(self):
method test_manual_delete_deletes_stack_with_one_remaining (line 52) | def test_manual_delete_deletes_stack_with_one_remaining(self):
method test_manual_delete_deletes_empty_stack (line 76) | def test_manual_delete_deletes_empty_stack(self):
class PhotoDeletionDuplicateCleanupTestCase (line 101) | class PhotoDeletionDuplicateCleanupTestCase(TestCase):
method setUp (line 104) | def setUp(self):
method test_manual_delete_clears_duplicate_membership (line 107) | def test_manual_delete_clears_duplicate_membership(self):
method test_manual_delete_deletes_duplicate_with_one_remaining (line 138) | def test_manual_delete_deletes_duplicate_with_one_remaining(self):
method test_manual_delete_deletes_empty_duplicate_group (line 166) | def test_manual_delete_deletes_empty_duplicate_group(self):
class PhotoTrashRestoreTestCase (line 192) | class PhotoTrashRestoreTestCase(TestCase):
method setUp (line 195) | def setUp(self):
method test_trashed_photo_preserves_stack_membership (line 198) | def test_trashed_photo_preserves_stack_membership(self):
method test_trashed_photo_preserves_duplicate_membership (line 220) | def test_trashed_photo_preserves_duplicate_membership(self):
method test_restore_photo_from_trash (line 242) | def test_restore_photo_from_trash(self):
class PhotoInMultipleGroupsTestCase (line 254) | class PhotoInMultipleGroupsTestCase(TestCase):
method setUp (line 257) | def setUp(self):
method test_photo_in_both_stack_and_duplicate (line 260) | def test_photo_in_both_stack_and_duplicate(self):
method test_photo_in_multiple_stacks (line 300) | def test_photo_in_multiple_stacks(self):
class DuplicateResolutionCleanupTestCase (line 333) | class DuplicateResolutionCleanupTestCase(TestCase):
method setUp (line 336) | def setUp(self):
method test_resolve_duplicate_trashes_non_kept_photos (line 339) | def test_resolve_duplicate_trashes_non_kept_photos(self):
method test_resolve_duplicate_updates_status (line 364) | def test_resolve_duplicate_updates_status(self):
class EdgeCasesTestCase (line 382) | class EdgeCasesTestCase(TestCase):
method setUp (line 385) | def setUp(self):
method test_delete_photo_not_in_any_group (line 388) | def test_delete_photo_not_in_any_group(self):
method test_delete_photo_with_no_main_file (line 399) | def test_delete_photo_with_no_main_file(self):
method test_stack_primary_photo_deleted (line 414) | def test_stack_primary_photo_deleted(self):
method test_duplicate_kept_photo_deleted (line 440) | def test_duplicate_kept_photo_deleted(self):
class SharedFileTestCase (line 464) | class SharedFileTestCase(TestCase):
method setUp (line 467) | def setUp(self):
method test_delete_photo_preserves_shared_file (line 470) | def test_delete_photo_preserves_shared_file(self):
method test_delete_photo_removes_unshared_file (line 512) | def test_delete_photo_removes_unshared_file(self):
method test_delete_photo_with_shared_main_file_different_from_files (line 555) | def test_delete_photo_with_shared_main_file_different_from_files(self):
method test_delete_last_photo_using_shared_file (line 598) | def test_delete_last_photo_using_shared_file(self):
FILE: api/tests/test_photo_list_without_timestamp.py
class PhotoListWithoutTimestampTest (line 8) | class PhotoListWithoutTimestampTest(TestCase):
method setUp (line 9) | def setUp(self):
method test_retrieve_photos_without_exif_timestamp (line 14) | def test_retrieve_photos_without_exif_timestamp(self):
FILE: api/tests/test_photo_metadata.py
class PhotoMetadataModelTestCase (line 24) | class PhotoMetadataModelTestCase(TestCase):
method setUp (line 27) | def setUp(self):
method test_create_metadata_basic (line 31) | def test_create_metadata_basic(self):
method test_metadata_source_choices (line 47) | def test_metadata_source_choices(self):
method test_resolution_property (line 54) | def test_resolution_property(self):
method test_resolution_property_missing_dimensions (line 64) | def test_resolution_property_missing_dimensions(self):
method test_megapixels_property (line 74) | def test_megapixels_property(self):
method test_megapixels_property_missing_dimensions (line 85) | def test_megapixels_property_missing_dimensions(self):
method test_has_location_property_with_gps (line 95) | def test_has_location_property_with_gps(self):
method test_has_location_property_without_gps (line 105) | def test_has_location_property_without_gps(self):
method test_has_location_partial_gps (line 113) | def test_has_location_partial_gps(self):
method test_camera_display_make_and_model (line 123) | def test_camera_display_make_and_model(self):
method test_camera_display_model_includes_make (line 133) | def test_camera_display_model_includes_make(self):
method test_camera_display_only_model (line 144) | def test_camera_display_only_model(self):
method test_lens_display (line 153) | def test_lens_display(self):
method test_lens_display_model_includes_make (line 163) | def test_lens_display_model_includes_make(self):
method test_version_increments (line 173) | def test_version_increments(self):
method test_raw_data_json_fields (line 189) | def test_raw_data_json_fields(self):
method test_keywords_json_field (line 204) | def test_keywords_json_field(self):
class MetadataFileModelTestCase (line 217) | class MetadataFileModelTestCase(TestCase):
method setUp (line 220) | def setUp(self):
method test_file_type_choices (line 224) | def test_file_type_choices(self):
method test_source_choices (line 231) | def test_source_choices(self):
class MetadataEditModelTestCase (line 239) | class MetadataEditModelTestCase(TestCase):
method setUp (line 242) | def setUp(self):
method test_create_edit_record (line 246) | def test_create_edit_record(self):
method test_edit_records_ordered_by_created_at (line 263) | def test_edit_records_ordered_by_created_at(self):
class PhotoMetadataAPITestCase (line 286) | class PhotoMetadataAPITestCase(TestCase):
method setUp (line 289) | def setUp(self):
method test_retrieve_metadata (line 307) | def test_retrieve_metadata(self):
method test_retrieve_metadata_by_image_hash (line 317) | def test_retrieve_metadata_by_image_hash(self):
method test_retrieve_metadata_creates_if_missing (line 323) | def test_retrieve_metadata_creates_if_missing(self):
method test_retrieve_metadata_other_user_forbidden (line 336) | def test_retrieve_metadata_other_user_forbidden(self):
method test_update_metadata (line 344) | def test_update_metadata(self):
method test_update_creates_edit_history (line 358) | def test_update_creates_edit_history(self):
method test_get_edit_history (line 375) | def test_get_edit_history(self):
method test_revert_edit (line 401) | def test_revert_edit(self):
method test_revert_creates_new_edit_record (line 426) | def test_revert_creates_new_edit_record(self):
method test_revert_nonexistent_edit (line 445) | def test_revert_nonexistent_edit(self):
method test_unauthenticated_request (line 453) | def test_unauthenticated_request(self):
class BulkMetadataAPITestCase (line 462) | class BulkMetadataAPITestCase(TestCase):
method setUp (line 465) | def setUp(self):
method test_bulk_get_metadata (line 485) | def test_bulk_get_metadata(self):
method test_bulk_get_no_photo_ids (line 496) | def test_bulk_get_no_photo_ids(self):
method test_bulk_get_max_100_photos (line 502) | def test_bulk_get_max_100_photos(self):
method test_bulk_update_metadata (line 512) | def test_bulk_update_metadata(self):
method test_bulk_update_no_photo_ids (line 532) | def test_bulk_update_no_photo_ids(self):
method test_bulk_update_no_updates (line 542) | def test_bulk_update_no_updates(self):
method test_bulk_update_invalid_fields (line 552) | def test_bulk_update_invalid_fields(self):
method test_bulk_update_creates_edit_history (line 566) | def test_bulk_update_creates_edit_history(self):
method test_bulk_update_other_user_photos_ignored (line 582) | def test_bulk_update_other_user_photos_ignored(self):
class PhotoMetadataEdgeCasesTestCase (line 601) | class PhotoMetadataEdgeCasesTestCase(TestCase):
method setUp (line 604) | def setUp(self):
method test_metadata_with_special_characters (line 610) | def test_metadata_with_special_characters(self):
method test_metadata_with_very_long_caption (line 623) | def test_metadata_with_very_long_caption(self):
method test_metadata_with_null_values (line 634) | def test_metadata_with_null_values(self):
method test_metadata_with_zero_values (line 648) | def test_metadata_with_zero_values(self):
method test_metadata_with_negative_gps (line 661) | def test_metadata_with_negative_gps(self):
method test_one_to_one_relationship_enforced (line 672) | def test_one_to_one_relationship_enforced(self):
method test_invalid_uuid_in_url (line 679) | def test_invalid_uuid_in_url(self):
method test_staff_can_access_any_photo_metadata (line 686) | def test_staff_can_access_any_photo_metadata(self):
method test_update_increments_version (line 699) | def test_update_increments_version(self):
method test_revert_all_records_action (line 717) | def test_revert_all_records_action(self):
method test_keywords_array_update (line 739) | def test_keywords_array_update(self):
method test_empty_keywords_update (line 757) | def test_empty_keywords_update(self):
FILE: api/tests/test_photo_metadata_api.py
class PhotoMetadataRetrieveTestCase (line 22) | class PhotoMetadataRetrieveTestCase(APITestCase):
method setUp (line 25) | def setUp(self):
method test_get_metadata_by_uuid (line 31) | def test_get_metadata_by_uuid(self):
method test_get_metadata_by_image_hash (line 39) | def test_get_metadata_by_image_hash(self):
method test_get_metadata_creates_if_missing (line 44) | def test_get_metadata_creates_if_missing(self):
method test_get_metadata_nonexistent_photo (line 55) | def test_get_metadata_nonexistent_photo(self):
method test_get_metadata_other_user_forbidden (line 61) | def test_get_metadata_other_user_forbidden(self):
method test_get_metadata_admin_can_access_any (line 69) | def test_get_metadata_admin_can_access_any(self):
class PhotoMetadataUpdateTestCase (line 82) | class PhotoMetadataUpdateTestCase(APITestCase):
method setUp (line 85) | def setUp(self):
method test_update_metadata_title (line 91) | def test_update_metadata_title(self):
method test_update_metadata_creates_history (line 104) | def test_update_metadata_creates_history(self):
method test_update_metadata_rating (line 126) | def test_update_metadata_rating(self):
method test_update_metadata_caption (line 138) | def test_update_metadata_caption(self):
method test_update_metadata_version_increments (line 150) | def test_update_metadata_version_increments(self):
method test_update_metadata_forbidden_for_other_user (line 166) | def test_update_metadata_forbidden_for_other_user(self):
class PhotoMetadataHistoryTestCase (line 179) | class PhotoMetadataHistoryTestCase(APITestCase):
method setUp (line 182) | def setUp(self):
method test_get_empty_history (line 188) | def test_get_empty_history(self):
method test_get_history_with_edits (line 195) | def test_get_history_with_edits(self):
method test_history_pagination (line 212) | def test_history_pagination(self):
method test_history_ordered_by_date (line 232) | def test_history_ordered_by_date(self):
class PhotoMetadataRevertTestCase (line 259) | class PhotoMetadataRevertTestCase(APITestCase):
method setUp (line 262) | def setUp(self):
method test_revert_single_edit (line 269) | def test_revert_single_edit(self):
method test_revert_creates_history_entry (line 293) | def test_revert_creates_history_entry(self):
method test_revert_nonexistent_edit (line 314) | def test_revert_nonexistent_edit(self):
method test_revert_edit_from_wrong_photo (line 320) | def test_revert_edit_from_wrong_photo(self):
class PhotoMetadataRevertAllTestCase (line 338) | class PhotoMetadataRevertAllTestCase(APITestCase):
method setUp (line 341) | def setUp(self):
method test_revert_all_creates_history (line 347) | def test_revert_all_creates_history(self):
class BulkMetadataGetTestCase (line 362) | class BulkMetadataGetTestCase(APITestCase):
method setUp (line 365) | def setUp(self):
method test_bulk_get_by_uuid (line 371) | def test_bulk_get_by_uuid(self):
method test_bulk_get_by_image_hash (line 378) | def test_bulk_get_by_image_hash(self):
method test_bulk_get_mixed_ids (line 385) | def test_bulk_get_mixed_ids(self):
method test_bulk_get_no_ids (line 392) | def test_bulk_get_no_ids(self):
method test_bulk_get_too_many_ids (line 397) | def test_bulk_get_too_many_ids(self):
method test_bulk_get_filters_other_users (line 404) | def test_bulk_get_filters_other_users(self):
class BulkMetadataUpdateTestCase (line 416) | class BulkMetadataUpdateTestCase(APITestCase):
method setUp (line 419) | def setUp(self):
method test_bulk_update_rating (line 425) | def test_bulk_update_rating(self):
method test_bulk_update_creates_history (line 436) | def test_bulk_update_creates_history(self):
method test_bulk_update_no_ids (line 452) | def test_bulk_update_no_ids(self):
method test_bulk_update_no_updates (line 461) | def test_bulk_update_no_updates(self):
method test_bulk_update_invalid_field (line 470) | def test_bulk_update_invalid_field(self):
method test_bulk_update_too_many_photos (line 482) | def test_bulk_update_too_many_photos(self):
class PhotoMetadataEdgeCasesTestCase (line 493) | class PhotoMetadataEdgeCasesTestCase(APITestCase):
method setUp (line 496) | def setUp(self):
method test_photo_no_exif_data (line 502) | def test_photo_no_exif_data(self):
method test_metadata_with_special_characters (line 517) | def test_metadata_with_special_characters(self):
method test_metadata_empty_strings (line 529) | def test_metadata_empty_strings(self):
method test_metadata_null_values (line 546) | def test_metadata_null_values(self):
method test_concurrent_metadata_updates (line 556) | def test_concurrent_metadata_updates(self):
method test_very_long_values (line 580) | def test_very_long_values(self):
class PhotoMetadataModelTestCase (line 592) | class PhotoMetadataModelTestCase(TestCase):
method setUp (line 595) | def setUp(self):
method test_metadata_source_choices (line 599) | def test_metadata_source_choices(self):
method test_has_location_property (line 610) | def test_has_location_property(self):
method test_camera_display_property (line 626) | def test_camera_display_property(self):
method test_lens_display_property (line 637) | def test_lens_display_property(self):
class MetadataEditModelTestCase (line 649) | class MetadataEditModelTestCase(TestCase):
method setUp (line 652) | def setUp(self):
method test_create_edit_record (line 656) | def test_create_edit_record(self):
method test_edit_record_timestamps (line 670) | def test_edit_record_timestamps(self):
method test_edit_record_json_values (line 685) | def test_edit_record_json_values(self):
method test_edit_record_null_old_value (line 698) | def test_edit_record_null_old_value(self):
FILE: api/tests/test_photo_model_integration.py
class PhotoModelIntegrationTest (line 11) | class PhotoModelIntegrationTest(TestCase):
method setUp (line 12) | def setUp(self):
method test_photo_properties_delegate_to_caption_model (line 16) | def test_photo_properties_delegate_to_caption_model(self):
method test_photo_properties_delegate_to_search_model (line 35) | def test_photo_properties_delegate_to_search_model(self):
method test_photo_caption_methods_work_directly (line 61) | def test_photo_caption_methods_work_directly(self):
method test_photo_search_methods_work_directly (line 72) | def test_photo_search_methods_work_directly(self):
method test_geolocate_updates_search_location (line 90) | def test_geolocate_updates_search_location(self):
method test_cascade_deletion_of_related_models (line 119) | def test_cascade_deletion_of_related_models(self):
method test_lazy_creation_of_related_instances (line 142) | def test_lazy_creation_of_related_instances(self):
method test_get_or_create_methods (line 171) | def test_get_or_create_methods(self):
method test_complex_workflow (line 193) | def test_complex_workflow(self):
method test_property_error_handling (line 236) | def test_property_error_handling(self):
method test_backward_compatibility (line 250) | def test_backward_compatibility(self):
method test_queryset_only_with_search_location_fails (line 283) | def test_queryset_only_with_search_location_fails(self):
method test_queryset_only_with_search_instance_works (line 289) | def test_queryset_only_with_search_instance_works(self):
method test_album_date_queryset_works (line 309) | def test_album_date_queryset_works(self):
FILE: api/tests/test_photo_search_model.py
class PhotoSearchModelTest (line 13) | class PhotoSearchModelTest(TestCase):
method setUp (line 14) | def setUp(self):
method test_create_photo_search (line 18) | def test_create_photo_search(self):
method test_photo_search_one_to_one_relationship (line 30) | def test_photo_search_one_to_one_relationship(self):
method test_recreate_search_captions_with_places365 (line 40) | def test_recreate_search_captions_with_places365(self):
method test_recreate_search_captions_with_user_caption (line 62) | def test_recreate_search_captions_with_user_caption(self):
method test_recreate_search_captions_with_im2txt (line 74) | def test_recreate_search_captions_with_im2txt(self):
method test_recreate_search_captions_with_faces (line 86) | def test_recreate_search_captions_with_faces(self):
method test_recreate_search_captions_with_file_path (line 96) | def test_recreate_search_captions_with_file_path(self):
method test_recreate_search_captions_with_video (line 104) | def test_recreate_search_captions_with_video(self):
method test_recreate_search_captions_with_camera_info (line 112) | def test_recreate_search_captions_with_camera_info(self):
method test_update_search_location (line 123) | def test_update_search_location(self):
method test_update_search_location_with_empty_data (line 134) | def test_update_search_location_with_empty_data(self):
method test_search_captions_default_empty (line 142) | def test_search_captions_default_empty(self):
method test_search_location_default_empty (line 148) | def test_search_location_default_empty(self):
method test_str_representation (line 154) | def test_str_representation(self):
method test_cascade_delete_with_photo (line 163) | def test_cascade_delete_with_photo(self):
method test_recreate_search_captions_comprehensive (line 173) | def test_recreate_search_captions_comprehensive(self):
method test_search_captions_indexing (line 212) | def test_search_captions_indexing(self):
method test_search_location_indexing (line 218) | def test_search_location_indexing(self):
method test_empty_places365_handling (line 223) | def test_empty_places365_handling(self):
method test_none_values_handling (line 238) | def test_none_values_handling(self):
FILE: api/tests/test_photo_search_refactor.py
class PhotoSearchRefactorTest (line 7) | class PhotoSearchRefactorTest(TestCase):
method setUp (line 8) | def setUp(self):
method test_search_location_property_works (line 13) | def test_search_location_property_works(self):
method test_search_captions_property_works (line 28) | def test_search_captions_property_works(self):
method test_recreate_search_captions_works (line 43) | def test_recreate_search_captions_works(self):
method test_geolocate_updates_search_location (line 72) | def test_geolocate_updates_search_location(self):
method test_direct_access_consistency (line 99) | def test_direct_access_consistency(self):
FILE: api/tests/test_photo_summary.py
class PhotoSummaryViewTest (line 9) | class PhotoSummaryViewTest(TestCase):
method setUp (line 10) | def setUp(self):
method test_summary_view_existing_photo_regular_user (line 17) | def test_summary_view_existing_photo_regular_user(self):
method test_summary_view_existing_photo (line 28) | def test_summary_view_existing_photo(self):
method test_summary_view_nonexistent_photo (line 36) | def test_summary_view_nonexistent_photo(self):
method test_summary_view_no_aspect_ratio (line 42) | def test_summary_view_no_aspect_ratio(self):
FILE: api/tests/test_photo_viewset_permissions.py
class PhotoViewSetPermissionsTest (line 11) | class PhotoViewSetPermissionsTest(TestCase):
method setUp (line 12) | def setUp(self):
method test_owner_can_update_photo (line 18) | def test_owner_can_update_photo(self):
method test_non_owner_cannot_update_photo (line 28) | def test_non_owner_cannot_update_photo(self):
FILE: api/tests/test_predefined_rules.py
class PredefinedRulesTest (line 10) | class PredefinedRulesTest(TestCase):
method setUp (line 11) | def setUp(self):
method test_predefined_rules (line 18) | def test_predefined_rules(self):
method test_default_rules_on_predefined_rules_endpoint (line 27) | def test_default_rules_on_predefined_rules_endpoint(self):
method test_default_rules_endpoint (line 33) | def test_default_rules_endpoint(self):
method test_other_rules (line 38) | def test_other_rules(self):
FILE: api/tests/test_public_photos.py
class PublicPhotosTest (line 13) | class PublicPhotosTest(TestCase):
method setUp (line 14) | def setUp(self):
method test_set_my_photos_as_public (line 20) | def test_set_my_photos_as_public(self):
method test_set_my_photos_as_private (line 36) | def test_set_my_photos_as_private(self):
method test_set_photos_of_other_user_as_public (line 52) | def test_set_photos_of_other_user_as_public(self):
method test_tag_nonexistent_photo_as_favorite (line 70) | def test_tag_nonexistent_photo_as_favorite(self, logger_ext: unittest....
FILE: api/tests/test_reading_exif.py
class ReadFacesFromPhotosTest (line 12) | class ReadFacesFromPhotosTest(TestCase):
method setUp (line 13) | def setUp(self):
method test_reading_from_photo (line 18) | def test_reading_from_photo(self):
FILE: api/tests/test_recently_added_photos.py
class RecentlyAddedPhotosTest (line 9) | class RecentlyAddedPhotosTest(TestCase):
method setUp (line 10) | def setUp(self):
method test_retrieve_recently_added_photos (line 16) | def test_retrieve_recently_added_photos(self):
method test_retrieve_empty_result_when_no_photos (line 29) | def test_retrieve_empty_result_when_no_photos(self):
FILE: api/tests/test_redetection_idempotency.py
class DuplicateRedetectionTestCase (line 31) | class DuplicateRedetectionTestCase(TestCase):
method setUp (line 34) | def setUp(self):
method test_exact_copy_redetection_no_duplicates (line 37) | def test_exact_copy_redetection_no_duplicates(self):
method test_visual_duplicate_redetection_no_duplicates (line 61) | def test_visual_duplicate_redetection_no_duplicates(self):
method test_redetection_adds_new_photos_to_existing_group (line 91) | def test_redetection_adds_new_photos_to_existing_group(self):
method test_redetection_with_resolved_duplicates (line 122) | def test_redetection_with_resolved_duplicates(self):
class BurstRedetectionTestCase (line 158) | class BurstRedetectionTestCase(TestCase):
method setUp (line 161) | def setUp(self):
method test_burst_redetection_no_duplicate_stacks (line 164) | def test_burst_redetection_no_duplicate_stacks(self):
method test_redetection_adds_new_photos_to_existing_stack (line 194) | def test_redetection_adds_new_photos_to_existing_stack(self):
class APIRedetectionTestCase (line 236) | class APIRedetectionTestCase(APITestCase):
method setUp (line 239) | def setUp(self):
method test_duplicate_detect_api_idempotent (line 248)
Condensed preview — 423 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (2,522K chars).
[
{
"path": ".coveragerc",
"chars": 136,
"preview": "[run]\nbranch = True\n\n[report]\nskip_covered = True\nskip_empty = True\n# show_missing = True\n\n[html]\nskip_covered = True\nsk"
},
{
"path": ".github/FUNDING.yml",
"chars": 88,
"preview": "github: derneuere\ncustom: https://www.paypal.com/donate/?hosted_button_id=5JWVM2UR4LM96\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 1512,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n# 🛑 Before you "
},
{
"path": ".github/ISSUE_TEMPLATE/enhancement-request.md",
"chars": 443,
"preview": "---\nname: Enhancement request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n"
},
{
"path": ".github/workflows/docker-publish.yml",
"chars": 840,
"preview": "on:\n push:\n # Publish `dev` as Docker `latest` image.\n branches:\n - dev\n\njobs:\n # Run tests.\n # See also h"
},
{
"path": ".github/workflows/pre-commit.yml",
"chars": 291,
"preview": "name: Linting (using pre-commit)\n\non: [push, pull_request]\n\njobs:\n pre-commit:\n runs-on: ubuntu-latest\n steps:\n "
},
{
"path": ".gitignore",
"chars": 2591,
"preview": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Docker\nDockerfile\nentr"
},
{
"path": ".pre-commit-config.yaml",
"chars": 257,
"preview": "default_language_version:\n python: python3\n\nrepos:\n - repo: https://github.com/astral-sh/ruff-pre-commit\n rev: v0.9"
},
{
"path": "CLAUDE.md",
"chars": 3785,
"preview": "# LibrePhotos Backend Agent Guidelines\n\n## Build & Development Commands\n\n**Note:** All commands should be run inside the"
},
{
"path": "CONTRIBUTING.md",
"chars": 10524,
"preview": "# Contributing to LibrePhotos\n\nThank you for your interest in contributing to LibrePhotos! This guide will help you get "
},
{
"path": "LICENSE",
"chars": 1067,
"preview": "MIT License\n\nCopyright (c) 2017 Hooram Nam\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
},
{
"path": "README.md",
"chars": 5995,
"preview": "[][discord] [\nfrom api.serializers.job "
},
{
"path": "api/apps.py",
"chars": 114,
"preview": "from django.apps import AppConfig\n\n\nclass ApiConfig(AppConfig):\n name = \"api\"\n verbose_name = \"LibrePhotos\"\n"
},
{
"path": "api/autoalbum.py",
"chars": 6877,
"preview": "from datetime import datetime, timedelta\n\nimport numpy as np\nimport pytz\nfrom django.db.models import Q\n\nfrom api.models"
},
{
"path": "api/background_tasks.py",
"chars": 1483,
"preview": "from tqdm import tqdm\nfrom django.db import models\n\nfrom api.models import Photo\nfrom api.models.photo_caption import Ph"
},
{
"path": "api/batch_jobs.py",
"chars": 2251,
"preview": "import os\n\nfrom django.db.models import Q\n\nfrom api import util\nfrom api.image_similarity import build_image_similarity_"
},
{
"path": "api/burst_detection_rules.py",
"chars": 20247,
"preview": "\"\"\"\nBurst detection rules module for grouping photos taken in rapid succession.\n\nThis module provides a rules-based syst"
},
{
"path": "api/cluster_manager.py",
"chars": 4970,
"preview": "import numpy as np\n\nfrom api.models.cluster import UNKNOWN_CLUSTER_ID, Cluster, get_unknown_cluster\nfrom api.models.face"
},
{
"path": "api/date_time_extractor.py",
"chars": 20372,
"preview": "import json\nimport math\nimport os\nimport pathlib\nimport re\nfrom datetime import datetime\n\nimport pytz\n\nfrom api.metadata"
},
{
"path": "api/directory_watcher/__init__.py",
"chars": 2413,
"preview": "\"\"\"\nDirectory watcher module for scanning and processing photos.\n\nThis module implements a two-phase scan architecture t"
},
{
"path": "api/directory_watcher/file_grouping.py",
"chars": 4378,
"preview": "\"\"\"\nFile grouping utilities for the two-phase scan architecture.\n\nThis module provides functions for grouping related fi"
},
{
"path": "api/directory_watcher/file_handlers.py",
"chars": 14883,
"preview": "\"\"\"\nFile and Photo creation handlers.\n\nThis module contains functions for creating File records and grouping\nthem into P"
},
{
"path": "api/directory_watcher/processing_jobs.py",
"chars": 8293,
"preview": "\"\"\"\nPhoto processing jobs (tags, geolocation, faces).\n\nThese jobs run after the main scan to enrich photos with addition"
},
{
"path": "api/directory_watcher/repair_jobs.py",
"chars": 3394,
"preview": "\"\"\"\nRepair jobs for fixing ungrouped file variants.\n\nThis module contains jobs that repair data inconsistencies, such as"
},
{
"path": "api/directory_watcher/scan_jobs.py",
"chars": 14886,
"preview": "\"\"\"\nMain scan jobs for photo discovery and processing.\n\nThis module contains the core scan_photos function that implemen"
},
{
"path": "api/directory_watcher/utils.py",
"chars": 5204,
"preview": "\"\"\"\nUtility functions for directory scanning and job management.\n\"\"\"\n\nimport os\nimport stat\n\nfrom constance import confi"
},
{
"path": "api/drf_optimize.py",
"chars": 4885,
"preview": "from django.db import ProgrammingError, models\nfrom django.db.models.constants import LOOKUP_SEP\nfrom django.db.models.q"
},
{
"path": "api/duplicate_detection.py",
"chars": 17788,
"preview": "\"\"\"\nDuplicate detection module for finding duplicate photos.\n\nHandles two types of duplicates:\n- EXACT_COPY: Files with "
},
{
"path": "api/face_classify.py",
"chars": 16788,
"preview": "import datetime\nimport uuid\n\nimport numpy as np\nimport seaborn as sns\nfrom bulk_update.helper import bulk_update\nfrom dj"
},
{
"path": "api/face_extractor.py",
"chars": 4415,
"preview": "import numpy as np\nimport PIL\n\nfrom api.face_recognition import get_face_locations\nfrom api.metadata.reader import get_m"
},
{
"path": "api/face_recognition.py",
"chars": 699,
"preview": "import numpy as np\nimport requests\n\n\ndef get_face_encodings(image_path, known_face_locations):\n json = {\n \"sou"
},
{
"path": "api/feature/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "api/feature/embedded_media.py",
"chars": 2134,
"preview": "import os\nfrom mmap import ACCESS_READ, mmap\n\nimport magic\nfrom django.conf import settings\n\nJPEG_EOI_MARKER = b\"\\xff\\xd"
},
{
"path": "api/feature/tests/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "api/feature/tests/test_embedded_media.py",
"chars": 4950,
"preview": "from django.conf import settings\nfrom django.test import override_settings\nfrom rest_framework.test import APIClient, AP"
},
{
"path": "api/filters.py",
"chars": 2278,
"preview": "import datetime\nimport operator\nfrom functools import reduce\n\nfrom django.db.models import Q\nfrom rest_framework import "
},
{
"path": "api/geocode/__init__.py",
"chars": 22,
"preview": "GEOCODE_VERSION = \"1\"\n"
},
{
"path": "api/geocode/config.py",
"chars": 1401,
"preview": "from constance import config as settings\n\nfrom .parsers.mapbox import parse as parse_mapbox\nfrom .parsers.nominatim impo"
},
{
"path": "api/geocode/geocode.py",
"chars": 2381,
"preview": "from typing import List\n\nimport geopy\nfrom constance import config as site_config\n\nfrom api import util\n\nfrom .config im"
},
{
"path": "api/geocode/parsers/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "api/geocode/parsers/mapbox.py",
"chars": 528,
"preview": "from api.geocode import GEOCODE_VERSION\n\n\ndef parse(location):\n context = location.raw[\"context\"]\n center = [locat"
},
{
"path": "api/geocode/parsers/nominatim.py",
"chars": 646,
"preview": "from api.geocode import GEOCODE_VERSION\n\n\ndef parse(location):\n data = location.raw[\"address\"]\n props = [\n "
},
{
"path": "api/geocode/parsers/opencage.py",
"chars": 669,
"preview": "from api.geocode import GEOCODE_VERSION\n\n\ndef parse(location):\n data = location.raw[\"components\"]\n center = [locat"
},
{
"path": "api/geocode/parsers/tomtom.py",
"chars": 1034,
"preview": "from functools import reduce\n\nfrom api.geocode import GEOCODE_VERSION\n\n\ndef _dedup(iterable):\n unique_items = set()\n\n"
},
{
"path": "api/image_captioning.py",
"chars": 2023,
"preview": "import requests\nfrom constance import config as site_config\n\n\ndef generate_caption(image_path, blip=False, prompt=None):"
},
{
"path": "api/image_similarity.py",
"chars": 2979,
"preview": "from datetime import datetime\n\nimport numpy as np\nimport requests\nfrom django.conf import settings\nfrom django.core.pagi"
},
{
"path": "api/llm.py",
"chars": 2646,
"preview": "import requests\nimport base64\nimport io\nfrom PIL import Image\nfrom constance import config as site_config\n\n\ndef image_to"
},
{
"path": "api/management/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "api/management/commands/build_similarity_index.py",
"chars": 410,
"preview": "from django.core.management.base import BaseCommand\nfrom django_q.tasks import AsyncTask\n\nfrom api.image_similarity impo"
},
{
"path": "api/management/commands/clear_cache.py",
"chars": 560,
"preview": "from django.conf import settings\nfrom django.core.cache import cache\nfrom django.core.management.base import BaseCommand"
},
{
"path": "api/management/commands/createadmin.py",
"chars": 2316,
"preview": "import os\nimport sys\n\nfrom django.core.management.base import BaseCommand, CommandError\nfrom django.core.validators impo"
},
{
"path": "api/management/commands/createuser.py",
"chars": 2738,
"preview": "import os\nimport sys\n\nfrom django.core.management.base import BaseCommand, CommandError\nfrom django.core.validators impo"
},
{
"path": "api/management/commands/save_metadata.py",
"chars": 2838,
"preview": "from django.core.management.base import BaseCommand\n\nfrom api.models import Photo, User\nfrom api.models.person import Pe"
},
{
"path": "api/management/commands/scan.py",
"chars": 2528,
"preview": "import traceback\nimport uuid\n\nfrom django.core.management.base import BaseCommand\n\nfrom api.directory_watcher import sca"
},
{
"path": "api/management/commands/start_cleaning_service.py",
"chars": 563,
"preview": "from django.core.management.base import BaseCommand\nfrom django_q.models import Schedule\nfrom django_q.tasks import sche"
},
{
"path": "api/management/commands/start_job_cleanup_service.py",
"chars": 1263,
"preview": "from django.core.management.base import BaseCommand\nfrom django_q.models import Schedule\nfrom django_q.tasks import sche"
},
{
"path": "api/management/commands/start_service.py",
"chars": 1047,
"preview": "from django.core.management.base import BaseCommand\nfrom django_q.models import Schedule\nfrom django_q.tasks import sche"
},
{
"path": "api/metadata/__init__.py",
"chars": 627,
"preview": "# api/metadata — organized metadata reading, writing, and tag constants.\n#\n# Submodules:\n# api.metadata.tags "
},
{
"path": "api/metadata/face_regions.py",
"chars": 6343,
"preview": "import PIL\n\nfrom api.metadata.reader import get_metadata\nfrom api.metadata.tags import Tags\nfrom api.models.face import "
},
{
"path": "api/metadata/reader.py",
"chars": 1543,
"preview": "import os\nimport os.path\n\nimport requests\n\n\ndef get_sidecar_files_in_priority_order(media_file):\n \"\"\"Returns a list o"
},
{
"path": "api/metadata/tags.py",
"chars": 1508,
"preview": "class Tags:\n RATING = \"Rating\"\n IMAGE_HEIGHT = \"ImageHeight\"\n IMAGE_WIDTH = \"ImageWidth\"\n DATE_TIME_ORIGINAL"
},
{
"path": "api/metadata/writer.py",
"chars": 1012,
"preview": "import os\n\nimport exiftool\n\nfrom api.metadata.reader import get_sidecar_files_in_priority_order\nfrom api.util import log"
},
{
"path": "api/middleware.py",
"chars": 662,
"preview": "class FingerPrintMiddleware:\n def __init__(self, get_response):\n self.get_response = get_response\n # On"
},
{
"path": "api/migrations/0001_initial.py",
"chars": 23130,
"preview": "# Generated by Django 2.1.2 on 2020-11-15 18:49\n\nimport datetime\n\nimport django.contrib.auth.models\nimport django.contri"
},
{
"path": "api/migrations/0002_add_confidence.py",
"chars": 334,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \""
},
{
"path": "api/migrations/0003_remove_unused_thumbs.py",
"chars": 551,
"preview": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \"0002_add"
},
{
"path": "api/migrations/0004_fix_album_thing_constraint.py",
"chars": 544,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \""
},
{
"path": "api/migrations/0005_add_video_to_photo.py",
"chars": 313,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \""
},
{
"path": "api/migrations/0006_migrate_to_boolean_field.py",
"chars": 361,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \""
},
{
"path": "api/migrations/0007_migrate_to_json_field.py",
"chars": 758,
"preview": "import json\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n "
},
{
"path": "api/migrations/0008_remove_image_path.py",
"chars": 245,
"preview": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \"0007_mig"
},
{
"path": "api/migrations/0009_add_aspect_ratio.py",
"chars": 1013,
"preview": "import exiftool\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n "
},
{
"path": "api/migrations/0009_add_clip_embedding_field.py",
"chars": 919,
"preview": "from django.contrib.postgres.fields import ArrayField\nfrom django.db import migrations, models\n\n\nclass Migration(migrati"
},
{
"path": "api/migrations/0010_merge_20210725_1547.py",
"chars": 263,
"preview": "# Generated by Django 3.1.8 on 2021-07-25 21:47\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration"
},
{
"path": "api/migrations/0011_a_add_rating.py",
"chars": 459,
"preview": "# Generated by Django 3.1.8 on 2021-08-06 11:32\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0011_b_migrate_favorited_to_rating.py",
"chars": 721,
"preview": "# 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"
},
{
"path": "api/migrations/0011_c_remove_favorited.py",
"chars": 336,
"preview": "# Generated by Django 3.1.8 on 2021-08-06 11:32\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration"
},
{
"path": "api/migrations/0012_add_favorite_min_rating.py",
"chars": 404,
"preview": "# Generated by Django 3.1.8 on 2021-08-08 17:05\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0013_add_image_scale_and_misc.py",
"chars": 2724,
"preview": "# Generated by Django 3.1.8 on 2021-09-09 16:06\n\nimport django.db.models.deletion\nfrom django.db import migrations, mode"
},
{
"path": "api/migrations/0014_add_save_metadata_to_disk.py",
"chars": 614,
"preview": "# Generated by Django 3.1.8 on 2021-08-08 17:05\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0015_add_dominant_color.py",
"chars": 351,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \""
},
{
"path": "api/migrations/0016_add_transcode_videos.py",
"chars": 340,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \""
},
{
"path": "api/migrations/0017_add_cover_photo.py",
"chars": 506,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \""
},
{
"path": "api/migrations/0018_user_config_datetime_rules.py",
"chars": 488,
"preview": "# 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\ncla"
},
{
"path": "api/migrations/0019_change_config_datetime_rules.py",
"chars": 608,
"preview": "# 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\ncla"
},
{
"path": "api/migrations/0020_add_default_timezone.py",
"chars": 503,
"preview": "# 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"
},
{
"path": "api/migrations/0021_remove_photo_image.py",
"chars": 324,
"preview": "# Generated by Django 3.1.14 on 2022-02-01 22:42\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migratio"
},
{
"path": "api/migrations/0022_photo_video_length.py",
"chars": 393,
"preview": "# Generated by Django 3.1.14 on 2022-02-20 11:16\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0023_photo_deleted.py",
"chars": 398,
"preview": "# Generated by Django 3.1.14 on 2022-02-23 21:29\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0024_photo_timestamp.py",
"chars": 404,
"preview": "# Generated by Django 3.1.14 on 2022-03-18 10:35\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0025_add_cover_photo.py",
"chars": 508,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \""
},
{
"path": "api/migrations/0026_add_cluster_info.py",
"chars": 3528,
"preview": "# Generated by Django 3.1.14 on 2022-07-15 21:19\n\nimport django.db.models.deletion\nimport django.db.models.manager\nfrom "
},
{
"path": "api/migrations/0027_rename_unknown_person.py",
"chars": 1210,
"preview": "# Generated by Django 3.1.14 on 2022-07-17 19:07\nfrom django.db import migrations\n\nUNKNOWN_PERSON_NAME = \"Unknown - Othe"
},
{
"path": "api/migrations/0028_add_metadata_fields.py",
"chars": 2152,
"preview": "# Generated by Django 3.1.14 on 2022-07-30 11:47\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0029_change_to_text_field.py",
"chars": 397,
"preview": "# Generated by Django 3.1.14 on 2022-07-31 11:35\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0030_user_confidence_person.py",
"chars": 390,
"preview": "# Generated by Django 3.1.14 on 2022-08-08 13:39\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0031_remove_account.py",
"chars": 434,
"preview": "# Generated by Django 3.1.14 on 2022-09-01 16:28\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migratio"
},
{
"path": "api/migrations/0032_always_have_owner.py",
"chars": 700,
"preview": "# 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_e"
},
{
"path": "api/migrations/0033_add_post_delete_person.py",
"chars": 548,
"preview": "# Generated by Django 3.1.14 on 2022-09-02 10:23\n\nimport django.db.models.deletion\nfrom django.db import migrations, mod"
},
{
"path": "api/migrations/0034_allow_deleting_person.py",
"chars": 585,
"preview": "# Generated by Django 3.1.14 on 2022-09-02 10:26\n\nimport django.db.models.deletion\nfrom django.db import migrations, mod"
},
{
"path": "api/migrations/0035_add_files_model.py",
"chars": 1482,
"preview": "# Generated by Django 3.1.14 on 2022-11-09 17:35\n\nimport django.db.models.deletion\nfrom django.db import migrations, mod"
},
{
"path": "api/migrations/0036_handle_missing_files.py",
"chars": 804,
"preview": "# Generated by Django 3.1.14 on 2022-11-10 08:41\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0037_migrate_to_files.py",
"chars": 1805,
"preview": "import os\n\nfrom django.db import migrations\n\nfrom api.models.file import is_metadata, is_raw, is_video\n\nIMAGE = 1\nVIDEO "
},
{
"path": "api/migrations/0038_add_main_file.py",
"chars": 940,
"preview": "from django.db import migrations\n\nfrom api.models.file import is_metadata, is_raw, is_video\n\nIMAGE = 1\nVIDEO = 2\nMETADAT"
},
{
"path": "api/migrations/0039_remove_photo_image_paths.py",
"chars": 323,
"preview": "# Generated by Django 3.1.14 on 2022-12-21 09:24\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migratio"
},
{
"path": "api/migrations/0040_add_user_public_sharing_flag.py",
"chars": 345,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \""
},
{
"path": "api/migrations/0041_apply_user_enum_for_person.py",
"chars": 638,
"preview": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n def apply_enum(apps, schema_editor):\n "
},
{
"path": "api/migrations/0042_alter_albumuser_cover_photo_alter_photo_main_file.py",
"chars": 915,
"preview": "# Generated by Django 4.2rc1 on 2023-04-04 09:14\n\nimport django.db.models.deletion\nfrom django.db import migrations, mod"
},
{
"path": "api/migrations/0043_alter_photo_size.py",
"chars": 412,
"preview": "# Generated by Django 4.2rc1 on 2023-04-05 07:34\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0044_alter_cluster_person_alter_person_cluster_owner.py",
"chars": 1002,
"preview": "# Generated by Django 4.2rc1 on 2023-04-07 19:02\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom"
},
{
"path": "api/migrations/0045_alter_face_cluster.py",
"chars": 633,
"preview": "# Generated by Django 4.2rc1 on 2023-04-07 19:15\n\nimport django.db.models.deletion\nfrom django.db import migrations, mod"
},
{
"path": "api/migrations/0046_add_embedded_media.py",
"chars": 335,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \""
},
{
"path": "api/migrations/0047_alter_file_embedded_media.py",
"chars": 394,
"preview": "# Generated by Django 4.2rc1 on 2023-04-15 10:42\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0048_fix_null_height.py",
"chars": 259,
"preview": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \"0047_alt"
},
{
"path": "api/migrations/0049_fix_metadata_files_as_main_files.py",
"chars": 423,
"preview": "from django.db import migrations\n\n\ndef delete_photos_with_metadata_as_main(apps, schema_editor):\n Photo = apps.get_mo"
},
{
"path": "api/migrations/0050_person_face_count.py",
"chars": 396,
"preview": "# Generated by Django 4.2.1 on 2023-06-20 09:46\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0051_set_person_defaults.py",
"chars": 1680,
"preview": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n def apply_default(apps, schema_editor):\n "
},
{
"path": "api/migrations/0052_alter_person_name.py",
"chars": 517,
"preview": "# Generated by Django 4.2.1 on 2023-06-26 12:14\n\nimport django.core.validators\nfrom django.db import migrations, models\n"
},
{
"path": "api/migrations/0053_user_confidence_unknown_face_and_more.py",
"chars": 787,
"preview": "# Generated by Django 4.2.1 on 2023-07-09 11:08\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0054_user_cluster_selection_epsilon_user_min_samples.py",
"chars": 568,
"preview": "# Generated by Django 4.2.1 on 2023-07-11 11:06\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0055_alter_longrunningjob_job_type.py",
"chars": 900,
"preview": "# Generated by Django 4.2.6 on 2023-10-27 13:01\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0056_user_llm_settings_alter_longrunningjob_job_type.py",
"chars": 1141,
"preview": "# 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\nclas"
},
{
"path": "api/migrations/0057_remove_face_image_path_and_more.py",
"chars": 549,
"preview": "# Generated by Django 4.2.8 on 2024-01-10 17:12\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0058_alter_user_avatar_alter_user_nextcloud_app_password_and_more.py",
"chars": 1371,
"preview": "# Generated by Django 4.2.9 on 2024-02-02 16:36\n\nimport django_cryptography.fields\nfrom django.db import migrations, mod"
},
{
"path": "api/migrations/0059_person_cover_face.py",
"chars": 617,
"preview": "# Generated by Django 4.2.11 on 2024-03-29 16:03\n\nimport django.db.models.deletion\nfrom django.db import migrations, mod"
},
{
"path": "api/migrations/0060_apply_default_face_cover.py",
"chars": 1041,
"preview": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n def apply_default(apps, schema_editor):\n "
},
{
"path": "api/migrations/0061_alter_person_name.py",
"chars": 554,
"preview": "# Generated by Django 4.2.11 on 2024-03-29 17:20\n\nimport django.core.validators\nfrom django.db import migrations, models"
},
{
"path": "api/migrations/0062_albumthing_cover_photos.py",
"chars": 467,
"preview": "# Generated by Django 4.2.11 on 2024-03-29 17:33\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0063_apply_default_album_things_cover.py",
"chars": 719,
"preview": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n def apply_default(apps, schema_editor):\n "
},
{
"path": "api/migrations/0064_albumthing_photo_count.py",
"chars": 402,
"preview": "# Generated by Django 4.2.11 on 2024-03-30 13:20\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0065_apply_default_photo_count.py",
"chars": 679,
"preview": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n def apply_default(apps, schema_editor):\n "
},
{
"path": "api/migrations/0066_photo_last_modified_alter_longrunningjob_job_type.py",
"chars": 1173,
"preview": "# Generated by Django 4.2.13 on 2024-06-12 15:09\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0067_alter_longrunningjob_job_type.py",
"chars": 1090,
"preview": "# Generated by Django 4.2.13 on 2024-06-16 15:40\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0068_remove_longrunningjob_result_and_more.py",
"chars": 1071,
"preview": "# 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,"
},
{
"path": "api/migrations/0069_rename_to_in_trashcan.py",
"chars": 333,
"preview": "from django.db import migrations\n\n\nclass Migration(migrations.Migration):\n dependencies = [\n (\"api\", \"0068_rem"
},
{
"path": "api/migrations/0070_photo_removed.py",
"chars": 401,
"preview": "# Generated by Django 4.2.14 on 2024-08-21 19:17\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0071_rename_person_label_probability_face_cluster_probability_and_more.py",
"chars": 1616,
"preview": "# Generated by Django 4.2.16 on 2024-09-20 18:56\n\nimport django.db.models.deletion\nfrom django.db import migrations, mod"
},
{
"path": "api/migrations/0072_alter_face_person.py",
"chars": 658,
"preview": "# Generated by Django 4.2.16 on 2024-09-20 19:07\n\nimport django.db.models.deletion\nfrom django.db import migrations, mod"
},
{
"path": "api/migrations/0073_remove_unknown_person.py",
"chars": 1901,
"preview": "from django.db import migrations\n\n\ndef delete_unknown_person_and_update_faces(apps, schema_editor):\n # Get models\n "
},
{
"path": "api/migrations/0074_migrate_cluster_person.py",
"chars": 1567,
"preview": "from django.db import migrations\n\n\ndef move_person_to_cluster_if_kind_cluster(apps, schema_editor):\n # Get the necess"
},
{
"path": "api/migrations/0075_alter_face_cluster_person.py",
"chars": 622,
"preview": "# Generated by Django 4.2.16 on 2024-09-20 19:49\n\nimport django.db.models.deletion\nfrom django.db import migrations, mod"
},
{
"path": "api/migrations/0076_alter_file_path_alter_longrunningjob_job_type_and_more.py",
"chars": 2185,
"preview": "# Generated by Django 4.2.18 on 2025-03-29 12:38\n\nfrom django.db import migrations, models\nimport django_cryptography.fi"
},
{
"path": "api/migrations/0077_alter_albumdate_title.py",
"chars": 491,
"preview": "# Generated by Django 4.2.18 on 2025-03-29 12:59\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0078_create_photo_thumbnail.py",
"chars": 2653,
"preview": "from django.db import migrations, models\nimport django.db.models.deletion\n\n\nclass Migration(migrations.Migration):\n d"
},
{
"path": "api/migrations/0079_alter_albumauto_title.py",
"chars": 416,
"preview": "# Generated by Django 4.2.18 on 2025-05-04 14:28\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations."
},
{
"path": "api/migrations/0080_create_photo_caption.py",
"chars": 3099,
"preview": "# Generated migration for PhotoCaption model\n\nfrom django.db import migrations, models\nimport django.db.models.deletion\n"
},
{
"path": "api/migrations/0081_remove_caption_fields_from_photo.py",
"chars": 459,
"preview": "# Generated migration to remove caption fields from Photo model\n\nfrom django.db import migrations\n\n\nclass Migration(migr"
},
{
"path": "api/migrations/0082_create_photo_search.py",
"chars": 3704,
"preview": "# Generated migration for PhotoSearch model\n\nfrom django.db import migrations, models\nimport django.db.models.deletion\n\n"
},
{
"path": "api/migrations/0083_remove_search_fields.py",
"chars": 484,
"preview": "# Generated migration to remove search fields from Photo and PhotoCaption models\n\nfrom django.db import migrations\n\n\ncla"
},
{
"path": "api/migrations/0084_convert_arrayfield_to_json.py",
"chars": 1937,
"preview": "# Migration to safely convert ArrayField to JSONField for SQLite compatibility\nfrom django.db import migrations, models\n"
},
{
"path": "api/migrations/0085_albumuser_public_expires_at_albumuser_public_slug.py",
"chars": 624,
"preview": "# Generated by Django 5.2.4 on 2025-08-16 08:40\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0086_remove_albumuser_public_and_more.py",
"chars": 1259,
"preview": "# Generated by Django 5.2.4 on 2025-08-17 17:23\n\nimport django.db.models.deletion\nfrom django.db import migrations, mode"
},
{
"path": "api/migrations/0087_add_folder_album.py",
"chars": 1497,
"preview": "# Generated by Django 5.2.4 on 2025-08-22 14:48\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom "
},
{
"path": "api/migrations/0088_remove_folder_album.py",
"chars": 294,
"preview": "# Generated by Django 5.2.4 on 2025-08-22 15:40\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration"
},
{
"path": "api/migrations/0089_add_text_alignment.py",
"chars": 437,
"preview": "# Generated by Django 5.2.4 on 2025-08-22 16:00\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0090_add_header_size.py",
"chars": 457,
"preview": "# Generated by Django 5.2.4 on 2025-08-22 16:27\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0091_alter_user_scan_directory.py",
"chars": 425,
"preview": "# Generated by Django 5.2.4 on 2025-08-30 12:34\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0092_add_skip_raw_files_field.py",
"chars": 396,
"preview": "# Generated by Django 5.2.7 on 2025-10-26 21:54\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0093_migrate_photon_to_nominatim.py",
"chars": 1350,
"preview": "\"\"\"\nMigration to change MAP_API_PROVIDER from 'photon' to 'nominatim'.\n\nPhoton's public API at photon.komoot.io has beco"
},
{
"path": "api/migrations/0094_add_slideshow_interval.py",
"chars": 403,
"preview": "# Generated manually for slideshow interval feature\n\nfrom django.db import migrations, models\n\n\nclass Migration(migratio"
},
{
"path": "api/migrations/0095_photo_perceptual_hash_alter_longrunningjob_job_type_and_more.py",
"chars": 2364,
"preview": "# Generated by Django 5.2.9 on 2025-12-23 09:47\n\nimport api.models.user\nimport django.db.models.deletion\nfrom django.con"
},
{
"path": "api/migrations/0096_add_progress_step_and_result_to_longrunningjob.py",
"chars": 628,
"preview": "# Generated by Django 5.2.9 on 2025-12-23 12:13\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0097_add_duplicate_detection_settings_to_user.py",
"chars": 668,
"preview": "# Generated by Django 5.2.9 on 2025-12-23 13:00\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0098_add_photo_stack.py",
"chars": 4511,
"preview": "# Generated migration for PhotoStack unified grouping system\n\nimport uuid\nfrom django.db import migrations, models\nimpor"
},
{
"path": "api/migrations/0099_photo_uuid_primary_key.py",
"chars": 24981,
"preview": "# Generated migration for Photo UUID primary key\n# This migration changes Photo from using image_hash as PK to using UUI"
},
{
"path": "api/migrations/0100_metadataedit_metadatafile_photometadata_stackreview_and_more.py",
"chars": 11082,
"preview": "# Generated by Django 5.2.9 on 2025-12-25 14:15\n\nimport api.models.user\nimport django.db.models.deletion\nimport uuid\nfro"
},
{
"path": "api/migrations/0101_populate_photo_metadata.py",
"chars": 4677,
"preview": "# Generated migration to populate PhotoMetadata from existing Photo data\n\nfrom django.db import migrations, transaction\n"
},
{
"path": "api/migrations/0102_photo_stacks_manytomany.py",
"chars": 2607,
"preview": "\"\"\"\nMigration to convert Photo.stack ForeignKey to Photo.stacks ManyToManyField.\n\nThis change allows a photo to belong t"
},
{
"path": "api/migrations/0103_remove_photo_metadata_fields.py",
"chars": 2196,
"preview": "# Generated migration to remove deprecated metadata fields from Photo model\n# These fields have been migrated to PhotoMe"
},
{
"path": "api/migrations/0104_remove_photostack_potential_savings_and_more.py",
"chars": 3132,
"preview": "# Generated by Django 5.2.9 on 2025-12-26 15:05\n\nimport api.models.user\nimport django.db.models.deletion\nimport uuid\nfro"
},
{
"path": "api/migrations/0105_alter_photo_image_hash.py",
"chars": 753,
"preview": "# Generated by Django 5.2.9 on 2025-12-26 16:12\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0106_alter_longrunningjob_options.py",
"chars": 439,
"preview": "# Generated by Django 5.2.9 on 2025-12-26 19:35\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration"
},
{
"path": "api/migrations/0107_add_burst_detection_rules.py",
"chars": 499,
"preview": "# Generated by Django 5.0 on 2024-12-26\n\nfrom django.db import migrations, models\n\nimport api.models.user\n\n\nclass Migrat"
},
{
"path": "api/migrations/0108_add_stack_raw_jpeg_field.py",
"chars": 403,
"preview": "# Generated migration for adding stack_raw_jpeg field\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrat"
},
{
"path": "api/migrations/0109_migrate_skip_raw_to_stack_raw_jpeg.py",
"chars": 1264,
"preview": "# Data migration to set stack_raw_jpeg based on skip_raw_files\n# If skip_raw_files was True (skip RAWs), then stack_raw_"
},
{
"path": "api/migrations/0110_fix_file_embedded_media_self_reference.py",
"chars": 435,
"preview": "# Generated migration to fix self-referential ManyToManyField\n\nfrom django.db import migrations, models\n\n\nclass Migratio"
},
{
"path": "api/migrations/0111_alter_file_embedded_media.py",
"chars": 414,
"preview": "# Generated by Django 5.2.9 on 2026-01-08 19:16\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0112_convert_file_stacks_to_variants.py",
"chars": 12825,
"preview": "\"\"\"\nMigration to convert RAW_JPEG_PAIR and LIVE_PHOTO stacks to file variants.\n\nThis migration implements the PhotoPrism"
},
{
"path": "api/migrations/0113_alter_photostack_stack_type.py",
"chars": 625,
"preview": "# Generated by Django 5.2.9 on 2026-01-21 13:36\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0114_add_file_path_unique.py",
"chars": 6690,
"preview": "# Generated migration to add unique constraint on File.path\n# This migration handles existing duplicate paths before add"
},
{
"path": "api/migrations/0115_cleanup_duplicate_photos.py",
"chars": 9164,
"preview": "# Generated migration to cleanup duplicate Photo records\n# This migration handles Photos with the same image_hash for th"
},
{
"path": "api/migrations/0116_cleanup_duplicate_groups_removed_photos.py",
"chars": 2109,
"preview": "# Generated migration to clean up Duplicate groups containing removed photos\n# This removes removed=True photos from gro"
},
{
"path": "api/migrations/0117_delete_removed_photos.py",
"chars": 1405,
"preview": "# Generated migration to delete removed photos\n# These are duplicate photos that were merged in migration 0115\n\nfrom dja"
},
{
"path": "api/migrations/0118_alter_longrunningjob_job_type.py",
"chars": 845,
"preview": "# Generated by Django 5.2.9 on 2026-01-28 13:43\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.M"
},
{
"path": "api/migrations/0119_add_public_sharing_options.py",
"chars": 1550,
"preview": "# Generated migration for public sharing options\n\nfrom django.db import migrations, models\nimport api.models.user\n\n\nclas"
},
{
"path": "api/migrations/0120_rename_thumbnails_uuid_to_hash.py",
"chars": 5095,
"preview": "# Migration to rename thumbnail files from UUID to image_hash\n# This migration fixes the thumbnail naming issue where th"
},
{
"path": "api/migrations/0121_add_default_tagging_model.py",
"chars": 1394,
"preview": "\"\"\"\nMigration to add a default TAGGING_MODEL entry to the constance database.\n\nWhen TAGGING_MODEL was added to CONSTANCE"
},
{
"path": "api/migrations/0121_user_save_face_tags_to_disk.py",
"chars": 360,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n dependencies = [\n (\"api\", "
},
{
"path": "api/migrations/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "api/ml_models.py",
"chars": 10272,
"preview": "import math\nimport os\nimport tarfile\nfrom pathlib import Path\n\nimport requests\nfrom constance import config as site_conf"
},
{
"path": "api/models/__init__.py",
"chars": 1220,
"preview": "from api.models.album_auto import AlbumAuto\nfrom api.models.album_date import AlbumDate\nfrom api.models.album_place impo"
},
{
"path": "api/models/album_auto.py",
"chars": 3938,
"preview": "from collections import Counter\n\nfrom django.db import models\n\nfrom api import util\nfrom api.models.person import Person"
},
{
"path": "api/models/album_date.py",
"chars": 1415,
"preview": "from django.db import models\n\nfrom api.models.photo import Photo\nfrom api.models.user import User, get_deleted_user\n\n\ncl"
},
{
"path": "api/models/album_place.py",
"chars": 821,
"preview": "from django.db import models\n\nfrom api.models.photo import Photo\nfrom api.models.user import User, get_deleted_user\n\n\ncl"
},
{
"path": "api/models/album_thing.py",
"chars": 2076,
"preview": "from django.db import models\nfrom django.db.models.signals import m2m_changed\nfrom django.dispatch import receiver\n\nfrom"
},
{
"path": "api/models/album_user.py",
"chars": 867,
"preview": "from django.db import models\n\nfrom api.models.photo import Photo\nfrom api.models.user import User, get_deleted_user\n\n\ncl"
}
]
// ... and 223 more files (download for full content)
About this extraction
This page contains the full source code of the LibrePhotos/librephotos GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 423 files (2.3 MB), approximately 616.2k tokens, and a symbol index with 3220 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.