├── .coveragerc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── enhancement-request.md └── workflows │ ├── docker-publish.yml │ └── pre-commit.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── api ├── __init__.py ├── admin.py ├── all_tasks.py ├── api_util.py ├── apps.py ├── autoalbum.py ├── background_tasks.py ├── batch_jobs.py ├── cluster_manager.py ├── date_time_extractor.py ├── directory_watcher.py ├── drf_optimize.py ├── exif_tags.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 │ │ ├── photon.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_service.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 │ └── __init__.py ├── ml_models.py ├── models │ ├── __init__.py │ ├── album_auto.py │ ├── album_date.py │ ├── album_place.py │ ├── album_thing.py │ ├── album_user.py │ ├── cache.py │ ├── cluster.py │ ├── face.py │ ├── file.py │ ├── long_running_job.py │ ├── person.py │ ├── photo.py │ ├── photo_caption.py │ ├── photo_search.py │ ├── thumbnail.py │ └── user.py ├── nextcloud.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 │ ├── photos.py │ ├── simple.py │ └── user.py ├── services.py ├── social_graph.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 │ │ │ │ ├── photon.py │ │ │ │ └── tomtom.py │ │ │ └── responses │ │ │ │ ├── mapbox.py │ │ │ │ ├── nominatim.py │ │ │ │ ├── opencage.py │ │ │ │ ├── photon.py │ │ │ │ └── tomtom.py │ │ ├── location_timeline_test_data.csv │ │ ├── niaz.jpg │ │ └── niaz.xmp │ ├── test_api_util.py │ ├── test_delete_duplicate_photos.py │ ├── test_delete_photos.py │ ├── test_directory_watcher_fix.py │ ├── test_dirtree.py │ ├── test_edit_photo_details.py │ ├── test_favorite_photos.py │ ├── test_geocode.py │ ├── test_get_faces.py │ ├── test_hide_photos.py │ ├── test_im2txt.py │ ├── test_location_timeline.py │ ├── test_only_photos_or_only_videos.py │ ├── test_photo_caption_model.py │ ├── test_photo_captions.py │ ├── test_photo_list_without_timestamp.py │ ├── test_photo_model_integration.py │ ├── test_photo_search_model.py │ ├── test_photo_search_refactor.py │ ├── test_photo_summary.py │ ├── test_predefined_rules.py │ ├── test_public_photos.py │ ├── test_reading_exif.py │ ├── test_recently_added_photos.py │ ├── test_regenerate_titles.py │ ├── test_retrieve_photo.py │ ├── test_scan_photos.py │ ├── test_search_term_examples.py │ ├── test_search_terms.py │ ├── test_setup_directory.py │ ├── test_share_photos.py │ ├── test_user.py │ ├── test_zip_list_photos_view_v2.py │ └── utils.py ├── thumbnails.py ├── util.py └── views │ ├── __init__.py │ ├── album_auto.py │ ├── albums.py │ ├── custom_api_view.py │ ├── dataviz.py │ ├── faces.py │ ├── jobs.py │ ├── pagination.py │ ├── photos.py │ ├── search.py │ ├── services.py │ ├── sharing.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 ├── 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 ├── screenshots ├── lp-square-black.png ├── lp-white.png ├── mockups_main_fhd.png ├── more_to_discover.png ├── photo_info_fhd.png ├── photo_manage.png └── site-logo.png └── 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 └── thumbnail ├── __init__.py ├── main.py └── test ├── .gitignore ├── __init__.py ├── samples ├── .gitkeep └── README.md └── test_thumbnail_worker.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | [report] 5 | skip_covered = True 6 | skip_empty = True 7 | # show_missing = True 8 | 9 | [html] 10 | skip_covered = True 11 | skip_empty = True 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: derneuere 2 | custom: https://www.paypal.com/donate/?hosted_button_id=5JWVM2UR4LM96 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | # 🛑 Before you create an issue make sure that: 11 | - Your issue is **strictly related to LibrePhotos** itself. Questions about setting up a reverse proxy belong in what ever reverse proxy you are using. 12 | - You have read the [documentation](https://docs.librephotos.com) thoroughly. 13 | - You have searched for a similar issue among all the former issues (even closed ones). 14 | - You have tried to replicate the issue with a clean install of the project. 15 | - You have asked for help on our Discord server [LibrePhotos](https://discord.gg/xwRvtSDGWb) if your issue involves general "how to" questions 16 | 17 | **When Submitting please remove every thing above this line** 18 | 19 | 20 | # 🐛 Bug Report 21 | 22 | * [ ] 📁 I've Included a ZIP file containing my librephotos `log` files 23 | * [ ] ❌ I have looked for similar issues (including closed ones) 24 | * [ ] 🎬 (If applicable) I've provided pictures or links to videos that clearly demonstrate the issue 25 | 26 | ## 📝 Description of issue: 27 | 28 | 29 | ## 🔁 How can we reproduce it: 30 | 31 | 32 | ## Please provide additional information: 33 | - 💻 Operating system: 34 | - ⚙ Architecture (x86 or ARM): 35 | - 🔢 Librephotos version: 36 | - 📸 Librephotos installation method (Docker, Kubernetes, .deb, etc.): 37 | * 🐋 If Docker or Kubernets, provide docker-compose image tag: 38 | - 📁 How is you picture library mounted (Local file system (Type), NFS, SMB, etc.): 39 | - ☁ If you are virtualizing librephotos, Virtualization platform (Proxmox, Xen, HyperV, etc.): 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the enhancement you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe why this will benefit the LibrePhotos** 14 | A clear and concise explanation on why this will make LibrePhotos better. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the enhancement request here. 18 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Publish `dev` as Docker `latest` image. 4 | branches: 5 | - dev 6 | 7 | jobs: 8 | # Run tests. 9 | # See also https://docs.docker.com/docker-hub/builds/automated-testing/ 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 15 | - name: Run tests 16 | run: echo "to-do" 17 | 18 | # Push image to GitHub Packages. 19 | # See also https://docs.docker.com/docker-hub/builds/ 20 | push: 21 | # Ensure test job passes before pushing image. 22 | needs: test 23 | 24 | runs-on: ubuntu-latest 25 | if: github.event_name == 'push' 26 | 27 | steps: 28 | - name: Repository Dispatch 29 | uses: peter-evans/repository-dispatch@v3 30 | with: 31 | token: ${{ secrets.REPO_ACCESS_TOKEN }} 32 | repository: librephotos/librephotos-docker 33 | event-type: backend-commit-event 34 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: Linting (using pre-commit) 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | pre-commit: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Install dependencies 11 | run: pip install ruff 12 | - name: Run pre-commit check 13 | uses: pre-commit/action@v3.0.1 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | 4 | repos: 5 | - repo: https://github.com/astral-sh/ruff-pre-commit 6 | rev: v0.9.4 # Check latest version 7 | hooks: 8 | - id: ruff 9 | args: ["--fix"] # Fix lint & format 10 | 11 | - id: ruff-format # Format code 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Hooram Nam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "api.apps.ApiConfig" 2 | -------------------------------------------------------------------------------- /api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | name = "api" 6 | verbose_name = "LibrePhotos" 7 | -------------------------------------------------------------------------------- /api/background_tasks.py: -------------------------------------------------------------------------------- 1 | from tqdm import tqdm 2 | from django.db import models 3 | 4 | from api.models import Photo 5 | from api.util import logger 6 | 7 | 8 | def generate_captions(overwrite=False): 9 | if overwrite: 10 | photos = Photo.objects.all() 11 | else: 12 | # Find photos that don't have search captions in PhotoSearch model 13 | photos = Photo.objects.filter( 14 | models.Q(search_instance__isnull=True) 15 | | models.Q(search_instance__search_captions__isnull=True) 16 | ) 17 | logger.info("%d photos to be processed for caption generation" % photos.count()) 18 | for photo in photos: 19 | logger.info("generating captions for %s" % photo.main_file.path) 20 | photo._generate_captions() 21 | photo.save() 22 | 23 | 24 | def geolocate(overwrite=False): 25 | if overwrite: 26 | photos = Photo.objects.all() 27 | else: 28 | photos = Photo.objects.filter(geolocation_json={}) 29 | logger.info("%d photos to be geolocated" % photos.count()) 30 | for photo in photos: 31 | try: 32 | logger.info("geolocating %s" % photo.main_file.path) 33 | photo._geolocate() 34 | photo._add_location_to_album_dates() 35 | except Exception: 36 | logger.exception("could not geolocate photo: " + photo) 37 | 38 | 39 | def add_photos_to_album_things(): 40 | photos = Photo.objects.all() 41 | for photo in tqdm(photos): 42 | photo._add_to_album_place() 43 | -------------------------------------------------------------------------------- /api/exif_tags.py: -------------------------------------------------------------------------------- 1 | class Tags: 2 | RATING = "Rating" 3 | IMAGE_HEIGHT = "ImageHeight" 4 | IMAGE_WIDTH = "ImageWidth" 5 | DATE_TIME_ORIGINAL = "EXIF:DateTimeOriginal" 6 | DATE_TIME = "EXIF:DateTime" 7 | QUICKTIME_CREATE_DATE = "QuickTime:CreateDate" 8 | QUICKTIME_DURATION = "QuickTime:Duration" 9 | LATITUDE = "Composite:GPSLatitude" 10 | LONGITUDE = "Composite:GPSLongitude" 11 | GPS_DATE_TIME = "Composite:GPSDateTime" 12 | FILE_SIZE = "File:FileSize" 13 | FSTOP = "EXIF:FNumber" 14 | EXPOSURE_TIME = "EXIF:ExposureTime" 15 | ISO = "EXIF:ISOSpeedRatings" 16 | FOCAL_LENGTH = "EXIF:FocalLength" 17 | FOCAL_LENGTH_35MM = "EXIF:FocalLengthIn35mmFilm" 18 | SHUTTER_SPEED = "EXIF:ShutterSpeedValue" 19 | CAMERA = "EXIF:Model" 20 | LENS = "EXIF:LensModel" 21 | SUBJECT_DISTANCE = "EXIF:SubjectDistance" 22 | DIGITAL_ZOOM_RATIO = "EXIF:DigitalZoomRatio" 23 | REGION_INFO = "XMP:RegionInfo" 24 | ROTATION = "QuickTime:Rotation" 25 | ORIENTATION = "EXIF:Orientation" 26 | -------------------------------------------------------------------------------- /api/face_recognition.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import requests 3 | 4 | 5 | def get_face_encodings(image_path, known_face_locations): 6 | json = { 7 | "source": image_path, 8 | "face_locations": known_face_locations, 9 | } 10 | face_encoding = requests.post( 11 | "http://localhost:8005/face-encodings", json=json 12 | ).json() 13 | 14 | face_encodings_list = face_encoding["encodings"] 15 | face_encodings = [np.array(enc) for enc in face_encodings_list] 16 | 17 | return face_encodings 18 | 19 | 20 | def get_face_locations(image_path, model="hog"): 21 | json = {"source": image_path, "model": model} 22 | face_locations = requests.post( 23 | "http://localhost:8005/face-locations", json=json 24 | ).json() 25 | return face_locations["face_locations"] 26 | -------------------------------------------------------------------------------- /api/feature/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/api/feature/__init__.py -------------------------------------------------------------------------------- /api/feature/embedded_media.py: -------------------------------------------------------------------------------- 1 | import os 2 | from mmap import ACCESS_READ, mmap 3 | 4 | import magic 5 | from django.conf import settings 6 | 7 | JPEG_EOI_MARKER = b"\xff\xd9" 8 | GOOGLE_PIXEL_MOTION_PHOTO_MP4_SIGNATURES = [b"ftypmp42", b"ftypisom", b"ftypiso2"] 9 | 10 | # in reality Samsung motion photo marker will look something like this 11 | # ........Image_UTC_Data1458170015363SEFHe...........#...#.......SEFT..0.....MotionPhoto_Data 12 | # but we are interested only in the content of the video which is right after MotionPhoto_Data 13 | SAMSUNG_MOTION_PHOTO_MARKER = b"MotionPhoto_Data" 14 | 15 | 16 | def _locate_embedded_video_google(data): 17 | signatures = GOOGLE_PIXEL_MOTION_PHOTO_MP4_SIGNATURES 18 | for signature in signatures: 19 | position = data.find(signature) 20 | if position != -1: 21 | return position - 4 22 | return -1 23 | 24 | 25 | def _locate_embedded_video_samsung(data): 26 | position = data.find(SAMSUNG_MOTION_PHOTO_MARKER) 27 | if position != -1: 28 | return position + len(SAMSUNG_MOTION_PHOTO_MARKER) 29 | return -1 30 | 31 | 32 | def has_embedded_media(path: str) -> bool: 33 | mime = magic.Magic(mime=True) 34 | mime_type = mime.from_file(path) 35 | if mime_type != "image/jpeg": 36 | return False 37 | with open(path, "rb") as image: 38 | with mmap(image.fileno(), 0, access=ACCESS_READ) as mm: 39 | return ( 40 | _locate_embedded_video_samsung(mm) != -1 41 | or _locate_embedded_video_google(mm) != -1 42 | ) 43 | 44 | 45 | def extract_embedded_media(path: str, hash: str) -> str | None: 46 | with open(str(path), "rb") as image: 47 | with mmap(image.fileno(), 0, access=ACCESS_READ) as mm: 48 | position = _locate_embedded_video_google( 49 | mm 50 | ) or _locate_embedded_video_google(mm) 51 | if position == -1: 52 | return None 53 | output_dir = f"{settings.MEDIA_ROOT}/embedded_media" 54 | if not os.path.exists(output_dir): 55 | os.makedirs(output_dir) 56 | output_path = f"{output_dir}/{hash}_1.mp4" 57 | with open(output_path, "wb+") as video: 58 | mm.seek(position) 59 | data = mm.read(mm.size()) 60 | video.write(data) 61 | return output_path 62 | -------------------------------------------------------------------------------- /api/feature/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/api/feature/tests/__init__.py -------------------------------------------------------------------------------- /api/filters.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import operator 3 | from functools import reduce 4 | 5 | from django.db.models import Q 6 | from rest_framework import filters 7 | 8 | from api import util 9 | from api.image_similarity import search_similar_embedding 10 | from api.semantic_search import calculate_query_embeddings 11 | 12 | 13 | class SemanticSearchFilter(filters.SearchFilter): 14 | def filter_queryset(self, request, queryset, view): 15 | search_fields = self.get_search_fields(view, request) 16 | search_terms = self.get_search_terms(request) 17 | 18 | if not search_fields or not search_terms: 19 | return queryset 20 | 21 | orm_lookups = [ 22 | self.construct_search(str(search_field), queryset=queryset) 23 | for search_field in search_fields 24 | ] 25 | 26 | if request.user.semantic_search_topk > 0: 27 | query = request.query_params.get("search") 28 | start = datetime.datetime.now() 29 | emb, magnitude = calculate_query_embeddings(query) 30 | elapsed = (datetime.datetime.now() - start).total_seconds() 31 | util.logger.info( 32 | "finished calculating query embedding - took %.2f seconds" % (elapsed) 33 | ) 34 | start = datetime.datetime.now() 35 | image_hashes = search_similar_embedding( 36 | request.user.id, emb, request.user.semantic_search_topk, threshold=27 37 | ) 38 | elapsed = (datetime.datetime.now() - start).total_seconds() 39 | util.logger.info("search similar embedding - took %.2f seconds" % (elapsed)) 40 | conditions = [] 41 | for search_term in search_terms: 42 | queries = [Q(**{orm_lookup: search_term}) for orm_lookup in orm_lookups] 43 | 44 | if request.user.semantic_search_topk > 0: 45 | queries += [Q(image_hash__in=image_hashes)] 46 | 47 | conditions.append(reduce(operator.or_, queries)) 48 | queryset = queryset.filter(reduce(operator.and_, conditions)) 49 | 50 | if self.must_call_distinct(queryset, search_fields): 51 | # Filtering against a many-to-many field requires us to 52 | # call queryset.distinct() in order to avoid duplicate items 53 | # in the resulting queryset. 54 | # We try to avoid this if possible, for performance reasons. 55 | queryset = queryset.distinct() 56 | return queryset 57 | -------------------------------------------------------------------------------- /api/geocode/__init__.py: -------------------------------------------------------------------------------- 1 | GEOCODE_VERSION = "1" 2 | -------------------------------------------------------------------------------- /api/geocode/config.py: -------------------------------------------------------------------------------- 1 | from constance import config as settings 2 | 3 | from .parsers.mapbox import parse as parse_mapbox 4 | from .parsers.nominatim import parse as parse_nominatim 5 | from .parsers.opencage import parse as parse_opencage 6 | from .parsers.photon import parse as parse_photon 7 | from .parsers.tomtom import parse as parse_tomtom 8 | 9 | 10 | def _get_config(): 11 | return { 12 | "mapbox": { 13 | "geocode_args": {"api_key": settings.MAP_API_KEY}, 14 | "parser": parse_mapbox, 15 | }, 16 | "maptiler": { 17 | "geocode_args": {"api_key": settings.MAP_API_KEY}, 18 | "parser": parse_mapbox, 19 | }, 20 | "tomtom": { 21 | "geocode_args": {"api_key": settings.MAP_API_KEY}, 22 | "parser": parse_tomtom, 23 | }, 24 | "photon": { 25 | "geocode_args": { 26 | "domain": "photon.komoot.io", 27 | }, 28 | "parser": parse_photon, 29 | }, 30 | "nominatim": { 31 | "geocode_args": {"user_agent": "librephotos"}, 32 | "parser": parse_nominatim, 33 | }, 34 | "opencage": { 35 | "geocode_args": { 36 | "api_key": settings.MAP_API_KEY, 37 | }, 38 | "parser": parse_opencage, 39 | }, 40 | } 41 | 42 | 43 | def get_provider_config(provider) -> dict: 44 | config = _get_config() 45 | if provider not in config: 46 | raise Exception(f"Map provider not found: {provider}.") 47 | return config[provider]["geocode_args"] 48 | 49 | 50 | def get_provider_parser(provider) -> callable: 51 | config = _get_config() 52 | if provider not in config: 53 | raise Exception(f"Map provider not found: {provider}.") 54 | return config[provider]["parser"] 55 | -------------------------------------------------------------------------------- /api/geocode/geocode.py: -------------------------------------------------------------------------------- 1 | import geopy 2 | from constance import config as site_config 3 | 4 | from api import util 5 | 6 | from .config import get_provider_config, get_provider_parser 7 | 8 | 9 | class Geocode: 10 | def __init__(self, provider): 11 | self._provider_config = get_provider_config(provider) 12 | self._parser = get_provider_parser(provider) 13 | self._geocoder = geopy.get_geocoder_for_service(provider)( 14 | **self._provider_config 15 | ) 16 | 17 | def reverse(self, lat: float, lon: float) -> dict: 18 | if ( 19 | "geocode_args" in self._provider_config 20 | and "api_key" in self._provider_config["geocode_args"] 21 | and self._provider_config["geocode_args"]["api_key"] is None 22 | ): 23 | util.logger.warning( 24 | "No API key found for map provider. Please set MAP_API_KEY in the admin panel or switch map provider." 25 | ) 26 | return {} 27 | location = self._geocoder.reverse(f"{lat},{lon}") 28 | return self._parser(location) 29 | 30 | 31 | def reverse_geocode(lat: float, lon: float) -> dict: 32 | try: 33 | return Geocode(site_config.MAP_API_PROVIDER).reverse(lat, lon) 34 | except Exception as e: 35 | util.logger.warning(f"Error while reverse geocoding: {e}") 36 | return {} 37 | -------------------------------------------------------------------------------- /api/geocode/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/api/geocode/parsers/__init__.py -------------------------------------------------------------------------------- /api/geocode/parsers/mapbox.py: -------------------------------------------------------------------------------- 1 | from api.geocode import GEOCODE_VERSION 2 | 3 | 4 | def parse(location): 5 | context = location.raw["context"] 6 | center = [location.raw["center"][1], location.raw["center"][0]] 7 | local_name = location.raw["text"] 8 | places = [local_name] + [ 9 | i["text"] for i in context if not i["id"].startswith("post") 10 | ] 11 | return { 12 | "features": [{"text": place, "center": center} for place in places], 13 | "places": places, 14 | "address": location.address, 15 | "center": center, 16 | "_v": GEOCODE_VERSION, 17 | } 18 | -------------------------------------------------------------------------------- /api/geocode/parsers/nominatim.py: -------------------------------------------------------------------------------- 1 | from api.geocode import GEOCODE_VERSION 2 | 3 | 4 | def parse(location): 5 | data = location.raw["address"] 6 | props = [ 7 | "road", 8 | "town", 9 | "neighbourhood", 10 | "suburb", 11 | "hamlet", 12 | "borough", 13 | "city", 14 | "county", 15 | "state", 16 | "country", 17 | ] 18 | places = [data[prop] for prop in props if prop in data] 19 | center = [float(location.raw["lat"]), float(location.raw["lon"])] 20 | return { 21 | "features": [{"text": place, "center": center} for place in places], 22 | "places": places, 23 | "address": location.address, 24 | "center": center, 25 | "_v": GEOCODE_VERSION, 26 | } 27 | -------------------------------------------------------------------------------- /api/geocode/parsers/opencage.py: -------------------------------------------------------------------------------- 1 | from api.geocode import GEOCODE_VERSION 2 | 3 | 4 | def parse(location): 5 | data = location.raw["components"] 6 | center = [location.raw["geometry"]["lat"], location.raw["geometry"]["lng"]] 7 | props = [ 8 | data["_type"], 9 | "road", 10 | "suburb", 11 | "municipality", 12 | "hamlet", 13 | "towncity", 14 | "borough", 15 | "state", 16 | "county", 17 | "country", 18 | ] 19 | places = [data[prop] for prop in props if prop in data] 20 | return { 21 | "features": [{"text": place, "center": center} for place in places], 22 | "places": places, 23 | "address": location.address, 24 | "center": center, 25 | "_v": GEOCODE_VERSION, 26 | } 27 | -------------------------------------------------------------------------------- /api/geocode/parsers/photon.py: -------------------------------------------------------------------------------- 1 | from api.geocode import GEOCODE_VERSION 2 | 3 | 4 | def parse(location): 5 | data = location.raw["properties"] 6 | props = [ 7 | "street", 8 | "locality", 9 | "district", 10 | "city", 11 | "state", 12 | "country", 13 | ] 14 | places = [data[prop] for prop in props if prop in data] 15 | center = [ 16 | float(location.raw["geometry"]["coordinates"][1]), 17 | float(location.raw["geometry"]["coordinates"][0]), 18 | ] 19 | return { 20 | "features": [{"text": place, "center": center} for place in places], 21 | "places": places, 22 | "address": location.address, 23 | "center": center, 24 | "_v": GEOCODE_VERSION, 25 | } 26 | -------------------------------------------------------------------------------- /api/geocode/parsers/tomtom.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | 3 | from api.geocode import GEOCODE_VERSION 4 | 5 | 6 | def _dedup(iterable): 7 | unique_items = set() 8 | 9 | def reducer(acc, item): 10 | if item not in unique_items: 11 | unique_items.add(item) 12 | acc.append(item) 13 | return acc 14 | 15 | return reduce(reducer, iterable, []) 16 | 17 | 18 | def parse(location): 19 | data = location.raw["address"] 20 | address = location.address 21 | center = list(map(lambda x: float(x), location.raw["position"].split(","))) 22 | props = [ 23 | "street", 24 | "streetName", 25 | "municipalitySubdivision", 26 | "countrySubdivision", 27 | "countrySecondarySubdivision", 28 | "municipality", 29 | "municipalitySubdivision", 30 | "country", 31 | ] 32 | places = _dedup( 33 | [data[prop] for prop in props if prop in data and len(data[prop]) > 2] 34 | ) 35 | return { 36 | "features": [{"text": place, "center": center} for place in places], 37 | "places": places, 38 | "address": address, 39 | "center": center, 40 | "_v": GEOCODE_VERSION, 41 | } 42 | -------------------------------------------------------------------------------- /api/image_captioning.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from constance import config as site_config 3 | 4 | 5 | def generate_caption(image_path, blip=False, prompt=None): 6 | # Check if Moondream is selected as captioning model 7 | if site_config.CAPTIONING_MODEL == "moondream": 8 | # Use custom prompt if provided, otherwise use default caption prompt 9 | if prompt is None: 10 | prompt = "Describe this image in a short, concise caption." 11 | 12 | json_data = { 13 | "image_path": image_path, 14 | "prompt": prompt, 15 | "max_tokens": 256, 16 | } 17 | try: 18 | response = requests.post("http://localhost:8008/generate", json=json_data) 19 | 20 | if response.status_code != 201: 21 | print( 22 | f"Error with Moondream captioning service: HTTP {response.status_code} - {response.text}" 23 | ) 24 | return "Error generating caption with Moondream: Service unavailable" 25 | 26 | response_data = response.json() 27 | return response_data["response"] 28 | except requests.exceptions.ConnectionError: 29 | print( 30 | "Error with Moondream captioning service: Cannot connect to LLM service on port 8008" 31 | ) 32 | return "Error generating caption with Moondream: Service unavailable" 33 | except requests.exceptions.Timeout: 34 | print("Error with Moondream captioning service: Request timeout") 35 | return "Error generating caption with Moondream: Request timeout" 36 | except Exception as e: 37 | print(f"Error with Moondream captioning service: {e}") 38 | return "Error generating caption with Moondream" 39 | 40 | # Original implementation for other models 41 | json_data = { 42 | "image_path": image_path, 43 | "onnx": False, 44 | "blip": blip, 45 | } 46 | caption_response = requests.post( 47 | "http://localhost:8007/generate-caption", json=json_data 48 | ).json() 49 | 50 | return caption_response["caption"] 51 | 52 | 53 | def unload_model(): 54 | requests.get("http://localhost:8007/unload-model") 55 | -------------------------------------------------------------------------------- /api/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/api/management/__init__.py -------------------------------------------------------------------------------- /api/management/commands/build_similarity_index.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django_q.tasks import AsyncTask 3 | 4 | from api.image_similarity import build_image_similarity_index 5 | from api.models import User 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Build image similarity index for all users" 10 | 11 | def handle(self, *args, **kwargs): 12 | for user in User.objects.all(): 13 | AsyncTask(build_image_similarity_index, user).run() 14 | -------------------------------------------------------------------------------- /api/management/commands/clear_cache.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.cache import cache 3 | from django.core.management.base import BaseCommand, CommandError 4 | 5 | 6 | class Command(BaseCommand): 7 | """A simple management command which clears the site-wide cache.""" 8 | 9 | help = "Fully clear your site-wide cache." 10 | 11 | def handle(self, *args, **kwargs): 12 | try: 13 | assert settings.CACHES 14 | cache.clear() 15 | self.stdout.write("Your cache has been cleared!\n") 16 | except AttributeError: 17 | raise CommandError("You have no cache configured!\n") 18 | -------------------------------------------------------------------------------- /api/management/commands/createadmin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from django.core.management.base import BaseCommand, CommandError 5 | from django.core.validators import ValidationError, validate_email 6 | 7 | from api.models import User 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Create a LibrePhotos user with administrative permissions" 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument("admin_username", help="Username to create") 15 | parser.add_argument("admin_email", help="Email address of the new user") 16 | parser.add_argument( 17 | "-u", 18 | "--update", 19 | help=( 20 | "Update an existing superuser's password (ignoring the" 21 | "provided email) instead of reporting an error" 22 | ), 23 | action="store_true", 24 | ) 25 | # Done this way because command lines are visible to the whole system by 26 | # default on Linux, so a password in the arguments would leak 27 | parser.epilog = ( 28 | "The password is read from the ADMIN_PASSWORD" 29 | "environment variable or interactively if" 30 | "ADMIN_PASSWORD is not set" 31 | ) 32 | 33 | def handle(self, *args, **options): 34 | try: 35 | validate_email(options["admin_email"]) 36 | except ValidationError as err: 37 | raise CommandError(err.message) 38 | 39 | if "ADMIN_PASSWORD" in os.environ: 40 | options["admin_password"] = os.environ["ADMIN_PASSWORD"] 41 | else: 42 | options["admin_password"] = User.objects.make_random_password() 43 | 44 | if not options["admin_password"]: 45 | raise CommandError("Admin password cannot be empty") 46 | 47 | if not User.objects.filter(username=options["admin_username"].lower()).exists(): 48 | User.objects.create_superuser( 49 | options["admin_username"].lower(), 50 | options["admin_email"], 51 | options["admin_password"], 52 | ) 53 | elif options["update"]: 54 | print( 55 | "Warning: ignoring provided email " + options["admin_email"], 56 | file=sys.stderr, 57 | ) 58 | admin_user = User.objects.get(username=options["admin_username"].lower()) 59 | admin_user.set_password(options["admin_password"]) 60 | admin_user.save() 61 | else: 62 | raise CommandError("Specified user already exists") 63 | -------------------------------------------------------------------------------- /api/management/commands/save_metadata.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from api.models import Photo 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "save metadata to image files (or XMP sidecar files)" 8 | 9 | def handle(self, *args, **kwargs): 10 | for photo in Photo.objects.all(): 11 | photo._save_metadata() 12 | -------------------------------------------------------------------------------- /api/management/commands/scan.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | import uuid 3 | 4 | from django.core.management.base import BaseCommand 5 | 6 | from api.directory_watcher import scan_photos 7 | from api.models import User 8 | from api.models.user import get_deleted_user 9 | from nextcloud.directory_watcher import scan_photos as scan_photos_nextcloud 10 | 11 | 12 | class Command(BaseCommand): 13 | help = "scan directory for all users" 14 | 15 | def add_arguments(self, parser): 16 | parser_group = parser.add_mutually_exclusive_group() 17 | parser_group.add_argument( 18 | "-f", "--full-scan", help=("Run full directory scan"), action="store_true" 19 | ) 20 | parser_group.add_argument( 21 | "-s", "--scan-files", help=("Scan a list of files"), nargs="+", default=[] 22 | ) 23 | parser_group.add_argument( 24 | "-n", 25 | "--nextcloud", 26 | help=("Run nextcloud scan instead of directory scan"), 27 | action="store_true", 28 | ) 29 | 30 | def handle(self, *args, **options): 31 | # Nextcloud scan 32 | if options["nextcloud"]: 33 | self.nextcloud_scan() 34 | return 35 | 36 | # Add a single file. 37 | if options["scan_files"]: 38 | scan_files = options["scan_files"] 39 | deleted_user: User = get_deleted_user() 40 | for user in User.objects.all(): 41 | user_files = [] 42 | if user == deleted_user: 43 | continue 44 | for scan_file in scan_files: 45 | if scan_file.startswith(user.scan_directory): 46 | user_files.append(scan_file) 47 | if user_files: 48 | scan_photos(user, False, uuid.uuid4(), scan_files=user_files) 49 | return 50 | 51 | # Directory scan 52 | deleted_user: User = get_deleted_user() 53 | for user in User.objects.all(): 54 | if user != deleted_user: 55 | scan_photos( 56 | user, options["full_scan"], uuid.uuid4(), user.scan_directory 57 | ) 58 | 59 | def nextcloud_scan(self): 60 | for user in User.objects.all(): 61 | if not user.nextcloud_scan_directory: 62 | print( 63 | f"Skipping nextcloud scan for user {user.username}. No scan directory configured." 64 | ) 65 | continue 66 | print(f"Starting nextcloud scan for user {user.username}.") 67 | try: 68 | scan_photos_nextcloud(user, uuid.uuid4()) 69 | except Exception: 70 | print(f"Nextcloud scan for user {user.username} failed:") 71 | print(traceback.format_exc()) 72 | -------------------------------------------------------------------------------- /api/management/commands/start_cleaning_service.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django_q.models import Schedule 3 | from django_q.tasks import schedule 4 | 5 | from api.util import logger 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Start the cleanup service." 10 | 11 | def handle(self, *args, **kwargs): 12 | if not Schedule.objects.filter( 13 | func="api.services.cleanup_deleted_photos" 14 | ).exists(): 15 | schedule( 16 | "api.services.cleanup_deleted_photos", 17 | schedule_type=Schedule.DAILY, 18 | ) 19 | logger.info("Cleanup service started") 20 | -------------------------------------------------------------------------------- /api/management/commands/start_service.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django_q.models import Schedule 3 | from django_q.tasks import schedule 4 | 5 | from api.services import SERVICES, start_service 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Start one of the services." 10 | 11 | # Define all the services that can be started 12 | def add_arguments(self, parser): 13 | parser.add_argument( 14 | "service", 15 | type=str, 16 | help="The service to start", 17 | choices=[ 18 | SERVICES.keys(), 19 | "all", 20 | ], 21 | ) 22 | 23 | def handle(self, *args, **kwargs): 24 | service = kwargs["service"] 25 | if service == "all": 26 | for svc in SERVICES.keys(): 27 | start_service(svc) 28 | if not Schedule.objects.filter(func="api.services.check_services").exists(): 29 | schedule( 30 | "api.services.check_services", 31 | schedule_type=Schedule.MINUTES, 32 | minutes=1, 33 | ) 34 | else: 35 | start_service(service) 36 | -------------------------------------------------------------------------------- /api/middleware.py: -------------------------------------------------------------------------------- 1 | class FingerPrintMiddleware: 2 | def __init__(self, get_response): 3 | self.get_response = get_response 4 | # One-time configuration and initializatio 5 | 6 | def __call__(self, request): 7 | response = self.get_response(request) 8 | import hashlib 9 | 10 | fingerprint_raw = "".join( 11 | ( 12 | request.META.get("HTTP_USER_AGENT", ""), 13 | request.META.get("HTTP_ACCEPT_ENCODING", ""), 14 | ) 15 | ) 16 | # print(fingerprint_raw) 17 | fingerprint = hashlib.md5(fingerprint_raw.encode("utf-8")).hexdigest() 18 | request.fingerprint = fingerprint 19 | # print(fingerprint) 20 | return response 21 | -------------------------------------------------------------------------------- /api/migrations/0002_add_confidence.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0001_initial"), 7 | ] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="User", 12 | name="confidence", 13 | field=models.FloatField(default=0.1, db_index=True), 14 | ) 15 | ] 16 | -------------------------------------------------------------------------------- /api/migrations/0003_remove_unused_thumbs.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0002_add_confidence"), 7 | ] 8 | 9 | operations = [ 10 | migrations.RemoveField(model_name="Photo", name="thumbnail_tiny"), 11 | migrations.RemoveField(model_name="Photo", name="thumbnail_small"), 12 | migrations.RemoveField(model_name="Photo", name="thumbnail"), 13 | migrations.RemoveField(model_name="Photo", name="square_thumbnail_tiny"), 14 | migrations.RemoveField(model_name="Photo", name="square_thumbnail_big"), 15 | ] 16 | -------------------------------------------------------------------------------- /api/migrations/0004_fix_album_thing_constraint.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0003_remove_unused_thumbs"), 7 | ] 8 | 9 | operations = [ 10 | migrations.AlterUniqueTogether( 11 | name="albumthing", 12 | unique_together=set([]), 13 | ), 14 | migrations.AddConstraint( 15 | model_name="albumthing", 16 | constraint=models.UniqueConstraint( 17 | fields=["title", "thing_type", "owner"], 18 | name="unique AlbumThing", 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /api/migrations/0005_add_video_to_photo.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0004_fix_album_thing_constraint"), 7 | ] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="Photo", name="video", field=models.BooleanField(default=False) 12 | ) 13 | ] 14 | -------------------------------------------------------------------------------- /api/migrations/0006_migrate_to_boolean_field.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0005_add_video_to_photo"), 7 | ] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="Face", 12 | name="person_label_is_inferred", 13 | field=models.BooleanField(null=True, db_index=True), 14 | ) 15 | ] 16 | -------------------------------------------------------------------------------- /api/migrations/0007_migrate_to_json_field.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0006_migrate_to_boolean_field"), 9 | ] 10 | 11 | def forwards_func(apps, schema): 12 | Photo = apps.get_model("api", "Photo") 13 | for obj in Photo.objects.all(): 14 | try: 15 | obj.image_paths.append(obj.image_path) 16 | obj.save() 17 | except json.decoder.JSONDecodeError: 18 | print("Cannot convert {} object".format(obj.image_path)) 19 | 20 | operations = [ 21 | migrations.AddField( 22 | model_name="Photo", 23 | name="image_paths", 24 | field=models.JSONField(db_index=True, default=list), 25 | ), 26 | migrations.RunPython(forwards_func), 27 | ] 28 | -------------------------------------------------------------------------------- /api/migrations/0008_remove_image_path.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0007_migrate_to_json_field"), 7 | ] 8 | 9 | operations = [ 10 | migrations.RemoveField(model_name="Photo", name="image_path"), 11 | ] 12 | -------------------------------------------------------------------------------- /api/migrations/0009_add_aspect_ratio.py: -------------------------------------------------------------------------------- 1 | import exiftool 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [ 7 | ("api", "0008_remove_image_path"), 8 | ] 9 | 10 | def forwards_func(apps, schema): 11 | Photo = apps.get_model("api", "Photo") 12 | with exiftool.ExifTool() as et: 13 | for obj in Photo.objects.all(): 14 | if obj.thumbnail_big: 15 | try: 16 | height = et.get_tag("ImageHeight", obj.thumbnail_big.path) 17 | width = et.get_tag("ImageWidth", obj.thumbnail_big.path) 18 | obj.aspect_ratio = round((width / height), 2) 19 | obj.save() 20 | except Exception: 21 | print("Cannot convert {} object".format(obj)) 22 | 23 | operations = [ 24 | migrations.AddField( 25 | model_name="Photo", 26 | name="aspect_ratio", 27 | field=models.FloatField(blank=True, null=True), 28 | ), 29 | migrations.RunPython(forwards_func), 30 | ] 31 | -------------------------------------------------------------------------------- /api/migrations/0009_add_clip_embedding_field.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.fields import ArrayField 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [ 7 | ("api", "0008_remove_image_path"), 8 | ] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="Photo", 13 | name="clip_embeddings", 14 | field=ArrayField( 15 | models.FloatField(blank=True, null=True), size=512, null=True 16 | ), 17 | ), 18 | migrations.AddField( 19 | model_name="Photo", 20 | name="clip_embeddings_magnitude", 21 | field=models.FloatField(blank=True, null=True), 22 | ), 23 | migrations.AddField( 24 | model_name="User", 25 | name="semantic_search_topk", 26 | field=models.IntegerField(default=0, null=False), 27 | ), 28 | migrations.RemoveField( 29 | model_name="Photo", 30 | name="encoding", 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /api/migrations/0010_merge_20210725_1547.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-07-25 21:47 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0009_add_aspect_ratio"), 9 | ("api", "0009_add_clip_embedding_field"), 10 | ] 11 | 12 | operations = [] 13 | -------------------------------------------------------------------------------- /api/migrations/0011_a_add_rating.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-08-06 11:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0010_merge_20210725_1547"), 9 | ] 10 | 11 | run_before = [("api", "0011_b_migrate_favorited_to_rating")] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="photo", 16 | name="rating", 17 | field=models.IntegerField(db_index=True, default=0), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /api/migrations/0011_b_migrate_favorited_to_rating.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-08-06 11:32 2 | 3 | from django.db import migrations 4 | 5 | 6 | def favorited_to_rating(apps, schema_editor): 7 | Photo = apps.get_model("api", "Photo") 8 | for photo in Photo.objects.all(): 9 | photo.rating = 4 if photo.favorited else 0 10 | photo.save() 11 | 12 | 13 | def rating_to_favorited(apps, schema_editor): 14 | Photo = apps.get_model("api", "Photo") 15 | for photo in Photo.objects.all(): 16 | photo.favorited = photo.rating >= 4 17 | photo.save() 18 | 19 | 20 | class Migration(migrations.Migration): 21 | dependencies = [ 22 | ("api", "0011_a_add_rating"), 23 | ] 24 | 25 | run_before = [("api", "0011_c_remove_favorited")] 26 | 27 | operations = [migrations.RunPython(favorited_to_rating, rating_to_favorited)] 28 | -------------------------------------------------------------------------------- /api/migrations/0011_c_remove_favorited.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-08-06 11:32 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0011_b_migrate_favorited_to_rating"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="photo", 14 | name="favorited", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /api/migrations/0012_add_favorite_min_rating.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-08-08 17:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0011_c_remove_favorited"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="user", 14 | name="favorite_min_rating", 15 | field=models.IntegerField(db_index=True, default=4), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/0014_add_save_metadata_to_disk.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-08-08 17:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0013_add_image_scale_and_misc"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="user", 14 | name="save_metadata_to_disk", 15 | field=models.TextField( 16 | choices=[ 17 | ("OFF", "Off"), 18 | ("MEDIA_FILE", "Media File"), 19 | ("SIDECAR_FILE", "Sidecar File"), 20 | ], 21 | default="OFF", 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /api/migrations/0015_add_dominant_color.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0014_add_save_metadata_to_disk"), 7 | ] 8 | operations = [ 9 | migrations.AddField( 10 | model_name="Photo", 11 | name="dominant_color", 12 | field=models.TextField(blank=True, null=True), 13 | ), 14 | ] 15 | -------------------------------------------------------------------------------- /api/migrations/0016_add_transcode_videos.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0015_add_dominant_color"), 7 | ] 8 | operations = [ 9 | migrations.AddField( 10 | model_name="User", 11 | name="transcode_videos", 12 | field=models.BooleanField(default=False), 13 | ), 14 | ] 15 | -------------------------------------------------------------------------------- /api/migrations/0017_add_cover_photo.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0016_add_transcode_videos"), 7 | ] 8 | operations = [ 9 | migrations.AddField( 10 | model_name="Person", 11 | name="cover_photo", 12 | field=models.ForeignKey( 13 | to="api.Photo", 14 | related_name="person", 15 | on_delete=models.PROTECT, 16 | blank=False, 17 | null=True, 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /api/migrations/0018_user_config_datetime_rules.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-01-24 17:11 2 | 3 | from django.db import migrations, models 4 | 5 | import api.models.user 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("api", "0017_add_cover_photo"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="user", 16 | name="config_datetime_rules", 17 | field=models.JSONField( 18 | default=api.models.user.get_default_config_datetime_rules 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /api/migrations/0019_change_config_datetime_rules.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-01-24 17:11 2 | 3 | from django.db import migrations, models 4 | 5 | import api.models.user 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("api", "0018_user_config_datetime_rules"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name="user", 16 | name="config_datetime_rules", 17 | ), 18 | migrations.AddField( 19 | model_name="user", 20 | name="datetime_rules", 21 | field=models.JSONField( 22 | default=api.models.user.get_default_config_datetime_rules 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /api/migrations/0020_add_default_timezone.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-01-24 17:11 2 | 3 | import pytz 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0019_change_config_datetime_rules"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="user", 15 | name="default_timezone", 16 | field=models.TextField( 17 | choices=[(x, x) for x in pytz.all_timezones], 18 | default="UTC", 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /api/migrations/0021_remove_photo_image.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-02-01 22:42 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0020_add_default_timezone"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="photo", 14 | name="image", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /api/migrations/0022_photo_video_length.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-02-20 11:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0021_remove_photo_image"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="photo", 14 | name="video_length", 15 | field=models.TextField(blank=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/0023_photo_deleted.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-02-23 21:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0022_photo_video_length"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="photo", 14 | name="deleted", 15 | field=models.BooleanField(db_index=True, default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/0024_photo_timestamp.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-03-18 10:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0023_photo_deleted"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="photo", 14 | name="timestamp", 15 | field=models.DateTimeField(blank=True, db_index=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/0025_add_cover_photo.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0024_photo_timestamp"), 7 | ] 8 | operations = [ 9 | migrations.AddField( 10 | model_name="AlbumUser", 11 | name="cover_photo", 12 | field=models.ForeignKey( 13 | to="api.Photo", 14 | related_name="album_user", 15 | on_delete=models.PROTECT, 16 | blank=False, 17 | null=True, 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /api/migrations/0027_rename_unknown_person.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-07-17 19:07 2 | from django.db import migrations 3 | 4 | UNKNOWN_PERSON_NAME = "Unknown - Other" 5 | KIND_UNKNOWN = "UNKNOWN" 6 | 7 | 8 | def migrate_unknown(apps, schema_editor): 9 | Person = apps.get_model("api", "Person") 10 | person: Person 11 | try: 12 | person = Person.objects.get(name="unknown") 13 | person.name = UNKNOWN_PERSON_NAME 14 | person.kind = KIND_UNKNOWN 15 | person.save() 16 | except Person.DoesNotExist: 17 | unknown_person: Person = Person.objects.get_or_create( 18 | name=UNKNOWN_PERSON_NAME, cluster_owner=None, kind=KIND_UNKNOWN 19 | )[0] 20 | if unknown_person.kind != KIND_UNKNOWN: 21 | unknown_person.kind = KIND_UNKNOWN 22 | unknown_person.save() 23 | 24 | 25 | def unmigrate_unknown(apps, schema_editor): 26 | Person = apps.get_model("api", "Person") 27 | try: 28 | person: Person = Person.objects.get(name=UNKNOWN_PERSON_NAME) 29 | person.name = "unknown" 30 | person.kind = "" 31 | person.save() 32 | except Person.DoesNotExist: 33 | pass 34 | 35 | 36 | class Migration(migrations.Migration): 37 | dependencies = [ 38 | ("api", "0026_add_cluster_info"), 39 | ] 40 | 41 | operations = [migrations.RunPython(migrate_unknown, unmigrate_unknown)] 42 | -------------------------------------------------------------------------------- /api/migrations/0028_add_metadata_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-07-30 11:47 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0027_rename_unknown_person"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="photo", 14 | name="camera", 15 | field=models.TextField(blank=True, null=True), 16 | ), 17 | migrations.AddField( 18 | model_name="photo", 19 | name="digitalZoomRatio", 20 | field=models.FloatField(blank=True, null=True), 21 | ), 22 | migrations.AddField( 23 | model_name="photo", 24 | name="focalLength35Equivalent", 25 | field=models.IntegerField(blank=True, null=True), 26 | ), 27 | migrations.AddField( 28 | model_name="photo", 29 | name="focal_length", 30 | field=models.FloatField(blank=True, null=True), 31 | ), 32 | migrations.AddField( 33 | model_name="photo", 34 | name="fstop", 35 | field=models.FloatField(blank=True, null=True), 36 | ), 37 | migrations.AddField( 38 | model_name="photo", 39 | name="height", 40 | field=models.IntegerField(default=0), 41 | ), 42 | migrations.AddField( 43 | model_name="photo", 44 | name="iso", 45 | field=models.IntegerField(blank=True, null=True), 46 | ), 47 | migrations.AddField( 48 | model_name="photo", 49 | name="lens", 50 | field=models.TextField(blank=True, null=True), 51 | ), 52 | migrations.AddField( 53 | model_name="photo", 54 | name="shutter_speed", 55 | field=models.FloatField(blank=True, null=True), 56 | ), 57 | migrations.AddField( 58 | model_name="photo", 59 | name="size", 60 | field=models.IntegerField(default=0), 61 | ), 62 | migrations.AddField( 63 | model_name="photo", 64 | name="subjectDistance", 65 | field=models.FloatField(blank=True, null=True), 66 | ), 67 | migrations.AddField( 68 | model_name="photo", 69 | name="width", 70 | field=models.IntegerField(default=0), 71 | ), 72 | ] 73 | -------------------------------------------------------------------------------- /api/migrations/0029_change_to_text_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-07-31 11:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0028_add_metadata_fields"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="photo", 14 | name="shutter_speed", 15 | field=models.TextField(blank=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/0030_user_confidence_person.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-08-08 13:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0029_change_to_text_field"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="user", 14 | name="confidence_person", 15 | field=models.FloatField(default=0.9), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/0031_remove_account.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-09-01 16:28 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0030_user_confidence_person"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelManagers( 13 | name="albumdate", 14 | managers=[], 15 | ), 16 | migrations.RemoveField( 17 | model_name="person", 18 | name="account", 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /api/migrations/0032_always_have_owner.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-08-06 11:32 2 | 3 | from django.db import migrations 4 | 5 | 6 | def add_cluster_owner(apps, schema_editor): 7 | Person = apps.get_model("api", "Person") 8 | for person in Person.objects.all(): 9 | if person.faces.first(): 10 | person.cluster_owner = person.faces.first().photo.owner 11 | person.save() 12 | 13 | 14 | def remove_cluster_owner(apps, schema_editor): 15 | Person = apps.get_model("api", "Person") 16 | for person in Person.objects.all(): 17 | person.cluster_owner = None 18 | 19 | 20 | class Migration(migrations.Migration): 21 | dependencies = [ 22 | ("api", "0031_remove_account"), 23 | ] 24 | 25 | operations = [migrations.RunPython(add_cluster_owner, remove_cluster_owner)] 26 | -------------------------------------------------------------------------------- /api/migrations/0033_add_post_delete_person.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-09-02 10:23 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0032_always_have_owner"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="face", 15 | name="person", 16 | field=models.ForeignKey( 17 | on_delete=django.db.models.deletion.DO_NOTHING, 18 | related_name="faces", 19 | to="api.person", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /api/migrations/0034_allow_deleting_person.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-09-02 10:26 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0033_add_post_delete_person"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="person", 15 | name="cover_photo", 16 | field=models.ForeignKey( 17 | null=True, 18 | on_delete=django.db.models.deletion.SET_NULL, 19 | related_name="person", 20 | to="api.photo", 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /api/migrations/0035_add_files_model.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-11-09 17:35 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0034_allow_deleting_person"), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="File", 15 | fields=[ 16 | ( 17 | "hash", 18 | models.CharField(max_length=64, primary_key=True, serialize=False), 19 | ), 20 | ("path", models.TextField(blank=True, null=True)), 21 | ( 22 | "type", 23 | models.PositiveIntegerField( 24 | blank=True, 25 | choices=[ 26 | (1, "Image"), 27 | (2, "Video"), 28 | (3, "Metadata File e.g. XMP"), 29 | (4, "Raw File"), 30 | ], 31 | ), 32 | ), 33 | ], 34 | ), 35 | migrations.AddField( 36 | model_name="photo", 37 | name="files", 38 | field=models.ManyToManyField(to="api.File"), 39 | ), 40 | migrations.AddField( 41 | model_name="photo", 42 | name="main_file", 43 | field=models.ForeignKey( 44 | null=True, 45 | on_delete=django.db.models.deletion.PROTECT, 46 | related_name="main_photo", 47 | to="api.file", 48 | ), 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /api/migrations/0036_handle_missing_files.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-11-10 08:41 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0035_add_files_model"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="file", 14 | name="missing", 15 | field=models.BooleanField(default=False), 16 | ), 17 | migrations.AlterField( 18 | model_name="file", 19 | name="type", 20 | field=models.PositiveIntegerField( 21 | blank=True, 22 | choices=[ 23 | (1, "Image"), 24 | (2, "Video"), 25 | (3, "Metadata File e.g. XMP"), 26 | (4, "Raw File"), 27 | (5, "Unknown"), 28 | ], 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /api/migrations/0037_migrate_to_files.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.db import migrations 4 | 5 | from api.models.file import is_metadata, is_raw, is_video 6 | 7 | IMAGE = 1 8 | VIDEO = 2 9 | METADATA_FILE = 3 10 | RAW_FILE = 4 11 | UNKNOWN = 5 12 | 13 | 14 | def find_out_type(path): 15 | if is_raw(path): 16 | return RAW_FILE 17 | if is_video(path): 18 | return VIDEO 19 | if is_metadata(path): 20 | return METADATA_FILE 21 | return IMAGE 22 | 23 | 24 | def migrate_to_files(apps, schema_editor): 25 | Photo = apps.get_model("api", "Photo") 26 | File = apps.get_model("api", "File") 27 | for photo in Photo.objects.all(): 28 | if photo.image_paths: 29 | for path in photo.image_paths: 30 | file: File = File() 31 | file.path = path 32 | if os.path.exists(path): 33 | file.type = find_out_type(path) 34 | else: 35 | file.type = UNKNOWN 36 | if photo.video: 37 | file.type = VIDEO 38 | file.missing = True 39 | # This is fine, because at this point all files that belong to a photo have the same hash 40 | file.hash = photo.image_hash 41 | file.save() 42 | photo.files.add(file) 43 | photo.save() 44 | # handle missing photos 45 | else: 46 | file: File = File() 47 | file.path = None 48 | file.type = UNKNOWN 49 | file.missing = True 50 | file.hash = photo.image_hash 51 | file.save() 52 | photo.files.add(file) 53 | photo.save() 54 | 55 | 56 | def remove_files(apps, schema_editor): 57 | File = apps.get_model("api", "File") 58 | for file in File.objects.all(): 59 | file.delete() 60 | 61 | 62 | class Migration(migrations.Migration): 63 | dependencies = [ 64 | ("api", "0036_handle_missing_files"), 65 | ] 66 | 67 | operations = [migrations.RunPython(migrate_to_files, remove_files)] 68 | -------------------------------------------------------------------------------- /api/migrations/0038_add_main_file.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from api.models.file import is_metadata, is_raw, is_video 4 | 5 | IMAGE = 1 6 | VIDEO = 2 7 | METADATA_FILE = 3 8 | RAW_FILE = 4 9 | UNKNOWN = 5 10 | 11 | 12 | def find_out_type(path): 13 | if is_raw(path): 14 | return RAW_FILE 15 | if is_video(path): 16 | return VIDEO 17 | if is_metadata(path): 18 | return METADATA_FILE 19 | return IMAGE 20 | 21 | 22 | def add_main_file(apps, schema_editor): 23 | Photo = apps.get_model("api", "Photo") 24 | for photo in Photo.objects.all(): 25 | if photo.files.count() > 0: 26 | photo.main_file = photo.files.first() 27 | photo.save() 28 | 29 | 30 | def remove_main_file(apps, schema_editor): 31 | Photo = apps.get_model("api", "Photo") 32 | for photo in Photo.objects.all(): 33 | photo.main_file = None 34 | photo.save() 35 | 36 | 37 | class Migration(migrations.Migration): 38 | dependencies = [ 39 | ("api", "0037_migrate_to_files"), 40 | ] 41 | 42 | operations = [migrations.RunPython(add_main_file, remove_main_file)] 43 | -------------------------------------------------------------------------------- /api/migrations/0039_remove_photo_image_paths.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-12-21 09:24 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0038_add_main_file"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="photo", 14 | name="image_paths", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /api/migrations/0040_add_user_public_sharing_flag.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0039_remove_photo_image_paths"), 7 | ] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="user", 12 | name="public_sharing", 13 | field=models.BooleanField(default=False), 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /api/migrations/0041_apply_user_enum_for_person.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | def apply_enum(apps, schema_editor): 6 | Person = apps.get_model("api", "Person") 7 | for person in Person.objects.filter(kind="").all(): 8 | person.kind = "USER" 9 | person.save() 10 | 11 | def remove_enum(apps, schema_editor): 12 | Person = apps.get_model("api", "Person") 13 | for person in Person.objects.filter(kind="").all(): 14 | person.kind = "" 15 | person.save() 16 | 17 | dependencies = [ 18 | ("api", "0040_add_user_public_sharing_flag"), 19 | ] 20 | 21 | operations = [migrations.RunPython(apply_enum, remove_enum)] 22 | -------------------------------------------------------------------------------- /api/migrations/0042_alter_albumuser_cover_photo_alter_photo_main_file.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2rc1 on 2023-04-04 09:14 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0041_apply_user_enum_for_person"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="albumuser", 15 | name="cover_photo", 16 | field=models.ForeignKey( 17 | null=True, 18 | on_delete=django.db.models.deletion.SET_NULL, 19 | related_name="album_user", 20 | to="api.photo", 21 | ), 22 | ), 23 | migrations.AlterField( 24 | model_name="photo", 25 | name="main_file", 26 | field=models.ForeignKey( 27 | null=True, 28 | on_delete=django.db.models.deletion.SET_NULL, 29 | related_name="main_photo", 30 | to="api.file", 31 | ), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /api/migrations/0043_alter_photo_size.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2rc1 on 2023-04-05 07:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0042_alter_albumuser_cover_photo_alter_photo_main_file"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="photo", 14 | name="size", 15 | field=models.BigIntegerField(default=0), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/0044_alter_cluster_person_alter_person_cluster_owner.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2rc1 on 2023-04-07 19:02 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("api", "0043_alter_photo_size"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="cluster", 16 | name="person", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.SET_NULL, 21 | related_name="clusters", 22 | to="api.person", 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="person", 27 | name="cluster_owner", 28 | field=models.ForeignKey( 29 | default=None, 30 | null=True, 31 | on_delete=django.db.models.deletion.SET_NULL, 32 | related_name="owner", 33 | to=settings.AUTH_USER_MODEL, 34 | ), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /api/migrations/0045_alter_face_cluster.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2rc1 on 2023-04-07 19:15 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0044_alter_cluster_person_alter_person_cluster_owner"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="face", 15 | name="cluster", 16 | field=models.ForeignKey( 17 | blank=True, 18 | null=True, 19 | on_delete=django.db.models.deletion.SET_NULL, 20 | related_name="faces", 21 | to="api.cluster", 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /api/migrations/0046_add_embedded_media.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0045_alter_face_cluster"), 7 | ] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="file", 12 | name="embedded_media", 13 | field=models.ManyToManyField("File"), 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /api/migrations/0047_alter_file_embedded_media.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2rc1 on 2023-04-15 10:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0046_add_embedded_media"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="file", 14 | name="embedded_media", 15 | field=models.ManyToManyField(to="api.file"), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/0048_fix_null_height.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0047_alter_file_embedded_media"), 7 | ] 8 | 9 | operations = [ 10 | migrations.RunSQL("UPDATE api_photo SET height=0 WHERE height IS NULL;") 11 | ] 12 | -------------------------------------------------------------------------------- /api/migrations/0049_fix_metadata_files_as_main_files.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def delete_photos_with_metadata_as_main(apps, schema_editor): 5 | Photo = apps.get_model("api", "Photo") 6 | for photo in Photo.objects.filter(main_file__type=3): 7 | photo.delete() 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | ("api", "0048_fix_null_height"), 13 | ] 14 | 15 | operations = [ 16 | migrations.RunPython(delete_photos_with_metadata_as_main), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/0050_person_face_count.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-06-20 09:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0049_fix_metadata_files_as_main_files"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="person", 14 | name="face_count", 15 | field=models.IntegerField(default=0), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/0051_set_person_defaults.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | def apply_default(apps, schema_editor): 6 | Person = apps.get_model("api", "Person") 7 | User = apps.get_model("api", "User") 8 | 9 | for person in Person.objects.filter(kind="USER").all(): 10 | number_of_faces = person.faces.filter( 11 | photo__hidden=False, 12 | photo__deleted=False, 13 | photo__owner=person.cluster_owner.id, 14 | ).count() 15 | if not person.cover_photo and number_of_faces > 0: 16 | person.cover_photo = ( 17 | person.faces.filter( 18 | photo__hidden=False, 19 | photo__deleted=False, 20 | photo__owner=person.cluster_owner.id, 21 | ) 22 | .first() 23 | .photo 24 | ) 25 | confidence_person = ( 26 | User.objects.filter(id=person.cluster_owner.id) 27 | .first() 28 | .confidence_person 29 | ) 30 | person.face_count = person.faces.filter( 31 | photo__hidden=False, 32 | photo__deleted=False, 33 | photo__owner=person.cluster_owner.id, 34 | person_label_probability__gte=confidence_person, 35 | ).count() 36 | person.save() 37 | 38 | def remove_default(apps, schema_editor): 39 | Person = apps.get_model("api", "Person") 40 | for person in Person.objects.all(): 41 | person.face_count = 0 42 | person.save() 43 | 44 | dependencies = [ 45 | ("api", "0050_person_face_count"), 46 | ] 47 | 48 | operations = [migrations.RunPython(apply_default, remove_default)] 49 | -------------------------------------------------------------------------------- /api/migrations/0052_alter_person_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-06-26 12:14 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0051_set_person_defaults"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="person", 15 | name="name", 16 | field=models.CharField( 17 | max_length=128, 18 | validators=[django.core.validators.MinLengthValidator(1)], 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /api/migrations/0053_user_confidence_unknown_face_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-07-09 11:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0052_alter_person_name"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="user", 14 | name="confidence_unknown_face", 15 | field=models.FloatField(default=0.5), 16 | ), 17 | migrations.AddField( 18 | model_name="user", 19 | name="face_recognition_model", 20 | field=models.TextField( 21 | choices=[("HOG", "Hog"), ("CNN", "Cnn")], default="HOG" 22 | ), 23 | ), 24 | migrations.AddField( 25 | model_name="user", 26 | name="min_cluster_size", 27 | field=models.IntegerField(default=0), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /api/migrations/0054_user_cluster_selection_epsilon_user_min_samples.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-07-11 11:06 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0053_user_confidence_unknown_face_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="user", 14 | name="cluster_selection_epsilon", 15 | field=models.FloatField(default=0.05), 16 | ), 17 | migrations.AddField( 18 | model_name="user", 19 | name="min_samples", 20 | field=models.IntegerField(default=1), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /api/migrations/0055_alter_longrunningjob_job_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.6 on 2023-10-27 13:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0054_user_cluster_selection_epsilon_user_min_samples"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="longrunningjob", 14 | name="job_type", 15 | field=models.PositiveIntegerField( 16 | choices=[ 17 | (1, "Scan Photos"), 18 | (2, "Generate Event Albums"), 19 | (3, "Regenerate Event Titles"), 20 | (4, "Train Faces"), 21 | (5, "Delete Missing Photos"), 22 | (7, "Scan Faces"), 23 | (6, "Calculate Clip Embeddings"), 24 | (8, "Find Similar Faces"), 25 | (9, "Download Selected Photos"), 26 | ] 27 | ), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /api/migrations/0056_user_llm_settings_alter_longrunningjob_job_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.8 on 2023-12-21 11:16 2 | 3 | from django.db import migrations, models 4 | 5 | import api.models.user 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("api", "0055_alter_longrunningjob_job_type"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="user", 16 | name="llm_settings", 17 | field=models.JSONField(default=api.models.user.get_default_llm_settings), 18 | ), 19 | migrations.AlterField( 20 | model_name="longrunningjob", 21 | name="job_type", 22 | field=models.PositiveIntegerField( 23 | choices=[ 24 | (1, "Scan Photos"), 25 | (2, "Generate Event Albums"), 26 | (3, "Regenerate Event Titles"), 27 | (4, "Train Faces"), 28 | (5, "Delete Missing Photos"), 29 | (7, "Scan Faces"), 30 | (6, "Calculate Clip Embeddings"), 31 | (8, "Find Similar Faces"), 32 | (9, "Download Selected Photos"), 33 | (10, "Download Models"), 34 | ] 35 | ), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /api/migrations/0057_remove_face_image_path_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.8 on 2024-01-10 17:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0056_user_llm_settings_alter_longrunningjob_job_type"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="face", 14 | name="image_path", 15 | ), 16 | migrations.AlterField( 17 | model_name="face", 18 | name="person_label_is_inferred", 19 | field=models.BooleanField(db_index=True, default=False), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /api/migrations/0058_alter_user_avatar_alter_user_nextcloud_app_password_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-02-02 16:36 2 | 3 | import django_cryptography.fields 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0057_remove_face_image_path_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="user", 15 | name="avatar", 16 | field=models.ImageField(blank=True, null=True, upload_to="avatars"), 17 | ), 18 | migrations.AlterField( 19 | model_name="user", 20 | name="nextcloud_app_password", 21 | field=django_cryptography.fields.encrypt( 22 | models.CharField(blank=True, default=None, max_length=64, null=True) 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="user", 27 | name="nextcloud_scan_directory", 28 | field=models.CharField( 29 | blank=True, db_index=True, max_length=512, null=True 30 | ), 31 | ), 32 | migrations.AlterField( 33 | model_name="user", 34 | name="nextcloud_server_address", 35 | field=models.CharField(blank=True, default=None, max_length=200, null=True), 36 | ), 37 | migrations.AlterField( 38 | model_name="user", 39 | name="nextcloud_username", 40 | field=models.CharField(blank=True, default=None, max_length=64, null=True), 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /api/migrations/0059_person_cover_face.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.11 on 2024-03-29 16:03 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0058_alter_user_avatar_alter_user_nextcloud_app_password_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="person", 15 | name="cover_face", 16 | field=models.ForeignKey( 17 | null=True, 18 | on_delete=django.db.models.deletion.SET_NULL, 19 | related_name="face", 20 | to="api.face", 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /api/migrations/0060_apply_default_face_cover.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | def apply_default(apps, schema_editor): 6 | Person = apps.get_model("api", "Person") 7 | 8 | for person in Person.objects.filter(kind="USER").all(): 9 | if not person.cover_face and person.faces.count() > 0: 10 | person.cover_face = person.faces.first() 11 | person.save() 12 | if ( 13 | not person.cover_face 14 | and person.cover_photo 15 | and person.cover_photo.faces.count() > 0 16 | ): 17 | person.cover_face = person.cover_photo.faces.filter( 18 | person__name=person.name 19 | ).first() 20 | person.save() 21 | 22 | def remove_default(apps, schema_editor): 23 | Person = apps.get_model("api", "Person") 24 | for person in Person.objects.all(): 25 | person.cover_face = None 26 | 27 | dependencies = [ 28 | ("api", "0059_person_cover_face"), 29 | ] 30 | 31 | operations = [migrations.RunPython(apply_default, remove_default)] 32 | -------------------------------------------------------------------------------- /api/migrations/0061_alter_person_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.11 on 2024-03-29 17:20 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0060_apply_default_face_cover"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="person", 15 | name="name", 16 | field=models.CharField( 17 | db_index=True, 18 | max_length=128, 19 | validators=[django.core.validators.MinLengthValidator(1)], 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /api/migrations/0062_albumthing_cover_photos.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.11 on 2024-03-29 17:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0061_alter_person_name"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="albumthing", 14 | name="cover_photos", 15 | field=models.ManyToManyField( 16 | related_name="album_thing_cover_photos", to="api.photo" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /api/migrations/0063_apply_default_album_things_cover.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | def apply_default(apps, schema_editor): 6 | AlbumThing = apps.get_model("api", "AlbumThing") 7 | 8 | for thing in AlbumThing.objects.all(): 9 | if thing.photos.count() > 0: 10 | thing.cover_photos.add(*thing.photos.all()[:4]) 11 | thing.save() 12 | 13 | def remove_default(apps, schema_editor): 14 | AlbumThing = apps.get_model("api", "AlbumThing") 15 | for thing in AlbumThing.objects.all(): 16 | thing.cover_photos = None 17 | thing.save() 18 | 19 | dependencies = [ 20 | ("api", "0062_albumthing_cover_photos"), 21 | ] 22 | 23 | operations = [migrations.RunPython(apply_default, remove_default)] 24 | -------------------------------------------------------------------------------- /api/migrations/0064_albumthing_photo_count.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.11 on 2024-03-30 13:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0063_apply_default_album_things_cover"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="albumthing", 14 | name="photo_count", 15 | field=models.IntegerField(default=0), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/0065_apply_default_photo_count.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | def apply_default(apps, schema_editor): 6 | AlbumThing = apps.get_model("api", "AlbumThing") 7 | 8 | for thing in AlbumThing.objects.all(): 9 | thing.photo_count = thing.photos.filter(hidden=False).count() 10 | thing.save() 11 | 12 | def remove_default(apps, schema_editor): 13 | AlbumThing = apps.get_model("api", "AlbumThing") 14 | for thing in AlbumThing.objects.all(): 15 | thing.photo_count = 0 16 | thing.save() 17 | 18 | dependencies = [ 19 | ("api", "0064_albumthing_photo_count"), 20 | ] 21 | 22 | operations = [migrations.RunPython(apply_default, remove_default)] 23 | -------------------------------------------------------------------------------- /api/migrations/0066_photo_last_modified_alter_longrunningjob_job_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.13 on 2024-06-12 15:09 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0065_apply_default_photo_count"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="photo", 14 | name="last_modified", 15 | field=models.DateTimeField(auto_now=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="longrunningjob", 19 | name="job_type", 20 | field=models.PositiveIntegerField( 21 | choices=[ 22 | (1, "Scan Photos"), 23 | (2, "Generate Event Albums"), 24 | (3, "Regenerate Event Titles"), 25 | (4, "Train Faces"), 26 | (5, "Delete Missing Photos"), 27 | (7, "Scan Faces"), 28 | (6, "Calculate Clip Embeddings"), 29 | (8, "Find Similar Faces"), 30 | (9, "Download Selected Photos"), 31 | (10, "Download Models"), 32 | (11, "Add Geolocation"), 33 | (12, "Generate Tags"), 34 | ] 35 | ), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /api/migrations/0067_alter_longrunningjob_job_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.13 on 2024-06-16 15:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0066_photo_last_modified_alter_longrunningjob_job_type"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="longrunningjob", 14 | name="job_type", 15 | field=models.PositiveIntegerField( 16 | choices=[ 17 | (1, "Scan Photos"), 18 | (2, "Generate Event Albums"), 19 | (3, "Regenerate Event Titles"), 20 | (4, "Train Faces"), 21 | (5, "Delete Missing Photos"), 22 | (7, "Scan Faces"), 23 | (6, "Calculate Clip Embeddings"), 24 | (8, "Find Similar Faces"), 25 | (9, "Download Selected Photos"), 26 | (10, "Download Models"), 27 | (11, "Add Geolocation"), 28 | (12, "Generate Tags"), 29 | (13, "Generate Face Embeddings"), 30 | ] 31 | ), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /api/migrations/0068_remove_longrunningjob_result_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.13 on 2024-06-18 13:18 2 | from django.db import migrations, models 3 | 4 | 5 | def copy_progress_data(apps, schema_editor): 6 | LongRunningJob = apps.get_model("api", "LongRunningJob") 7 | for job in LongRunningJob.objects.all(): 8 | result = job.result 9 | job.progress_current = result["progress"]["current"] 10 | job.progress_target = result["progress"]["target"] 11 | job.save() 12 | 13 | 14 | class Migration(migrations.Migration): 15 | dependencies = [ 16 | ("api", "0067_alter_longrunningjob_job_type"), 17 | ] 18 | 19 | operations = [ 20 | migrations.AddField( 21 | model_name="longrunningjob", 22 | name="progress_current", 23 | field=models.PositiveIntegerField(default=0), 24 | ), 25 | migrations.AddField( 26 | model_name="longrunningjob", 27 | name="progress_target", 28 | field=models.PositiveIntegerField(default=0), 29 | ), 30 | migrations.RunPython(copy_progress_data), 31 | migrations.RemoveField( 32 | model_name="longrunningjob", 33 | name="result", 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /api/migrations/0069_rename_to_in_trashcan.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("api", "0068_remove_longrunningjob_result_and_more"), 7 | ] 8 | 9 | operations = [ 10 | migrations.RenameField( 11 | model_name="photo", 12 | old_name="deleted", 13 | new_name="in_trashcan", 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /api/migrations/0070_photo_removed.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.14 on 2024-08-21 19:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0069_rename_to_in_trashcan"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="photo", 14 | name="removed", 15 | field=models.BooleanField(db_index=True, default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /api/migrations/0071_rename_person_label_probability_face_cluster_probability_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-09-20 18:56 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0070_photo_removed"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name="face", 15 | old_name="person_label_probability", 16 | new_name="cluster_probability", 17 | ), 18 | migrations.RemoveField( 19 | model_name="face", 20 | name="person_label_is_inferred", 21 | ), 22 | migrations.AddField( 23 | model_name="face", 24 | name="classification_person", 25 | field=models.ForeignKey( 26 | blank=True, 27 | null=True, 28 | on_delete=django.db.models.deletion.SET_NULL, 29 | related_name="classification_faces", 30 | to="api.person", 31 | ), 32 | ), 33 | migrations.AddField( 34 | model_name="face", 35 | name="classification_probability", 36 | field=models.FloatField(db_index=True, default=0.0), 37 | ), 38 | migrations.AddField( 39 | model_name="face", 40 | name="cluster_person", 41 | field=models.ForeignKey( 42 | blank=True, 43 | null=True, 44 | on_delete=django.db.models.deletion.SET_NULL, 45 | related_name="cluster_classification_faces", 46 | to="api.person", 47 | ), 48 | ), 49 | migrations.AddField( 50 | model_name="face", 51 | name="deleted", 52 | field=models.BooleanField(default=False), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /api/migrations/0072_alter_face_person.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-09-20 19:07 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ( 10 | "api", 11 | "0071_rename_person_label_probability_face_cluster_probability_and_more", 12 | ), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="face", 18 | name="person", 19 | field=models.ForeignKey( 20 | null=True, 21 | on_delete=django.db.models.deletion.DO_NOTHING, 22 | related_name="faces", 23 | to="api.person", 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /api/migrations/0073_remove_unknown_person.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def delete_unknown_person_and_update_faces(apps, schema_editor): 5 | # Get models 6 | Person = apps.get_model("api", "Person") 7 | Face = apps.get_model("api", "Face") 8 | 9 | # Define the name for unknown persons 10 | unknown_person_name = "Unknown - Other" 11 | 12 | # Find all persons with the name "Unknown - Other" 13 | unknown_persons = Person.objects.filter(name=unknown_person_name) 14 | 15 | # Iterate through each unknown person and set faces' person field to null 16 | for unknown_person in unknown_persons: 17 | # Set all faces' person field referencing the "Unknown - Other" person to null 18 | Face.objects.filter(person=unknown_person).update(person=None) 19 | 20 | # Delete the "Unknown - Other" person 21 | unknown_person.delete() 22 | 23 | 24 | def recreate_unknown_person_and_restore_faces(apps, schema_editor): 25 | # Get models 26 | Person = apps.get_model("api", "Person") 27 | Face = apps.get_model("api", "Face") 28 | User = apps.get_model("api", "User") 29 | 30 | # Define the name for unknown persons 31 | unknown_person_name = "Unknown - Other" 32 | 33 | # Retrieve all users to recreate their unknown persons 34 | users = User.objects.all() 35 | 36 | for user in users: 37 | # Recreate the "Unknown - Other" person for each user 38 | unknown_person = Person.objects.create( 39 | name=unknown_person_name, kind=Person.KIND_UNKNOWN, cluster_owner=user 40 | ) 41 | 42 | # Restore faces for each recreated person based on user ownership 43 | Face.objects.filter(person=None, photo__owner=user).update( 44 | person=unknown_person 45 | ) 46 | 47 | 48 | class Migration(migrations.Migration): 49 | dependencies = [ 50 | ( 51 | "api", 52 | "0072_alter_face_person", 53 | ), 54 | ] 55 | 56 | operations = [ 57 | migrations.RunPython( 58 | delete_unknown_person_and_update_faces, 59 | recreate_unknown_person_and_restore_faces, 60 | ), 61 | ] 62 | -------------------------------------------------------------------------------- /api/migrations/0074_migrate_cluster_person.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def move_person_to_cluster_if_kind_cluster(apps, schema_editor): 5 | # Get the necessary models 6 | Face = apps.get_model("api", "Face") 7 | 8 | # Define the constant for KIND_CLUSTER 9 | KIND_CLUSTER = "CLUSTER" 10 | 11 | # Fetch all Face instances where person is not null 12 | faces_to_update = Face.objects.filter(person__isnull=False) 13 | 14 | # Iterate over the faces and process each one 15 | for face in faces_to_update: 16 | # Check if the person is of type KIND_CLUSTER 17 | if face.person.kind == KIND_CLUSTER: 18 | # Move the person to the cluster field and set the person field to null 19 | face.cluster_person = face.person 20 | face.person = None 21 | face.save() 22 | 23 | 24 | def restore_person_from_cluster(apps, schema_editor): 25 | # Get the necessary models 26 | Face = apps.get_model("api", "Face") 27 | 28 | # Fetch all Face instances where original_person_id is not null (from forward migration) 29 | faces_to_restore = Face.objects.filter(cluster_person__isnull=False) 30 | 31 | # Iterate over the faces and restore the person reference from the original_person_id field 32 | for face in faces_to_restore: 33 | face.person = face.cluster_person 34 | face.cluster_person = None 35 | face.save() 36 | 37 | 38 | class Migration(migrations.Migration): 39 | dependencies = [ 40 | ( 41 | "api", 42 | "0073_remove_unknown_person", 43 | ), 44 | ] 45 | 46 | operations = [ 47 | migrations.RunPython( 48 | move_person_to_cluster_if_kind_cluster, 49 | restore_person_from_cluster, 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /api/migrations/0075_alter_face_cluster_person.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-09-20 19:49 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0074_migrate_cluster_person"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="face", 15 | name="cluster_person", 16 | field=models.ForeignKey( 17 | blank=True, 18 | null=True, 19 | on_delete=django.db.models.deletion.SET_NULL, 20 | related_name="cluster_faces", 21 | to="api.person", 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /api/migrations/0076_alter_file_path_alter_longrunningjob_job_type_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.18 on 2025-03-29 12:38 2 | 3 | from django.db import migrations, models 4 | import django_cryptography.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("api", "0075_alter_face_cluster_person"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="file", 16 | name="path", 17 | field=models.TextField(blank=True, default=""), 18 | ), 19 | migrations.AlterField( 20 | model_name="longrunningjob", 21 | name="job_type", 22 | field=models.PositiveIntegerField( 23 | choices=[ 24 | (1, "Scan Photos"), 25 | (2, "Generate Event Albums"), 26 | (3, "Regenerate Event Titles"), 27 | (4, "Train Faces"), 28 | (5, "Delete Missing Photos"), 29 | (7, "Scan Faces"), 30 | (6, "Calculate Clip Embeddings"), 31 | (8, "Find Similar Faces"), 32 | (9, "Download Selected Photos"), 33 | (10, "Download Models"), 34 | (11, "Add Geolocation"), 35 | (12, "Generate Tags"), 36 | (13, "Generate Face Embeddings"), 37 | (14, "Scan Missing Photos"), 38 | ] 39 | ), 40 | ), 41 | migrations.AlterField( 42 | model_name="user", 43 | name="nextcloud_app_password", 44 | field=django_cryptography.fields.encrypt( 45 | models.CharField(blank=True, default="", max_length=64) 46 | ), 47 | ), 48 | migrations.AlterField( 49 | model_name="user", 50 | name="nextcloud_scan_directory", 51 | field=models.CharField( 52 | blank=True, db_index=True, default="", max_length=512 53 | ), 54 | ), 55 | migrations.AlterField( 56 | model_name="user", 57 | name="nextcloud_server_address", 58 | field=models.CharField(blank=True, default="", max_length=200), 59 | ), 60 | migrations.AlterField( 61 | model_name="user", 62 | name="nextcloud_username", 63 | field=models.CharField(blank=True, default="", max_length=64), 64 | ), 65 | ] 66 | -------------------------------------------------------------------------------- /api/migrations/0077_alter_albumdate_title.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.18 on 2025-03-29 12:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("api", "0076_alter_file_path_alter_longrunningjob_job_type_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="albumdate", 15 | name="title", 16 | field=models.CharField( 17 | blank=True, db_index=True, default="", max_length=512 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /api/migrations/0079_alter_albumauto_title.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.18 on 2025-05-04 14:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("api", "0078_create_photo_thumbnail"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="albumauto", 15 | name="title", 16 | field=models.CharField(default="Untitled Album", max_length=512), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /api/migrations/0081_remove_caption_fields_from_photo.py: -------------------------------------------------------------------------------- 1 | # Generated migration to remove caption fields from Photo model 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0080_create_photo_caption'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='photo', 15 | name='captions_json', 16 | ), 17 | migrations.RemoveField( 18 | model_name='photo', 19 | name='search_captions', 20 | ), 21 | ] -------------------------------------------------------------------------------- /api/migrations/0083_remove_search_fields.py: -------------------------------------------------------------------------------- 1 | # Generated migration to remove search fields from Photo and PhotoCaption models 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0082_create_photo_search'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='photo', 15 | name='search_location', 16 | ), 17 | migrations.RemoveField( 18 | model_name='photocaption', 19 | name='search_captions', 20 | ), 21 | ] -------------------------------------------------------------------------------- /api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/api/migrations/__init__.py -------------------------------------------------------------------------------- /api/models/__init__.py: -------------------------------------------------------------------------------- 1 | from api.models.album_auto import AlbumAuto 2 | from api.models.album_date import AlbumDate 3 | from api.models.album_place import AlbumPlace 4 | from api.models.album_thing import AlbumThing 5 | from api.models.album_user import AlbumUser 6 | from api.models.cluster import Cluster 7 | from api.models.face import Face 8 | from api.models.file import File 9 | from api.models.long_running_job import LongRunningJob 10 | from api.models.person import Person 11 | from api.models.photo import Photo 12 | from api.models.photo_caption import PhotoCaption 13 | from api.models.photo_search import PhotoSearch 14 | from api.models.thumbnail import Thumbnail 15 | from api.models.user import User 16 | 17 | __all__ = [ 18 | "AlbumAuto", 19 | "AlbumDate", 20 | "AlbumPlace", 21 | "AlbumThing", 22 | "AlbumUser", 23 | "Cluster", 24 | "Face", 25 | "LongRunningJob", 26 | "Person", 27 | "Photo", 28 | "PhotoCaption", 29 | "PhotoSearch", 30 | "Thumbnail", 31 | "User", 32 | "File", 33 | ] 34 | -------------------------------------------------------------------------------- /api/models/album_date.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from api.models.photo import Photo 4 | from api.models.user import User, get_deleted_user 5 | 6 | 7 | class AlbumDate(models.Model): 8 | title = models.CharField(blank=True, default="", max_length=512, db_index=True) 9 | date = models.DateField(db_index=True, null=True) 10 | photos = models.ManyToManyField(Photo) 11 | favorited = models.BooleanField(default=False, db_index=True) 12 | location = models.JSONField(blank=True, db_index=True, null=True) 13 | owner = models.ForeignKey( 14 | User, on_delete=models.SET(get_deleted_user), default=None 15 | ) 16 | shared_to = models.ManyToManyField(User, related_name="album_date_shared_to") 17 | objects = models.Manager() 18 | 19 | class Meta: 20 | unique_together = ("date", "owner") 21 | 22 | def __str__(self): 23 | return str(self.date) + " (" + str(self.owner) + ")" 24 | 25 | def ordered_photos(self): 26 | return self.photos.all().order_by("-exif_timestamp") 27 | 28 | 29 | def get_or_create_album_date(date, owner): 30 | try: 31 | return AlbumDate.objects.get_or_create(date=date, owner=owner)[0] 32 | except AlbumDate.MultipleObjectsReturned: 33 | return AlbumDate.objects.filter(date=date, owner=owner).first() 34 | 35 | 36 | def get_album_date(date, owner): 37 | try: 38 | return AlbumDate.objects.get(date=date, owner=owner) 39 | except Exception: 40 | return None 41 | 42 | 43 | def get_album_nodate(owner): 44 | return AlbumDate.objects.get_or_create(date=None, owner=owner)[0] 45 | -------------------------------------------------------------------------------- /api/models/album_place.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from api.models.photo import Photo 4 | from api.models.user import User, get_deleted_user 5 | 6 | 7 | class AlbumPlace(models.Model): 8 | title = models.CharField(max_length=512, db_index=True) 9 | photos = models.ManyToManyField(Photo) 10 | geolocation_level = models.IntegerField(db_index=True, null=True) 11 | favorited = models.BooleanField(default=False, db_index=True) 12 | owner = models.ForeignKey( 13 | User, on_delete=models.SET(get_deleted_user), default=None 14 | ) 15 | 16 | shared_to = models.ManyToManyField(User, related_name="album_place_shared_to") 17 | 18 | class Meta: 19 | unique_together = ("title", "owner") 20 | 21 | def __str__(self): 22 | return "%d: %s" % (self.id, self.title) 23 | 24 | 25 | def get_album_place(title, owner): 26 | return AlbumPlace.objects.get_or_create(title=title, owner=owner)[0] 27 | -------------------------------------------------------------------------------- /api/models/album_thing.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models.signals import m2m_changed 3 | from django.dispatch import receiver 4 | 5 | from api.models.photo import Photo 6 | from api.models.user import User, get_deleted_user 7 | 8 | 9 | def update_default_cover_photo(instance): 10 | if instance.cover_photos.count() < 4: 11 | photos_to_add = instance.photos.filter(hidden=False)[ 12 | : 4 - instance.cover_photos.count() 13 | ] 14 | instance.cover_photos.add(*photos_to_add) 15 | 16 | 17 | class AlbumThing(models.Model): 18 | title = models.CharField(max_length=512, db_index=True) 19 | photos = models.ManyToManyField(Photo) 20 | thing_type = models.CharField(max_length=512, db_index=True, null=True) 21 | favorited = models.BooleanField(default=False, db_index=True) 22 | owner = models.ForeignKey( 23 | User, on_delete=models.SET(get_deleted_user), default=None 24 | ) 25 | shared_to = models.ManyToManyField(User, related_name="album_thing_shared_to") 26 | cover_photos = models.ManyToManyField( 27 | Photo, related_name="album_thing_cover_photos" 28 | ) 29 | photo_count = models.IntegerField(default=0) 30 | 31 | class Meta: 32 | constraints = [ 33 | models.UniqueConstraint( 34 | fields=["title", "thing_type", "owner"], name="unique AlbumThing" 35 | ) 36 | ] 37 | 38 | def save(self, *args, **kwargs): 39 | super().save(*args, **kwargs) 40 | 41 | def update_default_cover_photo(self): 42 | update_default_cover_photo(self) 43 | 44 | def __str__(self): 45 | return "%d: %s" % (self.id or 0, self.title) 46 | 47 | 48 | @receiver(m2m_changed, sender=AlbumThing.photos.through) 49 | def update_photo_count(sender, instance, action, reverse, model, pk_set, **kwargs): 50 | if action == "post_add" or (action == "post_remove" and not reverse): 51 | count = instance.photos.filter(hidden=False).count() 52 | instance.photo_count = count 53 | instance.save(update_fields=["photo_count"]) 54 | instance.update_default_cover_photo() 55 | 56 | 57 | def get_album_thing(title, owner, thing_type=None): 58 | return AlbumThing.objects.get_or_create( 59 | title=title, owner=owner, thing_type=thing_type 60 | )[0] 61 | -------------------------------------------------------------------------------- /api/models/album_user.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from api.models.photo import Photo 4 | from api.models.user import User, get_deleted_user 5 | 6 | 7 | class AlbumUser(models.Model): 8 | title = models.CharField(max_length=512) 9 | created_on = models.DateTimeField(auto_now=True, db_index=True) 10 | photos = models.ManyToManyField(Photo) 11 | favorited = models.BooleanField(default=False, db_index=True) 12 | owner = models.ForeignKey( 13 | User, on_delete=models.SET(get_deleted_user), default=None 14 | ) 15 | cover_photo = models.ForeignKey( 16 | Photo, 17 | related_name="album_user", 18 | on_delete=models.SET_NULL, 19 | blank=False, 20 | null=True, 21 | ) 22 | 23 | shared_to = models.ManyToManyField(User, related_name="album_user_shared_to") 24 | 25 | public = models.BooleanField(default=False, db_index=True) 26 | 27 | def __str__(self): 28 | return f"{self.title} ({self.owner.username})" 29 | 30 | class Meta: 31 | unique_together = ("title", "owner") 32 | -------------------------------------------------------------------------------- /api/models/cache.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.core.cache import cache 4 | from django.db.models.signals import post_delete, post_save 5 | 6 | from api.models.album_auto import AlbumAuto 7 | from api.models.album_date import AlbumDate 8 | from api.models.album_place import AlbumPlace 9 | from api.models.album_thing import AlbumThing 10 | from api.models.album_user import AlbumUser 11 | from api.models.face import Face 12 | from api.models.person import Person 13 | from api.models.photo import Photo 14 | 15 | 16 | def change_api_updated_at(sender=None, instance=None, *args, **kwargs): 17 | cache.set("api_updated_at_timestamp", datetime.utcnow()) 18 | 19 | 20 | # for cache invalidation. invalidates all cache on modelviewsets on delete and save on any model 21 | for model in [ 22 | Photo, 23 | Person, 24 | Face, 25 | AlbumDate, 26 | AlbumAuto, 27 | AlbumUser, 28 | AlbumPlace, 29 | AlbumThing, 30 | ]: 31 | post_save.connect(receiver=change_api_updated_at, sender=model) 32 | post_delete.connect(receiver=change_api_updated_at, sender=model) 33 | -------------------------------------------------------------------------------- /api/models/cluster.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from django.core.exceptions import MultipleObjectsReturned 3 | from django.db import models 4 | 5 | from api.models.person import Person 6 | from api.models.user import User, get_deleted_user 7 | from api.util import logger 8 | 9 | UNKNOWN_CLUSTER_ID = -1 10 | UNKNOWN_CLUSTER_NAME = "Other Unknown Cluster" 11 | 12 | 13 | class Cluster(models.Model): 14 | person = models.ForeignKey( 15 | Person, 16 | on_delete=models.SET_NULL, 17 | related_name="clusters", 18 | blank=True, 19 | null=True, 20 | ) 21 | mean_face_encoding = models.TextField() 22 | cluster_id = models.IntegerField(null=True) 23 | name = models.TextField(null=True) 24 | 25 | owner = models.ForeignKey( 26 | User, on_delete=models.SET(get_deleted_user), default=None, null=True 27 | ) 28 | 29 | def __str__(self): 30 | return "%d" % self.id 31 | 32 | def get_mean_encoding_array(self) -> np.ndarray: 33 | return np.frombuffer(bytes.fromhex(self.mean_face_encoding)) 34 | 35 | def set_metadata(self, all_vectors): 36 | self.mean_face_encoding = ( 37 | Cluster.calculate_mean_face_encoding(all_vectors).tobytes().hex() 38 | ) 39 | 40 | @staticmethod 41 | def get_or_create_cluster_by_name(user: User, name): 42 | return Cluster.objects.get_or_create(owner=user, name=name)[0] 43 | 44 | @staticmethod 45 | def get_or_create_cluster_by_id(user: User, cluster_id: int): 46 | try: 47 | return Cluster.objects.get_or_create(owner=user, cluster_id=cluster_id)[0] 48 | except MultipleObjectsReturned: 49 | logger.error( 50 | "Multiple clusters found with id %d. Choosing first one" % cluster_id 51 | ) 52 | return Cluster.objects.filter(owner=user, cluster_id=cluster_id).first() 53 | 54 | @staticmethod 55 | def calculate_mean_face_encoding(all_encodings): 56 | return np.mean(a=all_encodings, axis=0, dtype=np.float64) 57 | 58 | 59 | def get_unknown_cluster(user: User) -> Cluster: 60 | unknown_cluster: Cluster = Cluster.get_or_create_cluster_by_id( 61 | user, UNKNOWN_CLUSTER_ID 62 | ) 63 | if unknown_cluster.person is not None: 64 | unknown_cluster.person = None 65 | unknown_cluster.name = UNKNOWN_CLUSTER_NAME 66 | unknown_cluster.save() 67 | return unknown_cluster 68 | -------------------------------------------------------------------------------- /api/models/long_running_job.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.db import models 4 | 5 | from api.models.user import User, get_deleted_user 6 | 7 | 8 | class LongRunningJob(models.Model): 9 | JOB_SCAN_PHOTOS = 1 10 | JOB_GENERATE_AUTO_ALBUMS = 2 11 | JOB_GENERATE_AUTO_ALBUM_TITLES = 3 12 | JOB_TRAIN_FACES = 4 13 | JOB_DELETE_MISSING_PHOTOS = 5 14 | JOB_CALCULATE_CLIP_EMBEDDINGS = 6 15 | JOB_SCAN_FACES = 7 16 | JOB_CLUSTER_ALL_FACES = 8 17 | JOB_DOWNLOAD_PHOTOS = 9 18 | JOB_DOWNLOAD_MODELS = 10 19 | JOB_ADD_GEOLOCATION = 11 20 | JOB_GENERATE_TAGS = 12 21 | JOB_GENERATE_FACE_EMBEDDINGS = 13 22 | JOB_SCAN_MISSING_PHOTOS = 14 23 | 24 | JOB_TYPES = ( 25 | (JOB_SCAN_PHOTOS, "Scan Photos"), 26 | (JOB_GENERATE_AUTO_ALBUMS, "Generate Event Albums"), 27 | (JOB_GENERATE_AUTO_ALBUM_TITLES, "Regenerate Event Titles"), 28 | (JOB_TRAIN_FACES, "Train Faces"), 29 | (JOB_DELETE_MISSING_PHOTOS, "Delete Missing Photos"), 30 | (JOB_SCAN_FACES, "Scan Faces"), 31 | (JOB_CALCULATE_CLIP_EMBEDDINGS, "Calculate Clip Embeddings"), 32 | (JOB_CLUSTER_ALL_FACES, "Find Similar Faces"), 33 | (JOB_DOWNLOAD_PHOTOS, "Download Selected Photos"), 34 | (JOB_DOWNLOAD_MODELS, "Download Models"), 35 | (JOB_ADD_GEOLOCATION, "Add Geolocation"), 36 | (JOB_GENERATE_TAGS, "Generate Tags"), 37 | (JOB_GENERATE_FACE_EMBEDDINGS, "Generate Face Embeddings"), 38 | (JOB_SCAN_MISSING_PHOTOS, "Scan Missing Photos"), 39 | ) 40 | 41 | job_type = models.PositiveIntegerField( 42 | choices=JOB_TYPES, 43 | ) 44 | 45 | finished = models.BooleanField(default=False, blank=False, null=False) 46 | failed = models.BooleanField(default=False, blank=False, null=False) 47 | job_id = models.CharField(max_length=36, unique=True, db_index=True) 48 | queued_at = models.DateTimeField(default=datetime.now, null=False) 49 | started_at = models.DateTimeField(null=True) 50 | finished_at = models.DateTimeField(null=True) 51 | started_by = models.ForeignKey( 52 | User, on_delete=models.SET(get_deleted_user), default=None 53 | ) 54 | progress_current = models.PositiveIntegerField(default=0) 55 | progress_target = models.PositiveIntegerField(default=0) 56 | -------------------------------------------------------------------------------- /api/nextcloud.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import owncloud as nextcloud 4 | 5 | 6 | def login(user): 7 | nc = nextcloud.Client(user.nextcloud_server_address) 8 | nc.login(user.nextcloud_username, user.nextcloud_app_password) 9 | 10 | def path_to_dict(path): 11 | d = {"title": os.path.basename(path), "absolute_path": path} 12 | try: 13 | d["children"] = [ 14 | path_to_dict(os.path.join(path, x.path)) 15 | for x in nc.list(path) 16 | if x.is_dir() 17 | ] 18 | except Exception: 19 | pass 20 | 21 | return d 22 | 23 | 24 | def list_dir(user, path): 25 | nc = nextcloud.Client(user.nextcloud_server_address) 26 | nc.login(user.nextcloud_username, user.nextcloud_app_password) 27 | return [p.path for p in nc.list(path) if p.is_dir()] 28 | -------------------------------------------------------------------------------- /api/schemas/site_settings.py: -------------------------------------------------------------------------------- 1 | site_settings_schema = { 2 | "type": "object", 3 | "anyOf": [ 4 | {"required": ["allow_registration"]}, 5 | {"required": ["allow_upload"]}, 6 | {"required": ["skip_patterns"]}, 7 | {"required": ["map_api_provider"]}, 8 | {"required": ["map_api_key"]}, 9 | {"required": ["captioning_model"]}, 10 | {"required": ["llm_model"]}, 11 | ], 12 | "properties": { 13 | "allow_registration": {"type": "boolean"}, 14 | "allow_upload": {"type": "boolean"}, 15 | "skip_patterns": {"type": "string"}, 16 | "map_api_provider": {"type": "string"}, 17 | "map_api_key": {"type": "string"}, 18 | "captioning_model": {"type": "string"}, 19 | "llm_model": {"type": "string"}, 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /api/semantic_search.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import requests 3 | from django.conf import settings 4 | 5 | dir_clip_ViT_B_32_model = settings.CLIP_ROOT 6 | 7 | 8 | def create_clip_embeddings(imgs): 9 | json = { 10 | "imgs": imgs, 11 | "model": dir_clip_ViT_B_32_model, 12 | } 13 | clip_embeddings = requests.post( 14 | "http://localhost:8006/clip-embeddings", json=json 15 | ).json() 16 | 17 | imgs_emb = clip_embeddings["imgs_emb"] 18 | magnitudes = clip_embeddings["magnitudes"] 19 | 20 | # Convert Python lists to NumPy arrays 21 | imgs_emb = [np.array(enc) for enc in imgs_emb] 22 | 23 | return imgs_emb, magnitudes 24 | 25 | 26 | def calculate_query_embeddings(query): 27 | json = { 28 | "query": query, 29 | "model": dir_clip_ViT_B_32_model, 30 | } 31 | query_embedding = requests.post( 32 | "http://localhost:8006/query-embeddings", json=json 33 | ).json() 34 | 35 | emb = query_embedding["emb"] 36 | magnitude = query_embedding["magnitude"] 37 | return emb, magnitude 38 | -------------------------------------------------------------------------------- /api/serializers/PhotosGroupedByDate.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | 3 | utc = pytz.UTC 4 | 5 | 6 | class PhotosGroupedByDate: 7 | def __init__(self, location, date, photos): 8 | self.photos = photos 9 | self.date = date 10 | self.location = location 11 | 12 | 13 | def get_photos_ordered_by_date(photos): 14 | from collections import defaultdict 15 | 16 | groups = defaultdict(list) 17 | 18 | for photo in photos: 19 | if photo.exif_timestamp: 20 | groups[photo.exif_timestamp.date().strftime("%Y-%m-%d")].append(photo) 21 | else: 22 | groups[photo.exif_timestamp].append(photo) 23 | 24 | grouped_photo = list(groups.values()) 25 | result = [] 26 | no_timestamp_photos = [] 27 | for group in grouped_photo: 28 | location = "" 29 | if group and group[0].exif_timestamp: 30 | date = group[0].exif_timestamp 31 | result.append(PhotosGroupedByDate(location, date, group)) 32 | else: 33 | date = "No timestamp" 34 | no_timestamp_photos = PhotosGroupedByDate(location, date, group) 35 | # add no timestamp last 36 | if no_timestamp_photos != []: 37 | result.append(no_timestamp_photos) 38 | return result 39 | -------------------------------------------------------------------------------- /api/serializers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/api/serializers/__init__.py -------------------------------------------------------------------------------- /api/serializers/album_auto.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import AlbumAuto 4 | from api.serializers.person import PersonSerializer 5 | from api.serializers.photos import PhotoHashListSerializer 6 | from api.serializers.simple import PhotoSimpleSerializer 7 | 8 | 9 | class AlbumAutoSerializer(serializers.ModelSerializer): 10 | photos = PhotoSimpleSerializer(many=True, read_only=False) 11 | people = serializers.SerializerMethodField() 12 | 13 | class Meta: 14 | model = AlbumAuto 15 | fields = ( 16 | "id", 17 | "title", 18 | "favorited", 19 | "timestamp", 20 | "created_on", 21 | "gps_lat", 22 | "people", 23 | "gps_lon", 24 | "photos", 25 | ) 26 | 27 | def get_people(self, obj) -> PersonSerializer(many=True): 28 | res = [] 29 | for photo in obj.photos.all(): 30 | faces = photo.faces.all() 31 | for face in faces: 32 | serialized_person = PersonSerializer(face.person).data 33 | if serialized_person not in res: 34 | res.append(serialized_person) 35 | return res 36 | 37 | def delete(self, validated_data, id): 38 | album = AlbumAuto.objects.filter(id=id).get() 39 | album.delete() 40 | 41 | 42 | class AlbumAutoListSerializer(serializers.ModelSerializer): 43 | photos = serializers.SerializerMethodField() 44 | photo_count = serializers.SerializerMethodField() 45 | 46 | class Meta: 47 | model = AlbumAuto 48 | fields = ( 49 | "id", 50 | "title", 51 | "timestamp", 52 | "photos", 53 | "photo_count", 54 | "favorited", 55 | ) 56 | 57 | def get_photo_count(self, obj) -> int: 58 | try: 59 | return obj.photo_count 60 | except Exception: 61 | return obj.photos.count() 62 | 63 | def get_photos(self, obj) -> PhotoHashListSerializer: 64 | try: 65 | return PhotoHashListSerializer(obj.cover_photo[0]).data 66 | except Exception: 67 | return "" 68 | -------------------------------------------------------------------------------- /api/serializers/album_date.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import AlbumDate 4 | 5 | 6 | class IncompleteAlbumDateSerializer(serializers.ModelSerializer): 7 | id = serializers.SerializerMethodField() 8 | date = serializers.SerializerMethodField() 9 | location = serializers.SerializerMethodField() 10 | incomplete = serializers.SerializerMethodField() 11 | numberOfItems = serializers.SerializerMethodField("get_number_of_items") 12 | items = serializers.SerializerMethodField() 13 | 14 | class Meta: 15 | model = AlbumDate 16 | fields = ("id", "date", "location", "incomplete", "numberOfItems", "items") 17 | 18 | def get_id(self, obj) -> str: 19 | return str(obj.id) 20 | 21 | def get_date(self, obj) -> str: 22 | if obj.date: 23 | return obj.date.isoformat() 24 | else: 25 | return None 26 | 27 | def get_items(self, obj) -> list: 28 | return [] 29 | 30 | def get_incomplete(self, obj) -> bool: 31 | return True 32 | 33 | def get_number_of_items(self, obj) -> int: 34 | if obj and obj.photo_count: 35 | return obj.photo_count 36 | else: 37 | return 0 38 | 39 | def get_location(self, obj) -> str: 40 | if obj and obj.location: 41 | return obj.location["places"][0] 42 | else: 43 | return "" 44 | 45 | 46 | class AlbumDateSerializer(serializers.ModelSerializer): 47 | id = serializers.SerializerMethodField() 48 | date = serializers.SerializerMethodField() 49 | location = serializers.SerializerMethodField() 50 | incomplete = serializers.SerializerMethodField() 51 | numberOfItems = serializers.SerializerMethodField("get_number_of_items") 52 | items = serializers.SerializerMethodField() 53 | 54 | class Meta: 55 | model = AlbumDate 56 | fields = ("id", "date", "location", "incomplete", "numberOfItems", "items") 57 | 58 | def get_id(self, obj) -> str: 59 | return str(obj.id) 60 | 61 | def get_date(self, obj) -> str: 62 | if obj.date: 63 | return obj.date.isoformat() 64 | else: 65 | return None 66 | 67 | def get_items(self, obj) -> dict: 68 | # This method is removed as we're directly including paginated photos in the response. 69 | pass 70 | 71 | def get_incomplete(self, obj) -> bool: 72 | return False 73 | 74 | def get_number_of_items(self, obj) -> int: 75 | # this will also get added in the response 76 | pass 77 | 78 | def get_location(self, obj) -> str: 79 | if obj and obj.location: 80 | return obj.location["places"][0] 81 | else: 82 | return "" 83 | -------------------------------------------------------------------------------- /api/serializers/album_place.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import AlbumPlace 4 | from api.serializers.photos import GroupedPhotosSerializer, PhotoHashListSerializer 5 | from api.serializers.PhotosGroupedByDate import get_photos_ordered_by_date 6 | from api.serializers.simple import PhotoSuperSimpleSerializer 7 | 8 | 9 | class GroupedPlacePhotosSerializer(serializers.ModelSerializer): 10 | id = serializers.SerializerMethodField() 11 | grouped_photos = serializers.SerializerMethodField() 12 | 13 | class Meta: 14 | model = AlbumPlace 15 | fields = ( 16 | "id", 17 | "title", 18 | "grouped_photos", 19 | ) 20 | 21 | # To-Do: Remove legacy stuff 22 | def get_id(self, obj) -> str: 23 | return str(obj.id) 24 | 25 | def get_grouped_photos(self, obj) -> GroupedPhotosSerializer(many=True): 26 | grouped_photos = get_photos_ordered_by_date(obj.photos.all()) 27 | res = GroupedPhotosSerializer(grouped_photos, many=True).data 28 | return res 29 | 30 | 31 | class AlbumPlaceSerializer(serializers.ModelSerializer): 32 | photos = PhotoSuperSimpleSerializer(many=True, read_only=True) 33 | 34 | class Meta: 35 | model = AlbumPlace 36 | fields = ("id", "title", "photos") 37 | 38 | 39 | class AlbumPlaceListSerializer(serializers.ModelSerializer): 40 | cover_photos = PhotoHashListSerializer(many=True, read_only=True) 41 | photo_count = serializers.SerializerMethodField() 42 | 43 | class Meta: 44 | model = AlbumPlace 45 | fields = ("id", "geolocation_level", "cover_photos", "title", "photo_count") 46 | 47 | def get_photo_count(self, obj) -> int: 48 | return obj.photo_count 49 | -------------------------------------------------------------------------------- /api/serializers/album_thing.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import AlbumThing 4 | from api.serializers.photos import GroupedPhotosSerializer, PhotoHashListSerializer 5 | from api.serializers.PhotosGroupedByDate import get_photos_ordered_by_date 6 | from api.serializers.simple import PhotoSuperSimpleSerializer 7 | 8 | 9 | class GroupedThingPhotosSerializer(serializers.ModelSerializer): 10 | id = serializers.SerializerMethodField() 11 | grouped_photos = serializers.SerializerMethodField() 12 | 13 | class Meta: 14 | model = AlbumThing 15 | fields = ( 16 | "id", 17 | "title", 18 | "grouped_photos", 19 | ) 20 | 21 | def get_id(self, obj) -> str: 22 | return str(obj.id) 23 | 24 | def get_grouped_photos(self, obj) -> GroupedPhotosSerializer(many=True): 25 | grouped_photos = get_photos_ordered_by_date(obj.photos.all()) 26 | res = GroupedPhotosSerializer(grouped_photos, many=True).data 27 | return res 28 | 29 | 30 | class AlbumThingSerializer(serializers.ModelSerializer): 31 | photos = PhotoSuperSimpleSerializer(many=True, read_only=True) 32 | 33 | class Meta: 34 | model = AlbumThing 35 | fields = ("id", "title", "photos") 36 | 37 | 38 | class AlbumThingListSerializer(serializers.ModelSerializer): 39 | cover_photos = PhotoHashListSerializer(many=True, read_only=True) 40 | photo_count = serializers.SerializerMethodField() 41 | 42 | class Meta: 43 | model = AlbumThing 44 | fields = ( 45 | "id", 46 | "cover_photos", 47 | "title", 48 | "photo_count", 49 | "thing_type", 50 | "cover_photos", 51 | ) 52 | 53 | def get_photo_count(self, obj) -> int: 54 | return obj.photo_count 55 | -------------------------------------------------------------------------------- /api/serializers/face.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import Face, Person 4 | 5 | 6 | class PersonFaceListSerializer(serializers.ModelSerializer): 7 | face_url = serializers.SerializerMethodField() 8 | person_label_probability = serializers.SerializerMethodField() 9 | 10 | class Meta: 11 | model = Face 12 | fields = [ 13 | "id", 14 | "image", 15 | "face_url", 16 | "photo", 17 | "timestamp", 18 | "person_label_probability", 19 | ] 20 | 21 | def get_person_label_probability(self, obj): 22 | if obj.analysis_method == "clustering": 23 | return obj.cluster_probability 24 | else: 25 | return obj.classification_probability 26 | 27 | def get_face_url(self, obj): 28 | return obj.image.url 29 | 30 | 31 | class IncompletePersonFaceListSerializer(serializers.ModelSerializer): 32 | face_count = serializers.SerializerMethodField() 33 | 34 | class Meta: 35 | model = Person 36 | fields = ["id", "name", "kind", "face_count"] 37 | 38 | def get_face_count(self, obj) -> int: 39 | if obj and obj.viewable_face_count: 40 | return obj.viewable_face_count 41 | else: 42 | return 0 43 | 44 | 45 | class FaceListSerializer(serializers.ModelSerializer): 46 | person_name = serializers.SerializerMethodField() 47 | face_url = serializers.SerializerMethodField() 48 | person_label_probability = serializers.SerializerMethodField() 49 | 50 | class Meta: 51 | model = Face 52 | fields = ( 53 | "id", 54 | "image", 55 | "face_url", 56 | "photo", 57 | "timestamp", 58 | "person", 59 | "person_label_probability", 60 | "person_name", 61 | ) 62 | 63 | def get_person_label_probability(self, obj) -> float: 64 | return obj.cluster_probability 65 | 66 | def get_face_url(self, obj) -> str: 67 | return obj.image.url 68 | 69 | def get_person_name(self, obj) -> str: 70 | if obj.person: 71 | return obj.person.name 72 | else: 73 | return "Unknown - Other" 74 | -------------------------------------------------------------------------------- /api/serializers/job.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import LongRunningJob 4 | from api.serializers.simple import SimpleUserSerializer 5 | 6 | 7 | class LongRunningJobSerializer(serializers.ModelSerializer): 8 | job_type_str = serializers.SerializerMethodField() 9 | started_by = SimpleUserSerializer(read_only=True) 10 | 11 | class Meta: 12 | model = LongRunningJob 13 | fields = ( 14 | "job_id", 15 | "queued_at", 16 | "finished", 17 | "finished_at", 18 | "started_at", 19 | "failed", 20 | "job_type_str", 21 | "job_type", 22 | "started_by", 23 | "progress_current", 24 | "progress_target", 25 | "id", 26 | ) 27 | 28 | def get_job_type_str(self, obj) -> str: 29 | return dict(LongRunningJob.JOB_TYPES)[obj.job_type] 30 | -------------------------------------------------------------------------------- /api/serializers/simple.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import Photo, User 4 | 5 | 6 | class PhotoSuperSimpleSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Photo 9 | fields = ("image_hash", "rating", "hidden", "exif_timestamp", "public", "video") 10 | 11 | 12 | class PhotoSimpleSerializer(serializers.ModelSerializer): 13 | square_thumbnail = serializers.SerializerMethodField() 14 | 15 | class Meta: 16 | model = Photo 17 | fields = ( 18 | "square_thumbnail", 19 | "image_hash", 20 | "exif_timestamp", 21 | "exif_gps_lat", 22 | "exif_gps_lon", 23 | "rating", 24 | "geolocation_json", 25 | "public", 26 | "video", 27 | ) 28 | 29 | def get_square_thumbnail(self, obj) -> str: 30 | return ( 31 | obj.thumbnail.square_thumbnail.url 32 | if obj.thumbnail and obj.thumbnail.square_thumbnail 33 | else "" 34 | ) 35 | 36 | 37 | class SimpleUserSerializer(serializers.ModelSerializer): 38 | class Meta: 39 | model = User 40 | fields = ( 41 | "id", 42 | "username", 43 | "first_name", 44 | "last_name", 45 | ) 46 | -------------------------------------------------------------------------------- /api/social_graph.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | from django.db import connection 3 | 4 | from api.models import Person 5 | 6 | 7 | def build_social_graph(user): 8 | query = """ 9 | WITH face AS ( 10 | SELECT photo_id, person_id, name, owner_id 11 | FROM api_face 12 | JOIN api_person ON api_person.id = person_id 13 | JOIN api_photo ON api_photo.image_hash = photo_id 14 | WHERE person_id IS NOT NULL 15 | AND owner_id = {} 16 | ) 17 | SELECT f1.name, f2.name 18 | FROM face f1 19 | JOIN face f2 USING (photo_id) 20 | WHERE f1.person_id != f2.person_id 21 | GROUP BY f1.name, f2.name 22 | """.replace("{}", str(user.id)) 23 | G = nx.Graph() 24 | with connection.cursor() as cursor: 25 | cursor.execute(query) 26 | links = cursor.fetchall() 27 | if len(links) == 0: 28 | return {"nodes": [], "links": []} 29 | for link in links: 30 | G.add_edge(link[0], link[1]) 31 | pos = nx.spring_layout(G, k=1 / 2, scale=1000, iterations=20) 32 | return { 33 | "nodes": [{"id": node, "x": pos[0], "y": pos[1]} for node, pos in pos.items()], 34 | "links": [{"source": pair[0], "target": pair[1]} for pair in G.edges()], 35 | } 36 | 37 | 38 | def build_ego_graph(person_id): 39 | G = nx.Graph() 40 | person = Person.objects.prefetch_related("faces__photo__faces__person").filter( 41 | id=person_id 42 | )[0] 43 | for this_person_face in person.faces.all(): 44 | for other_person_face in this_person_face.photo.faces.all(): 45 | G.add_edge(person.name, other_person_face.person.name) 46 | nodes = [{"id": node} for node in G.nodes()] 47 | links = [{"source": pair[0], "target": pair[1]} for pair in G.edges()] 48 | res = {"nodes": nodes, "links": links} 49 | return res 50 | -------------------------------------------------------------------------------- /api/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/api/tests/__init__.py -------------------------------------------------------------------------------- /api/tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/api/tests/fixtures/__init__.py -------------------------------------------------------------------------------- /api/tests/fixtures/api_util/captions_json.py: -------------------------------------------------------------------------------- 1 | captions_json = { 2 | "places365": { 3 | "attributes": [ 4 | "no horizon", 5 | "man made", 6 | "enclosed area", 7 | "cloth", 8 | "natural light", 9 | "wood", 10 | "glass", 11 | "indoor lighting", 12 | "dry", 13 | ], 14 | "categories": ["phone booth", "ticket booth"], 15 | "environment": "indoor", 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /api/tests/fixtures/api_util/expectation.py: -------------------------------------------------------------------------------- 1 | wordcloud_expectation = { 2 | "captions": [ 3 | {"label": "outdoor", "y": 1.9459101490553132}, 4 | {"label": "indoor", "y": 0.6931471805599453}, 5 | {"label": "ticket booth", "y": 0.0}, 6 | {"label": "boardwalk", "y": 0.0}, 7 | {"label": "phone booth", "y": 0.0}, 8 | {"label": "delicatessen", "y": 0.0}, 9 | {"label": "lagoon", "y": 0.0}, 10 | {"label": "tundra", "y": 0.0}, 11 | {"label": "marsh", "y": 0.0}, 12 | {"label": "bakery shop", "y": 0.0}, 13 | {"label": "market outdoor", "y": 0.0}, 14 | {"label": "butchers shop", "y": 0.0}, 15 | {"label": "playground", "y": 0.0}, 16 | {"label": "picnic area", "y": 0.0}, 17 | ], 18 | "people": [], 19 | "locations": [ 20 | {"label": "New South Wales", "y": 1.3862943611198906}, 21 | {"label": "Sydney", "y": 1.3862943611198906}, 22 | {"label": "Australia", "y": 1.3862943611198906}, 23 | {"label": "Maroubra", "y": 1.0986122886681098}, 24 | {"label": "Ladakh", "y": 0.6931471805599453}, 25 | {"label": "Leh", "y": 0.6931471805599453}, 26 | {"label": "Berlin", "y": 0.6931471805599453}, 27 | {"label": "Germany", "y": 0.6931471805599453}, 28 | {"label": "India", "y": 0.6931471805599453}, 29 | {"label": "Lakeshore Road", "y": 0.0}, 30 | {"label": "Shachokol", "y": 0.0}, 31 | {"label": "Kreuzberg", "y": 0.0}, 32 | {"label": "Canada", "y": 0.0}, 33 | {"label": "Bondi Beach", "y": 0.0}, 34 | {"label": "Peterborough County", "y": 0.0}, 35 | {"label": "Lakefield", "y": 0.0}, 36 | {"label": "Main Bazaar", "y": 0.0}, 37 | {"label": "Ontario", "y": 0.0}, 38 | {"label": "Chuchat Yakma", "y": 0.0}, 39 | {"label": "Friedrichshain", "y": 0.0}, 40 | {"label": "Beach Road", "y": 0.0}, 41 | {"label": "Fire Route 47", "y": 0.0}, 42 | ], 43 | } 44 | -------------------------------------------------------------------------------- /api/tests/fixtures/api_util/sunburst_expectation.py: -------------------------------------------------------------------------------- 1 | expectation = { 2 | "name": "Places I've visited", 3 | "children": [ 4 | { 5 | "name": "Australia", 6 | "children": [ 7 | { 8 | "name": "New South Wales", 9 | "children": [{"name": "Sydney", "value": 4, "hex": "#57d3db"}], 10 | "hex": "#b9db57", 11 | } 12 | ], 13 | "hex": "#57d3db", 14 | }, 15 | { 16 | "name": "Canada", 17 | "children": [ 18 | { 19 | "name": "Ontario", 20 | "children": [ 21 | {"name": "Peterborough County", "value": 1, "hex": "#c957db"} 22 | ], 23 | "hex": "#dbae57", 24 | } 25 | ], 26 | "hex": "#dbae57", 27 | }, 28 | { 29 | "name": "Germany", 30 | "children": [ 31 | { 32 | "name": "Berlin", 33 | "children": [ 34 | {"name": "Friedrichshain", "value": 1, "hex": "#db5f57"}, 35 | {"name": "Kreuzberg", "value": 1, "hex": "#69db57"}, 36 | ], 37 | "hex": "#db579e", 38 | } 39 | ], 40 | "hex": "#57d3db", 41 | }, 42 | { 43 | "name": "India", 44 | "children": [ 45 | { 46 | "name": "Ladakh", 47 | "children": [{"name": "Leh", "value": 2, "hex": "#c957db"}], 48 | "hex": "#5784db", 49 | } 50 | ], 51 | "hex": "#db579e", 52 | }, 53 | ], 54 | } 55 | -------------------------------------------------------------------------------- /api/tests/fixtures/geocode/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/api/tests/fixtures/geocode/__init__.py -------------------------------------------------------------------------------- /api/tests/fixtures/location_timeline_test_data.csv: -------------------------------------------------------------------------------- 1 | Canada,2020-08-27 02:19:21.000000 +00:00 2 | Canada,2020-08-27 02:43:55.000000 +00:00 3 | Canada,2020-08-27 22:24:43.000000 +00:00 4 | Canada,2020-08-27 23:57:10.000000 +00:00 5 | Germany,2019-12-14 01:19:03.000000 +00:00 6 | Germany,2019-12-14 20:10:05.000000 +00:00 7 | Germany,2019-12-14 21:06:55.000000 +00:00 8 | Germany,2019-12-14 23:31:11.000000 +00:00 9 | France,2020-12-14 01:12:50.000000 +00:00 10 | France,2020-12-14 01:41:04.000000 +00:00 11 | France,2020-12-14 22:41:32.000000 +00:00 12 | France,2020-12-14 23:40:52.000000 +00:00 13 | Canada,2021-08-10 00:46:32.000000 +00:00 14 | Canada,2021-08-10 00:50:40.000000 +00:00 15 | Canada,2021-08-10 20:47:12.000000 +00:00 16 | Canada,2021-08-10 22:10:06.000000 +00:00 17 | France,2021-10-20 00:19:37.000000 +00:00 18 | France,2021-10-20 01:23:07.000000 +00:00 19 | France,2021-10-20 21:18:53.000000 +00:00 20 | France,2021-10-20 22:30:05.000000 +00:00 21 | -------------------------------------------------------------------------------- /api/tests/fixtures/niaz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/api/tests/fixtures/niaz.jpg -------------------------------------------------------------------------------- /api/tests/test_api_util.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from rest_framework.test import APIClient 3 | 4 | from api.tests.fixtures.api_util.expectation import wordcloud_expectation 5 | from api.tests.fixtures.api_util.photos import photos 6 | from api.tests.fixtures.api_util.sunburst_expectation import ( 7 | expectation as sunburst_expectation, 8 | ) 9 | from api.tests.utils import create_test_photo, create_test_user 10 | 11 | 12 | def create_photos(user): 13 | for p in photos: 14 | create_test_photo(owner=user, **p) 15 | 16 | 17 | def compare_objects_with_ignored_props(result, expectation, ignore): 18 | if isinstance(result, dict) and isinstance(expectation, dict): 19 | result_copy = {k: v for k, v in result.items() if k != ignore} 20 | expectation_copy = {k: v for k, v in expectation.items() if k != ignore} 21 | return all( 22 | compare_objects_with_ignored_props( 23 | result_copy[k], expectation_copy[k], ignore 24 | ) 25 | for k in result_copy 26 | ) and set(result_copy.keys()) == set(expectation_copy.keys()) 27 | if isinstance(result, list) and isinstance(expectation, list): 28 | return len(result) == len(expectation) and all( 29 | compare_objects_with_ignored_props(res, exp, ignore) 30 | for res, exp in zip(result, expectation) 31 | ) 32 | return result == expectation 33 | 34 | 35 | class TestApiUtil(TestCase): 36 | def setUp(self) -> None: 37 | self.client = APIClient() 38 | self.user = create_test_user() 39 | self.client.force_authenticate(user=self.user) 40 | 41 | def test_wordcloud(self): 42 | create_photos(self.user) 43 | response = self.client.get("/api/wordcloud/") 44 | actual = response.json() 45 | self.assertEqual(actual, wordcloud_expectation) 46 | 47 | def test_photo_month_count(self): 48 | create_photos(self.user) 49 | response = self.client.get("/api/photomonthcounts/") 50 | actual = response.json() 51 | self.assertEqual( 52 | actual, 53 | [ 54 | {"month": "2017-8", "count": 6}, 55 | {"month": "2017-9", "count": 0}, 56 | {"month": "2017-10", "count": 3}, 57 | ], 58 | ) 59 | 60 | def test_photo_month_count_no_photos(self): 61 | response = self.client.get("/api/photomonthcounts/") 62 | actual = response.json() 63 | self.assertEqual(actual, []) 64 | 65 | def test_location_sunburst(self): 66 | create_photos(self.user) 67 | response = self.client.get("/api/locationsunburst/") 68 | actual = response.json() 69 | assert compare_objects_with_ignored_props( 70 | actual, sunburst_expectation, ignore="hex" 71 | ) 72 | -------------------------------------------------------------------------------- /api/tests/test_delete_duplicate_photos.py: -------------------------------------------------------------------------------- 1 | from unittest import skip 2 | from unittest.mock import patch 3 | 4 | from django.test import TestCase 5 | from rest_framework.test import APIClient 6 | 7 | from api.tests.utils import create_test_photo, create_test_user 8 | 9 | 10 | class DeleteDuplicatePhotosTest(TestCase): 11 | def setUp(self): 12 | self.client = APIClient() 13 | self.user1 = create_test_user() 14 | self.user2 = create_test_user() 15 | self.client.force_authenticate(user=self.user1) 16 | 17 | @patch("api.models.Photo.delete_duplicate") 18 | def test_delete_duplicate_photos_success(self, delete_photo_mock): 19 | delete_photo_mock.return_value = True 20 | image = create_test_photo(owner=self.user1) 21 | 22 | response = self.client.delete( 23 | "/api/photosedit/duplicate/delete", 24 | format="json", 25 | data={"image_hash": image.image_hash, "path": "/path/to/file"}, 26 | headers={"Content-Type": "application/json"}, 27 | ) 28 | 29 | self.assertEqual(200, response.status_code) 30 | 31 | @patch("api.models.Photo.delete_duplicate") 32 | def test_delete_duplicate_photos_failure(self, delete_photo_mock): 33 | delete_photo_mock.return_value = False 34 | image = create_test_photo(owner=self.user1) 35 | 36 | response = self.client.delete( 37 | "/api/photosedit/duplicate/delete", 38 | format="json", 39 | data={"image_hash": image.image_hash, "path": "/path/to/file"}, 40 | headers={"Content-Type": "application/json"}, 41 | ) 42 | 43 | self.assertEqual(400, response.status_code) 44 | 45 | @skip("BUG?: currently user can delete duplicates of other user") 46 | def test_delete_duplicate_photos_of_other_user(self): 47 | pass 48 | 49 | def test_delete_non_existent_duplicate_photos(self): 50 | response = self.client.delete( 51 | "/api/photosedit/duplicate/delete", 52 | format="json", 53 | data={"image_hash": "non-existent-photo-hash", "path": "/path/to/file"}, 54 | headers={"Content-Type": "application/json"}, 55 | ) 56 | 57 | self.assertEqual(404, response.status_code) 58 | -------------------------------------------------------------------------------- /api/tests/test_directory_watcher_fix.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.db.models import Q 3 | 4 | from api.models import Photo 5 | from api.tests.utils import create_test_photo, create_test_user 6 | 7 | 8 | class DirectoryWatcherFixTest(TestCase): 9 | def setUp(self): 10 | self.user = create_test_user() 11 | 12 | def test_generate_tags_query_works(self): 13 | """Test that the generate_tags query works with the new PhotoCaption model""" 14 | # Create a photo without captions 15 | photo = create_test_photo(owner=self.user) 16 | 17 | # This query should work without FieldError 18 | existing_photos = Photo.objects.filter( 19 | Q(owner=self.user.id) 20 | & ( 21 | Q(caption_instance__isnull=True) 22 | | Q(caption_instance__captions_json__isnull=True) 23 | | Q(caption_instance__captions_json__places365__isnull=True) 24 | ) 25 | ) 26 | 27 | # Should find the photo since it has no captions 28 | self.assertEqual(existing_photos.count(), 1) 29 | self.assertEqual(existing_photos.first(), photo) 30 | 31 | def test_generate_tags_query_excludes_photos_with_places365(self): 32 | """Test that photos with places365 captions are excluded""" 33 | # Create a photo with places365 captions 34 | photo = create_test_photo(owner=self.user) 35 | caption_instance = photo._get_or_create_caption_instance() 36 | caption_instance.captions_json = { 37 | "places365": {"categories": ["outdoor"], "attributes": ["sunny"]} 38 | } 39 | caption_instance.save() 40 | 41 | # This query should exclude the photo since it has places365 captions 42 | existing_photos = Photo.objects.filter( 43 | Q(owner=self.user.id) 44 | & ( 45 | Q(caption_instance__isnull=True) 46 | | Q(caption_instance__captions_json__isnull=True) 47 | | Q(caption_instance__captions_json__places365__isnull=True) 48 | ) 49 | ) 50 | 51 | # Should not find the photo since it has places365 captions 52 | self.assertEqual(existing_photos.count(), 0) 53 | -------------------------------------------------------------------------------- /api/tests/test_only_photos_or_only_videos.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.utils import timezone 3 | from rest_framework.test import APIClient 4 | 5 | from api.models.album_date import AlbumDate 6 | from api.tests.utils import create_test_photo, create_test_user 7 | 8 | 9 | class OnlyPhotosOrOnlyVideosTest(TestCase): 10 | def setUp(self): 11 | self.client = APIClient() 12 | self.user = create_test_user() 13 | self.client.force_authenticate(user=self.user) 14 | 15 | def test_only_photos(self): 16 | now = timezone.now() 17 | photo = create_test_photo(owner=self.user, added_on=now, public=True) 18 | 19 | album = AlbumDate(owner=self.user) 20 | album.id = 1 21 | album.photos.add(photo) 22 | album.save() 23 | 24 | response = self.client.get("/api/albums/date/list?photo=true").url 25 | response = self.client.get(response) 26 | 27 | data = response.json() 28 | self.assertEqual(1, len(data["results"])) 29 | -------------------------------------------------------------------------------- /api/tests/test_photo_captions.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | from rest_framework.test import APIClient 5 | 6 | from api.tests.utils import create_test_photo, create_test_user 7 | 8 | 9 | class PhotoCaptionsTest(TestCase): 10 | def setUp(self): 11 | self.client = APIClient() 12 | self.user1 = create_test_user() 13 | self.user2 = create_test_user() 14 | self.client.force_authenticate(user=self.user1) 15 | 16 | @patch("api.models.Photo._generate_captions_im2txt", autospec=True) 17 | def test_generate_captions_for_my_photo(self, generate_caption_mock): 18 | generate_caption_mock.return_value = True 19 | photo = create_test_photo(owner=self.user1) 20 | 21 | payload = {"image_hash": photo.image_hash} 22 | headers = {"Content-Type": "application/json"} 23 | response = self.client.post( 24 | "/api/photosedit/generateim2txt/", 25 | format="json", 26 | data=payload, 27 | headers=headers, 28 | ) 29 | data = response.json() 30 | 31 | self.assertTrue(data["status"]) 32 | 33 | @patch("api.models.Photo._generate_captions_im2txt", autospec=True) 34 | def test_fail_to_generate_captions_for_my_photo(self, generate_caption_mock): 35 | generate_caption_mock.return_value = False 36 | photo = create_test_photo(owner=self.user1) 37 | 38 | payload = {"image_hash": photo.image_hash} 39 | headers = {"Content-Type": "application/json"} 40 | response = self.client.post( 41 | "/api/photosedit/generateim2txt/", 42 | format="json", 43 | data=payload, 44 | headers=headers, 45 | ) 46 | data = response.json() 47 | 48 | self.assertFalse(data["status"]) 49 | 50 | def test_generate_captions_for_my_photo_of_another_user(self): 51 | photo = create_test_photo(owner=self.user2) 52 | 53 | payload = {"image_hash": photo.image_hash} 54 | headers = {"Content-Type": "application/json"} 55 | response = self.client.post( 56 | "/api/photosedit/generateim2txt/", 57 | format="json", 58 | data=payload, 59 | headers=headers, 60 | ) 61 | data = response.json() 62 | 63 | self.assertEqual(400, response.status_code) 64 | self.assertFalse(data["status"]) 65 | self.assertEqual("you are not the owner of this photo", data["message"]) 66 | -------------------------------------------------------------------------------- /api/tests/test_photo_list_without_timestamp.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.utils import timezone 3 | from rest_framework.test import APIClient 4 | 5 | from api.tests.utils import create_test_photos, create_test_user 6 | 7 | 8 | class PhotoListWithoutTimestampTest(TestCase): 9 | def setUp(self): 10 | self.client = APIClient() 11 | self.user = create_test_user() 12 | self.client.force_authenticate(user=self.user) 13 | 14 | def test_retrieve_photos_without_exif_timestamp(self): 15 | now = timezone.now() 16 | create_test_photos(number_of_photos=1, owner=self.user, added_on=now) 17 | create_test_photos( 18 | number_of_photos=1, owner=self.user, added_on=now, exif_timestamp=now 19 | ) 20 | 21 | response = self.client.get("/api/photos/notimestamp/") 22 | json = response.json() 23 | 24 | self.assertEqual(response.status_code, 200) 25 | self.assertEqual(1, len(json["results"])) 26 | -------------------------------------------------------------------------------- /api/tests/test_photo_summary.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | from rest_framework import status 4 | from rest_framework.test import APIClient 5 | 6 | from api.tests.utils import create_test_photo, create_test_user 7 | 8 | 9 | class PhotoSummaryViewTest(TestCase): 10 | def setUp(self): 11 | self.user = create_test_user(is_admin=True) 12 | self.photo = create_test_photo(owner=self.user) 13 | self.photo.save() 14 | self.client = APIClient() 15 | self.client.force_authenticate(user=self.user) 16 | 17 | def test_summary_view_existing_photo_regular_user(self): 18 | regular_user = create_test_user() 19 | 20 | self.client.force_authenticate(user=regular_user) 21 | photo = create_test_photo(owner=regular_user) 22 | url = reverse("photos-summary", kwargs={"pk": photo.image_hash}) 23 | response = self.client.get(url) 24 | 25 | self.assertEqual(response.status_code, status.HTTP_200_OK) 26 | self.assertFalse(response.data["processing"]) 27 | 28 | def test_summary_view_existing_photo(self): 29 | url = reverse("photos-summary", kwargs={"pk": self.photo.image_hash}) 30 | response = self.client.get(url) 31 | 32 | self.assertEqual(response.status_code, status.HTTP_200_OK) 33 | 34 | self.assertFalse(response.data["processing"]) 35 | 36 | def test_summary_view_nonexistent_photo(self): 37 | url = reverse("photos-summary", kwargs={"pk": "nonexistent_hash"}) 38 | response = self.client.get(url) 39 | 40 | self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) 41 | 42 | def test_summary_view_no_aspect_ratio(self): 43 | # Simulate the case where aspect_ratio is None 44 | if hasattr(self.photo, "thumbnail") and self.photo.thumbnail: 45 | self.photo.thumbnail.aspect_ratio = None 46 | self.photo.thumbnail.save() 47 | self.photo.save() 48 | 49 | url = reverse("photos-summary", kwargs={"pk": self.photo.image_hash}) 50 | response = self.client.get(url) 51 | 52 | self.assertEqual(response.status_code, status.HTTP_200_OK) 53 | 54 | self.assertTrue(response.data["processing"]) 55 | -------------------------------------------------------------------------------- /api/tests/test_predefined_rules.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.test import TestCase 4 | from rest_framework.test import APIClient 5 | 6 | from api.date_time_extractor import DEFAULT_RULES_PARAMS, OTHER_RULES_PARAMS 7 | from api.models import User 8 | 9 | 10 | class PredefinedRulesTest(TestCase): 11 | def setUp(self): 12 | self.admin = User.objects.create_superuser( 13 | "test_admin", "test_admin@test.com", "test_password" 14 | ) 15 | self.client = APIClient() 16 | self.client.force_authenticate(user=self.admin) 17 | 18 | def test_predefined_rules(self): 19 | response = self.client.get("/api/predefinedrules/") 20 | self.assertEqual(200, response.status_code) 21 | data = response.json() 22 | self.assertIsInstance(data, str) 23 | rules = json.loads(data) 24 | self.assertIsInstance(rules, list) 25 | self.assertEqual(15, len(rules)) 26 | 27 | def test_default_rules_on_predefined_rules_endpoint(self): 28 | response = self.client.get("/api/predefinedrules/") 29 | rules = json.loads(response.json()) 30 | default_rules = list(filter(lambda x: x["is_default"], rules)) 31 | self.assertListEqual(DEFAULT_RULES_PARAMS, default_rules) 32 | 33 | def test_default_rules_endpoint(self): 34 | response = self.client.get("/api/defaultrules/") 35 | rules = json.loads(response.json()) 36 | self.assertListEqual(DEFAULT_RULES_PARAMS, rules) 37 | 38 | def test_other_rules(self): 39 | response = self.client.get("/api/predefinedrules/") 40 | rules = json.loads(response.json()) 41 | other_rules = list(filter(lambda x: not x["is_default"], rules)) 42 | self.assertListEqual(OTHER_RULES_PARAMS, other_rules) 43 | -------------------------------------------------------------------------------- /api/tests/test_reading_exif.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.test import TestCase 4 | from django.utils import timezone 5 | from faker import Faker 6 | from rest_framework.test import APIClient 7 | 8 | from api.models import File, Person, Photo 9 | from api.tests.utils import create_test_user 10 | 11 | 12 | class ReadFacesFromPhotosTest(TestCase): 13 | def setUp(self): 14 | self.client = APIClient() 15 | self.user1 = create_test_user(favorite_min_rating=1) 16 | self.client.force_authenticate(user=self.user1) 17 | 18 | def test_reading_from_photo(self): 19 | file = os.path.dirname(os.path.abspath(__file__)) + "/fixtures/niaz.jpg" 20 | 21 | exif_file = os.path.dirname(os.path.abspath(__file__)) + "/fixtures/niaz.xmp" 22 | 23 | fake = Faker() 24 | pk = fake.md5() 25 | os.system("cp " + file + " " + "/tmp/" + str(pk) + ".jpg") 26 | # copy exif file to photo and rename it to have the same name as the photo but with .xmp extension 27 | os.system("cp " + exif_file + " " + "/tmp/" + str(pk) + ".xmp") 28 | # we need a thumbnail in the thumbnails_big folder 29 | os.system( 30 | "cp " + file + " " + "/protected_media/thumbnails_big/" + str(pk) + ".jpg" 31 | ) 32 | 33 | photo = Photo(pk=pk, image_hash=pk, owner=self.user1) 34 | fileObject = File.create("/tmp/" + str(photo.pk) + ".jpg", self.user1) 35 | photo.main_file = fileObject 36 | photo.added_on = timezone.now() 37 | photo.save() 38 | 39 | # Create thumbnail for the photo 40 | from api.models.thumbnail import Thumbnail 41 | 42 | Thumbnail.objects.create( 43 | photo=photo, 44 | thumbnail_big="/protected_media/thumbnails_big/" + str(photo.pk) + ".jpg", 45 | aspect_ratio=1.0, 46 | ) 47 | 48 | photo._extract_faces() 49 | 50 | # To Debug Face Extraction: Look at the actual produced thumbnail 51 | # Thumbnail is wrong at the moment, need to create a correct face tag first, where I know the face is correct 52 | # output_file = ( 53 | # os.path.dirname(os.path.abspath(__file__)) 54 | # + "/fixtures/niaz_face.jpg" 55 | # ) 56 | # os.system("cp " + "/protected_media/faces/" + str(photo.pk) + "_0.jpg" + " " + output_file) 57 | 58 | self.assertEqual(1, len(photo.faces.all())) 59 | # One Niaz Faridani-Rad 60 | self.assertEqual(1, len(Person.objects.all())) 61 | # There has to be a face encoding 62 | self.assertIsNotNone(photo.faces.all()[0].encoding) 63 | self.assertEqual( 64 | "Niaz Faridani-Rad", 65 | Person.objects.filter(name="Niaz Faridani-Rad").first().name, 66 | ) 67 | -------------------------------------------------------------------------------- /api/tests/test_recently_added_photos.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from unittest import skip 3 | 4 | from django.test import TestCase 5 | from django.utils import timezone 6 | from rest_framework.test import APIClient 7 | 8 | from api.models import Photo 9 | from api.tests.utils import create_test_photos, create_test_user 10 | 11 | 12 | class RecentlyAddedPhotosTest(TestCase): 13 | def setUp(self): 14 | self.client = APIClient() 15 | self.user1 = create_test_user() 16 | self.user2 = create_test_user() 17 | self.client.force_authenticate(user=self.user1) 18 | 19 | def test_retrieve_recently_added_photos(self): 20 | today = timezone.now() 21 | before_today = timezone.now() - timedelta(days=1) 22 | create_test_photos(number_of_photos=3, owner=self.user1, added_on=today) 23 | create_test_photos(number_of_photos=4, owner=self.user1, added_on=before_today) 24 | create_test_photos(number_of_photos=5, owner=self.user2, added_on=today) 25 | 26 | response = self.client.get("/api/photos/recentlyadded/") 27 | json = response.json() 28 | 29 | self.assertEqual(response.status_code, 200) 30 | self.assertEqual(3, len(json["results"])) 31 | 32 | @skip("not implemented yet") 33 | # TODO: implement scenario 34 | def test_retrieve_empty_result_when_no_photos(self): 35 | Photo.objects.delete() 36 | response = self.client.get("/api/photos/recentlyadded/") 37 | json = response.json() 38 | 39 | self.assertEqual(response.status_code, 200) 40 | self.assertEqual(0, len(json["results"])) 41 | -------------------------------------------------------------------------------- /api/tests/test_regenerate_titles.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytz 4 | from django.test import TestCase 5 | 6 | from api.models import AlbumAuto, User 7 | 8 | 9 | class RegenerateTitlesTestCase(TestCase): 10 | def test_regenerate_titles(self): 11 | admin = User.objects.create_superuser( 12 | "test_admin", "test_admin@test.com", "test_password" 13 | ) 14 | album_auto = AlbumAuto.objects.create( 15 | timestamp=datetime.strptime("2022-01-02", "%Y-%m-%d").replace( 16 | tzinfo=pytz.utc 17 | ), 18 | created_on=datetime.strptime("2022-01-02", "%Y-%m-%d").replace( 19 | tzinfo=pytz.utc 20 | ), 21 | owner=admin, 22 | ) 23 | album_auto._generate_title() 24 | self.assertEqual(album_auto.title, "Sunday") 25 | -------------------------------------------------------------------------------- /api/tests/test_retrieve_photo.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from rest_framework.test import APIClient 3 | 4 | from api.tests.utils import create_test_photo, create_test_user 5 | 6 | 7 | class RetrievePhotoTest(TestCase): 8 | def setUp(self): 9 | self.client = APIClient() 10 | self.admin = create_test_user(is_admin=True) 11 | self.user = create_test_user() 12 | 13 | def test_should_retrieve_my_photo(self): 14 | self.client.force_authenticate(user=self.user) 15 | photo = create_test_photo(owner=self.user) 16 | 17 | headers = {"Content-Type": "application/json"} 18 | response = self.client.get( 19 | f"/api/photos/{photo.image_hash}/", 20 | format="json", 21 | headers=headers, 22 | ) 23 | 24 | self.assertEqual(200, response.status_code) 25 | 26 | def test_should_not_retrieve_other_user_photo(self): 27 | self.client.force_authenticate(user=self.user) 28 | photo = create_test_photo(owner=self.admin) 29 | 30 | headers = {"Content-Type": "application/json"} 31 | response = self.client.get( 32 | f"/api/photos/{photo.image_hash}/", 33 | format="json", 34 | headers=headers, 35 | ) 36 | 37 | self.assertEqual(403, response.status_code) 38 | 39 | def test_anonymous_user_should_retrieve_public_photo(self): 40 | self.client.force_authenticate(None) 41 | photo = create_test_photo(owner=self.user, public=True) 42 | 43 | headers = {"Content-Type": "application/json"} 44 | response = self.client.get( 45 | f"/api/photos/{photo.image_hash}/", 46 | format="json", 47 | headers=headers, 48 | ) 49 | 50 | self.assertEqual(200, response.status_code) 51 | 52 | def test_anonymous_user_should_not_retrieve_private_photo(self): 53 | self.client.force_authenticate(None) 54 | photo = create_test_photo(owner=self.user, public=False) 55 | 56 | headers = {"Content-Type": "application/json"} 57 | response = self.client.get( 58 | f"/api/photos/{photo.image_hash}/", 59 | format="json", 60 | headers=headers, 61 | ) 62 | 63 | self.assertEqual(404, response.status_code) 64 | -------------------------------------------------------------------------------- /api/tests/test_setup_directory.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from rest_framework.test import APIClient 3 | 4 | from api.models import User 5 | from api.tests.utils import create_password 6 | 7 | 8 | class SetupDirectoryTestCase(TestCase): 9 | userid = 0 10 | 11 | def setUp(self): 12 | self.client = APIClient() 13 | self.admin = User.objects.create_superuser( 14 | "test_admin", "test_admin@test.com", create_password() 15 | ) 16 | 17 | def test_setup_directory(self): 18 | self.client.force_authenticate(user=self.admin) 19 | response = self.client.patch( 20 | f"/api/manage/user/{self.admin.id}/", 21 | {"scan_directory": "/code"}, 22 | ) 23 | self.assertEqual(response.status_code, 200) 24 | 25 | def test_setup_not_existing_directory(self): 26 | self.client.force_authenticate(user=self.admin) 27 | response = self.client.patch( 28 | f"/api/manage/user/{self.admin.id}/", 29 | {"scan_directory": "/non-existent-directory"}, 30 | ) 31 | self.assertEqual(response.status_code, 400) 32 | self.assertEqual(response.json(), ["Scan directory does not exist"]) 33 | -------------------------------------------------------------------------------- /api/tests/test_zip_list_photos_view_v2.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | from django.utils import timezone 5 | from rest_framework.test import APIClient 6 | 7 | from api.models.long_running_job import LongRunningJob 8 | from api.tests.utils import create_test_photos, create_test_user 9 | 10 | 11 | class PhotoListWithoutTimestampTest(TestCase): 12 | def setUp(self): 13 | self.client = APIClient() 14 | self.user = create_test_user() 15 | self.client.force_authenticate(user=self.user) 16 | 17 | @patch("shutil.disk_usage") 18 | def test_download(self, patched_shutil): 19 | # test download function when we have enough storage 20 | patched_shutil.return_value.free = 500000000 21 | now = timezone.now() 22 | create_test_photos(number_of_photos=1, owner=self.user, added_on=now, size=100) 23 | 24 | response = self.client.get("/api/photos/notimestamp/") 25 | img_hash = response.json()["results"][0]["url"] 26 | datadict = {"owner": self.user, "image_hashes": [img_hash]} 27 | 28 | response_2 = self.client.post("/api/photos/download", data=datadict) 29 | lrr_job = LongRunningJob.objects.all()[0] 30 | self.assertEqual(lrr_job.job_id, response_2.json()["job_id"]) 31 | self.assertEqual(response_2.status_code, 200) 32 | 33 | # test download function when we dont have enough storage 34 | patched_shutil.return_value.free = 0 35 | response_3 = self.client.post("/api/photos/download", data=datadict) 36 | self.assertEqual(response_3.status_code, 507) 37 | -------------------------------------------------------------------------------- /api/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/api/views/__init__.py -------------------------------------------------------------------------------- /api/views/custom_api_view.py: -------------------------------------------------------------------------------- 1 | from rest_framework import mixins, viewsets 2 | 3 | 4 | class ListViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): 5 | """A viewset that provides `list` actions. 6 | 7 | To use it, override the class and set the `.queryset` and 8 | `.serializer_class` attributes. 9 | """ 10 | 11 | pass 12 | -------------------------------------------------------------------------------- /api/views/jobs.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Prefetch 2 | from rest_framework import viewsets 3 | from rest_framework.response import Response 4 | from rest_framework.views import APIView 5 | 6 | from api.models import LongRunningJob, User 7 | from api.serializers.job import LongRunningJobSerializer 8 | from api.views.pagination import TinyResultsSetPagination 9 | 10 | 11 | class LongRunningJobViewSet(viewsets.ModelViewSet): 12 | queryset = ( 13 | LongRunningJob.objects.prefetch_related( 14 | Prefetch( 15 | "started_by", 16 | queryset=User.objects.only("id", "username", "first_name", "last_name"), 17 | ), 18 | ) 19 | .all() 20 | .order_by("-started_at") 21 | ) 22 | serializer_class = LongRunningJobSerializer 23 | pagination_class = TinyResultsSetPagination 24 | 25 | 26 | class QueueAvailabilityView(APIView): 27 | def get(self, request, format=None): 28 | job_detail = None 29 | 30 | running_job = ( 31 | LongRunningJob.objects.filter(finished=False).order_by("-started_at").last() 32 | ) 33 | if running_job: 34 | job_detail = LongRunningJobSerializer(running_job).data 35 | 36 | return Response( 37 | { 38 | "status": True, 39 | "queue_can_accept_job": job_detail is None, 40 | "job_detail": job_detail, 41 | } 42 | ) 43 | -------------------------------------------------------------------------------- /api/views/pagination.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import PageNumberPagination 2 | 3 | 4 | class HugeResultsSetPagination(PageNumberPagination): 5 | page_size = 50000 6 | page_size_query_param = "page_size" 7 | max_page_size = 100000 8 | 9 | 10 | class StandardResultsSetPagination(PageNumberPagination): 11 | page_size = 10000 12 | page_size_query_param = "page_size" 13 | max_page_size = 100000 14 | 15 | 16 | class RegularResultsSetPagination(PageNumberPagination): 17 | page_size = 100 18 | page_size_query_param = "page_size" 19 | max_page_size = 100000 20 | 21 | 22 | class TinyResultsSetPagination(PageNumberPagination): 23 | page_size = 20 24 | page_size_query_param = "page_size" 25 | max_page_size = 50 26 | -------------------------------------------------------------------------------- /api/views/services.py: -------------------------------------------------------------------------------- 1 | from rest_framework import status, viewsets 2 | from rest_framework.decorators import action 3 | from rest_framework.permissions import IsAdminUser 4 | from rest_framework.response import Response 5 | 6 | from api.services import SERVICES, is_healthy, start_service, stop_service 7 | 8 | 9 | class ServiceViewSet(viewsets.ViewSet): 10 | permission_classes = [IsAdminUser] 11 | 12 | def list(self, request): 13 | return Response({"services": SERVICES}) 14 | 15 | def retrieve(self, request, pk=None): 16 | service_name = pk 17 | 18 | if service_name not in SERVICES: 19 | return Response( 20 | {"error": f"Service {service_name} not found"}, 21 | status=status.HTTP_404_NOT_FOUND, 22 | ) 23 | 24 | healthy = is_healthy(service_name) 25 | return Response({"service_name": service_name, "healthy": healthy}) 26 | 27 | @action(detail=True, methods=["post"]) 28 | def start(self, request, pk=None): 29 | service_name = pk 30 | 31 | if service_name not in SERVICES: 32 | return Response( 33 | {"error": f"Service {service_name} not found"}, 34 | status=status.HTTP_404_NOT_FOUND, 35 | ) 36 | 37 | start_result = start_service(service_name) 38 | if start_result: 39 | return Response( 40 | {"message": f"Service {service_name} started successfully"}, 41 | status=status.HTTP_200_OK, 42 | ) 43 | else: 44 | return Response( 45 | {"error": f"Failed to start service {service_name}"}, 46 | status=status.HTTP_500_INTERNAL_SERVER_ERROR, 47 | ) 48 | 49 | @action(detail=True, methods=["post"]) 50 | def stop(self, request, pk=None): 51 | service_name = pk 52 | 53 | if service_name not in SERVICES: 54 | return Response( 55 | {"error": f"Service {service_name} not found"}, 56 | status=status.HTTP_404_NOT_FOUND, 57 | ) 58 | 59 | stop_result = stop_service(service_name) 60 | if stop_result: 61 | return Response( 62 | {"message": f"Service {service_name} stopped successfully"}, 63 | status=status.HTTP_200_OK, 64 | ) 65 | else: 66 | return Response( 67 | {"error": f"Failed to stop service {service_name}"}, 68 | status=status.HTTP_500_INTERNAL_SERVER_ERROR, 69 | ) 70 | -------------------------------------------------------------------------------- /api/views/timezone.py: -------------------------------------------------------------------------------- 1 | from rest_framework.response import Response 2 | from rest_framework.views import APIView 3 | 4 | from api import date_time_extractor 5 | 6 | 7 | class TimeZoneView(APIView): 8 | def get(self, request, format=None): 9 | return Response(date_time_extractor.ALL_TIME_ZONES_JSON) 10 | -------------------------------------------------------------------------------- /image_similarity/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/image_similarity/__init__.py -------------------------------------------------------------------------------- /image_similarity/main.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from flask import Flask, jsonify, request 4 | from flask_restful import Api, Resource 5 | from gevent.pywsgi import WSGIServer 6 | from retrieval_index import RetrievalIndex 7 | from utils import logger 8 | 9 | app = Flask(__name__) 10 | api = Api(app) 11 | 12 | index = RetrievalIndex() 13 | 14 | 15 | class BuildIndex(Resource): 16 | def post(self): 17 | request_body = json.loads(request.data) 18 | 19 | user_id = request_body["user_id"] 20 | image_hashes = request_body["image_hashes"] 21 | image_embeddings = request_body["image_embeddings"] 22 | 23 | index.build_index_for_user(user_id, image_hashes, image_embeddings) 24 | 25 | # Return 0 if no index was created, otherwise return the actual size 26 | index_size = index.indices[user_id].ntotal if user_id in index.indices else 0 27 | return jsonify({"status": True, "index_size": index_size}) 28 | 29 | def delete(self): 30 | user_id = json.loads(request.data)["user_id"] 31 | if user_id not in index.indices: 32 | return jsonify({"status": True}) 33 | del index.indices[user_id] 34 | del index.image_hashes[user_id] 35 | return jsonify({"status": True}) 36 | 37 | 38 | class SearchIndex(Resource): 39 | def post(self): 40 | try: 41 | request_body = json.loads(request.data) 42 | 43 | user_id = request_body["user_id"] 44 | image_embedding = request_body["image_embedding"] 45 | if "n" in request_body.keys(): 46 | n = int(request_body["n"]) 47 | else: 48 | n = 100 49 | 50 | if "threshold" in request_body.keys(): 51 | thres = float(request_body["threshold"]) 52 | else: 53 | thres = 27.0 54 | 55 | res = index.search_similar(user_id, image_embedding, n, thres) 56 | 57 | return jsonify({"status": True, "result": res}) 58 | except BaseException as e: 59 | logger.error(str(e)) 60 | return jsonify({"status": False, "result": []}), 500 61 | 62 | 63 | class Health(Resource): 64 | def get(self): 65 | return jsonify({"status": True}) 66 | 67 | 68 | api.add_resource(BuildIndex, "/build/") 69 | api.add_resource(SearchIndex, "/search/") 70 | api.add_resource(Health, "/health/") 71 | 72 | 73 | def start_server(): 74 | logger.info("Starting server") 75 | server = WSGIServer(("0.0.0.0", 8002), app) 76 | server.serve_forever() 77 | 78 | 79 | if __name__ == "__main__": 80 | start_server() 81 | -------------------------------------------------------------------------------- /image_similarity/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.handlers 3 | import os 4 | import os.path 5 | 6 | BASE_LOGS = os.environ.get("BASE_LOGS", "/logs/") 7 | 8 | logger = logging.getLogger("image_similarity") 9 | formatter = logging.Formatter( 10 | "%(asctime)s : %(filename)s : %(funcName)s : %(lineno)s : %(levelname)s : %(message)s" 11 | ) 12 | fileMaxByte = 256 * 1024 * 200 # 100MB 13 | 14 | fileHandler = logging.handlers.RotatingFileHandler( 15 | os.path.join(BASE_LOGS, "image_similarity.log"), 16 | maxBytes=fileMaxByte, 17 | backupCount=10, 18 | ) 19 | 20 | fileHandler.setFormatter(formatter) 21 | logger.addHandler(fileHandler) 22 | logger.setLevel(logging.INFO) 23 | -------------------------------------------------------------------------------- /librephotos/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/librephotos/__init__.py -------------------------------------------------------------------------------- /librephotos/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/librephotos/settings/__init__.py -------------------------------------------------------------------------------- /librephotos/settings/development.py: -------------------------------------------------------------------------------- 1 | from .production import * # noqa 2 | 3 | DEBUG = True 4 | MIDDLEWARE += ["silk.middleware.SilkyMiddleware"] # noqa 5 | INSTALLED_APPS += ["silk"] # noqa 6 | INSTALLED_APPS += ["drf_spectacular"] 7 | SPECTACULAR_SETTINGS = { 8 | "TITLE": "LibrePhotos", 9 | "DESCRIPTION": "Your project description", 10 | "VERSION": "1.0.0", 11 | } 12 | -------------------------------------------------------------------------------- /librephotos/settings/test.py: -------------------------------------------------------------------------------- 1 | from .production import * # noqa 2 | 3 | DEBUG = True 4 | LOGGING = { 5 | "version": 1, 6 | "disable_existing_loggers": True, 7 | "handlers": { 8 | "null": { 9 | "class": "logging.NullHandler", 10 | }, 11 | }, 12 | "root": { 13 | "handlers": ["null"], 14 | "level": "CRITICAL", 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /librephotos/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | environment = "production" 6 | if os.environ.get("DEBUG", "0") == "1": 7 | environment = "development" 8 | 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", f"librephotos.settings.{environment}") 10 | 11 | application = get_wsgi_application() 12 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | environment = "production" 7 | if os.environ.get("DEBUG", "0") == "1": 8 | environment = "development" 9 | 10 | try: 11 | command = sys.argv[1] 12 | except IndexError: 13 | command = "help" 14 | 15 | do_not_collect_coverage = os.environ.get("NO_COVERAGE") is not None 16 | running_tests = command == "test" 17 | if running_tests: 18 | environment = "test" 19 | if running_tests and not do_not_collect_coverage: 20 | from coverage import Coverage 21 | 22 | cov = Coverage() 23 | cov.erase() 24 | cov.start() 25 | 26 | from django.core.management import execute_from_command_line 27 | 28 | os.environ.setdefault( 29 | "DJANGO_SETTINGS_MODULE", f"librephotos.settings.{environment}" 30 | ) 31 | execute_from_command_line(sys.argv) 32 | 33 | if running_tests and not do_not_collect_coverage: 34 | cov.stop() 35 | cov.save() 36 | cov.html_report() 37 | covered = cov.report() 38 | -------------------------------------------------------------------------------- /nextcloud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/nextcloud/__init__.py -------------------------------------------------------------------------------- /nextcloud/admin.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/nextcloud/admin.py -------------------------------------------------------------------------------- /nextcloud/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class NextcloudConfig(AppConfig): 5 | name = "nextcloud" 6 | -------------------------------------------------------------------------------- /nextcloud/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/nextcloud/models.py -------------------------------------------------------------------------------- /nextcloud/tests.py: -------------------------------------------------------------------------------- 1 | # from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /nextcloud/views.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from urllib.parse import urlparse 3 | 4 | import owncloud as nextcloud 5 | from django_q.tasks import AsyncTask 6 | from drf_spectacular.utils import extend_schema 7 | from rest_framework.response import Response 8 | from rest_framework.views import APIView 9 | 10 | from api.util import logger 11 | from nextcloud.directory_watcher import scan_photos 12 | 13 | 14 | class ListDir(APIView): 15 | def get(self, request, format=None): 16 | if not request.query_params.get("fpath"): 17 | return Response([]) 18 | path = request.query_params["fpath"] 19 | 20 | if not request.user.nextcloud_server_address or not valid_url( 21 | request.user.nextcloud_server_address 22 | ): 23 | return Response([]) 24 | 25 | nc = nextcloud.Client(request.user.nextcloud_server_address) 26 | nc.login(request.user.nextcloud_username, request.user.nextcloud_app_password) 27 | try: 28 | return Response( 29 | [ 30 | { 31 | "absolute_path": p.path, 32 | "title": p.path.split("/")[-2], 33 | "children": [], 34 | } 35 | for p in nc.list(path) 36 | if p.is_dir() 37 | ] 38 | ) 39 | except nextcloud.HTTPResponseError: 40 | return Response(status=400) 41 | 42 | 43 | def valid_url(url): 44 | try: 45 | urlparse(url) 46 | return True 47 | except BaseException: 48 | return False 49 | 50 | 51 | class ScanPhotosView(APIView): 52 | def post(self, request, format=None): 53 | return self._scan_photos(request) 54 | 55 | @extend_schema( 56 | deprecated=True, 57 | description="Use POST method instead", 58 | ) 59 | def get(self, request, format=None): 60 | return self._scan_photos(request) 61 | 62 | def _scan_photos(self, request): 63 | try: 64 | job_id = uuid.uuid4() 65 | AsyncTask(scan_photos, request.user, job_id).run() 66 | return Response({"status": True, "job_id": job_id}) 67 | except BaseException: 68 | logger.exception("An Error occurred") 69 | return Response({"status": False}) 70 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | line-length = 88 3 | target-version = "py311" 4 | extend-exclude = ["migrations", "im2txt", "blip", "places365"] 5 | 6 | [lint] 7 | select = [ 8 | "E", # Pyflakes (errors) 9 | "F", # Pyflakes 10 | "W", # pycodestyle warnings 11 | "I", # isort (import sorting) 12 | "N", # PEP8 naming 13 | "UP", # Pyupgrade (modern syntax) 14 | "DJ", # pylint-django equivalent 15 | "PL", # pylint rules 16 | ] 17 | ignore = ["E501", "E203", "E231"] 18 | 19 | [tool.ruff.format] 20 | quote-style = "double" 21 | indent-style = "space" 22 | line-ending = "lf" 23 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "packageRules": [ 4 | { 5 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 6 | "automerge": true 7 | } 8 | ], 9 | "extends": [ 10 | "config:base" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | ipdb==0.13.13 2 | ipython==8.35.0 3 | ipython-genutils==0.2.0 4 | Pygments==2.19.1 5 | prompt-toolkit==3.0.51 6 | nose==1.3.7 7 | pre-commit==4.2.0 8 | coverage==7.8.2 9 | Faker==33.3.1 10 | setuptools==78.1.1 11 | pyfakefs==5.8.0 12 | pytest==8.3.5 13 | ruff==0.11.11 14 | -------------------------------------------------------------------------------- /requirements.mlval.txt: -------------------------------------------------------------------------------- 1 | pycocotools==2.0.8 2 | pycocoevalcap==1.2 3 | nltk==3.9.1 4 | matplotlib==3.10.3 5 | onnx==1.17.0 6 | onnxscript -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==4.2.21 2 | django-constance==4.3.2 3 | django-cors-headers==4.7.0 4 | git+https://github.com/derneuere/django-chunked-upload@master#egg=django-chunked-upload 5 | django-cryptography==1.1 6 | django-extensions==3.2.3 7 | django-filter==24.3 8 | django-picklefield==3.3 9 | django-bulk-update 10 | django-silk==5.3.2 11 | djangorestframework==3.16.0 12 | djangorestframework-simplejwt==5.5.0 13 | drf-spectacular==0.28.0 14 | face-recognition==1.3.0 15 | faiss-cpu==1.10.0 16 | Flask==3.1.1 17 | Flask-Cors==6.0.0 18 | Flask-RESTful==0.3.10 19 | geopy==2.4.1 20 | gunicorn==23.0.0 21 | hdbscan==0.8.40 22 | matplotlib==3.10.3 23 | networkx==3.4.2 24 | nltk==3.9.1 25 | markupsafe==3.0.2 26 | Pillow==10.4.0 27 | psycopg==3.2.9 28 | https://github.com/owncloud/pyocclient/archive/master.zip 29 | pytz==2024.2 30 | tzdata==2024.2 31 | PyExifTool==0.4.9 32 | pyvips==2.2.3 33 | scikit-learn<1.6.2 34 | seaborn==0.13.2 35 | sentence_transformers==2.7.0 36 | timezonefinder==6.5.9 37 | tqdm==4.67.1 38 | gevent==24.11.1 39 | python-magic==0.4.27 40 | Wand==0.6.13 41 | django-q2==1.7.6 42 | safetensors==0.5.3 43 | py-cpuinfo==9.0.0 44 | psutil==6.1.1 45 | 46 | # Dependencies for blip 47 | timm==1.0.15 48 | # Dependencies for Moondream chat handler (llama-cpp-python multimodal) 49 | transformers==4.51.3 50 | # Dependencies for mistral quantized and multimodal models like Moondream 51 | llama-cpp-python==0.3.9 52 | -------------------------------------------------------------------------------- /screenshots/lp-square-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/screenshots/lp-square-black.png -------------------------------------------------------------------------------- /screenshots/lp-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/screenshots/lp-white.png -------------------------------------------------------------------------------- /screenshots/mockups_main_fhd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/screenshots/mockups_main_fhd.png -------------------------------------------------------------------------------- /screenshots/more_to_discover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/screenshots/more_to_discover.png -------------------------------------------------------------------------------- /screenshots/photo_info_fhd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/screenshots/photo_info_fhd.png -------------------------------------------------------------------------------- /screenshots/photo_manage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/screenshots/photo_manage.png -------------------------------------------------------------------------------- /screenshots/site-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/screenshots/site-logo.png -------------------------------------------------------------------------------- /service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/service/__init__.py -------------------------------------------------------------------------------- /service/clip_embeddings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/service/clip_embeddings/__init__.py -------------------------------------------------------------------------------- /service/clip_embeddings/main.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import gevent 4 | from flask import Flask, request 5 | from gevent.pywsgi import WSGIServer 6 | from semantic_search.semantic_search import SemanticSearch 7 | 8 | app = Flask(__name__) 9 | 10 | 11 | def log(message): 12 | print(f"clip embeddings: {message}") 13 | 14 | 15 | semantic_search_instance = None 16 | last_request_time = None 17 | 18 | 19 | @app.route("/clip-embeddings", methods=["POST"]) 20 | def create_clip_embeddings(): 21 | global last_request_time 22 | # Update last request time 23 | last_request_time = time.time() 24 | 25 | try: 26 | data = request.get_json() 27 | imgs = data["imgs"] 28 | model = data["model"] 29 | except Exception as e: 30 | print(str(e)) 31 | return "", 400 32 | 33 | global semantic_search_instance 34 | 35 | if semantic_search_instance is None: 36 | semantic_search_instance = SemanticSearch() 37 | 38 | imgs_emb, magnitudes = semantic_search_instance.calculate_clip_embeddings( 39 | imgs, model 40 | ) 41 | # Convert NumPy arrays to Python lists 42 | imgs_emb_list = [enc.tolist() for enc in imgs_emb] 43 | magnitudes = [float(m) for m in magnitudes] 44 | return {"imgs_emb": imgs_emb_list, "magnitudes": magnitudes}, 201 45 | 46 | 47 | @app.route("/query-embeddings", methods=["POST"]) 48 | def calculate_query_embeddings(): 49 | global last_request_time 50 | # Update last request time 51 | last_request_time = time.time() 52 | 53 | try: 54 | data = request.get_json() 55 | query = data["query"] 56 | model = data["model"] 57 | except Exception as e: 58 | print(str(e)) 59 | return "", 400 60 | global semantic_search_instance 61 | 62 | if semantic_search_instance is None: 63 | semantic_search_instance = SemanticSearch() 64 | 65 | emb, magnitude = semantic_search_instance.calculate_query_embeddings(query, model) 66 | return {"emb": emb, "magnitude": magnitude}, 201 67 | 68 | 69 | @app.route("/health", methods=["GET"]) 70 | def health(): 71 | return {"last_request_time": last_request_time}, 200 72 | 73 | 74 | if __name__ == "__main__": 75 | log("service starting") 76 | server = WSGIServer(("0.0.0.0", 8006), app) 77 | server_thread = gevent.spawn(server.serve_forever) 78 | gevent.joinall([server_thread]) 79 | -------------------------------------------------------------------------------- /service/clip_embeddings/semantic_search/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/service/clip_embeddings/semantic_search/__init__.py -------------------------------------------------------------------------------- /service/exif/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/service/exif/__init__.py -------------------------------------------------------------------------------- /service/exif/main.py: -------------------------------------------------------------------------------- 1 | import exiftool 2 | import gevent 3 | from flask import Flask, request 4 | from gevent.pywsgi import WSGIServer 5 | 6 | static_et = exiftool.ExifTool() 7 | static_struct_et = exiftool.ExifTool(common_args=["-struct"]) 8 | 9 | app = Flask(__name__) 10 | 11 | 12 | def log(message): 13 | print(f"exif: {message}") 14 | 15 | 16 | @app.route("/get-tags", methods=["POST"]) 17 | def get_tags(): 18 | try: 19 | data = request.get_json() 20 | files_by_reverse_priority = data["files_by_reverse_priority"] 21 | tags = data["tags"] 22 | struct = data["struct"] 23 | except Exception: 24 | return "", 400 25 | 26 | et = None 27 | if struct: 28 | et = static_struct_et 29 | else: 30 | et = static_et 31 | if not et.running: 32 | et.start() 33 | 34 | values = [] 35 | try: 36 | for tag in tags: 37 | value = None 38 | for file in files_by_reverse_priority: 39 | retrieved_value = et.get_tag(tag, file) 40 | if retrieved_value is not None: 41 | value = retrieved_value 42 | values.append(value) 43 | except Exception: 44 | log("An error occurred") 45 | 46 | return {"values": values}, 201 47 | 48 | 49 | @app.route("/health", methods=["GET"]) 50 | def health(): 51 | return {"status": "OK"}, 200 52 | 53 | 54 | if __name__ == "__main__": 55 | log("service starting") 56 | server = WSGIServer(("0.0.0.0", 8010), app) 57 | server_thread = gevent.spawn(server.serve_forever) 58 | gevent.joinall([server_thread]) 59 | -------------------------------------------------------------------------------- /service/face_recognition/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/service/face_recognition/__init__.py -------------------------------------------------------------------------------- /service/face_recognition/main.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import face_recognition 4 | import gevent 5 | import numpy as np 6 | import PIL 7 | from flask import Flask, request 8 | from gevent.pywsgi import WSGIServer 9 | 10 | app = Flask(__name__) 11 | 12 | last_request_time = None 13 | 14 | 15 | def log(message): 16 | print(f"face_recognition: {message}") 17 | 18 | 19 | @app.route("/face-encodings", methods=["POST"]) 20 | def create_face_encodings(): 21 | global last_request_time 22 | # Update last request time 23 | last_request_time = time.time() 24 | 25 | try: 26 | data = request.get_json() 27 | source = data["source"] 28 | face_locations = data["face_locations"] 29 | except Exception: 30 | return "", 400 31 | 32 | image = np.array(PIL.Image.open(source)) 33 | face_encodings = face_recognition.face_encodings( 34 | image, 35 | known_face_locations=face_locations, 36 | ) 37 | # Convert NumPy arrays to Python lists 38 | face_encodings_list = [enc.tolist() for enc in face_encodings] 39 | # Log number of face encodings 40 | log(f"created face_encodings={len(face_encodings_list)}") 41 | return {"encodings": face_encodings_list}, 201 42 | 43 | 44 | @app.route("/face-locations", methods=["POST"]) 45 | def create_face_locations(): 46 | global last_request_time 47 | # Update last request time 48 | last_request_time = time.time() 49 | 50 | try: 51 | data = request.get_json() 52 | source = data["source"] 53 | model = data["model"] 54 | except Exception: 55 | return "", 400 56 | 57 | image = np.array(PIL.Image.open(source)) 58 | face_locations = face_recognition.face_locations(image, model=model) 59 | log(f"created face_location={face_locations}") 60 | return {"face_locations": face_locations}, 201 61 | 62 | 63 | @app.route("/health", methods=["GET"]) 64 | def health(): 65 | return {"last_request_time": last_request_time}, 200 66 | 67 | 68 | if __name__ == "__main__": 69 | log("service starting") 70 | server = WSGIServer(("0.0.0.0", 8005), app) 71 | server_thread = gevent.spawn(server.serve_forever) 72 | gevent.joinall([server_thread]) 73 | -------------------------------------------------------------------------------- /service/image_captioning/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/service/image_captioning/__init__.py -------------------------------------------------------------------------------- /service/image_captioning/api/im2txt/build_vocab.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from collections import Counter 3 | 4 | from tqdm import tqdm 5 | 6 | caption_path = "api/im2txt/data/annotations/captions_train2014.json" 7 | vocab_path = "api/im2txt/data/vocab.pkl" 8 | threshold = 4 9 | 10 | 11 | class Vocabulary: 12 | """Simple vocabulary wrapper.""" 13 | 14 | def __init__(self): 15 | self.word2idx = {} 16 | self.idx2word = {} 17 | self.idx = 0 18 | 19 | def add_word(self, word): 20 | if word not in self.word2idx: 21 | self.word2idx[word] = self.idx 22 | self.idx2word[self.idx] = word 23 | self.idx += 1 24 | 25 | def __call__(self, word): 26 | if word not in self.word2idx: 27 | return self.word2idx[""] 28 | return self.word2idx[word] 29 | 30 | def __len__(self): 31 | return len(self.word2idx) 32 | 33 | 34 | def build_vocab(json, threshold): 35 | import nltk 36 | from pycocotools.coco import COCO 37 | 38 | """Build a simple vocabulary wrapper.""" 39 | coco = COCO(json) 40 | counter = Counter() 41 | ids = coco.anns.keys() 42 | for i, id in tqdm(enumerate(ids)): 43 | caption = str(coco.anns[id]["caption"]) 44 | tokens = nltk.tokenize.word_tokenize(caption.lower()) 45 | counter.update(tokens) 46 | 47 | # if (i+1) % 1000 == 0: 48 | # print("[{}/{}] Tokenized the captions.".format(i+1, len(ids))) 49 | 50 | # If the word frequency is less than 'threshold', then the word is discarded. 51 | words = [word for word, cnt in counter.items() if cnt >= threshold] 52 | 53 | # Create a vocab wrapper and add some special tokens. 54 | vocab = Vocabulary() 55 | vocab.add_word("") 56 | vocab.add_word("") 57 | vocab.add_word("") 58 | vocab.add_word("") 59 | 60 | # Add the words to the vocabulary. 61 | for i, word in enumerate(words): 62 | vocab.add_word(word) 63 | return vocab 64 | 65 | 66 | def main(): 67 | vocab = build_vocab(json=caption_path, threshold=threshold) 68 | with open(vocab_path, "wb") as f: 69 | pickle.dump(vocab, f) 70 | print(f"Total vocabulary size: {len(vocab)}") 71 | print(f"Saved the vocabulary wrapper to '{vocab_path}'") 72 | -------------------------------------------------------------------------------- /service/image_captioning/api/im2txt/model.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch import nn 3 | from torchvision import models 4 | 5 | 6 | class EncoderCNN(nn.Module): 7 | def __init__(self, embed_size): 8 | """Load the pretrained ResNet-152 and replace top fc layer.""" 9 | super(EncoderCNN, self).__init__() 10 | resnet = models.resnet152(pretrained=True) 11 | modules = list(resnet.children())[:-1] # delete the last fc layer. 12 | self.resnet = nn.Sequential(*modules) 13 | self.linear = nn.Linear(resnet.fc.in_features, embed_size) 14 | self.bn = nn.BatchNorm1d(embed_size, momentum=0.01) 15 | 16 | def forward(self, images): 17 | """Extract feature vectors from input images.""" 18 | with torch.no_grad(): 19 | features = self.resnet(images) 20 | features = features.reshape(features.size(0), -1) 21 | features = self.bn(self.linear(features)) 22 | return features 23 | 24 | 25 | class DecoderRNN(nn.Module): 26 | def __init__( 27 | self, embed_size, hidden_size, vocab_size, num_layers, max_seq_length=20 28 | ): 29 | """Set the hyper-parameters and build the layers.""" 30 | super(DecoderRNN, self).__init__() 31 | self.embed = nn.Embedding(vocab_size, embed_size) 32 | self.lstm = nn.LSTM(embed_size, hidden_size, num_layers, batch_first=True) 33 | self.linear = nn.Linear(hidden_size, vocab_size) 34 | self.max_seg_length = max_seq_length 35 | 36 | def forward(self, features): 37 | """Generate captions for given image features using greedy search.""" 38 | sampled_ids = [] 39 | states = None 40 | inputs = features.unsqueeze(1) 41 | for i in range(self.max_seg_length): 42 | hiddens, states = self.lstm( 43 | inputs, states 44 | ) # hiddens: (batch_size, 1, hidden_size) 45 | outputs = self.linear( 46 | hiddens.squeeze(1) 47 | ) # outputs: (batch_size, vocab_size) 48 | _, predicted = outputs.max(1) # predicted: (batch_size) 49 | sampled_ids.append(predicted) 50 | inputs = self.embed(predicted) # inputs: (batch_size, embed_size) 51 | inputs = inputs.unsqueeze(1) # inputs: (batch_size, 1, embed_size) 52 | sampled_ids = torch.stack( 53 | sampled_ids, 1 54 | ) # sampled_ids: (batch_size, max_seq_length) 55 | return sampled_ids 56 | -------------------------------------------------------------------------------- /service/image_captioning/api/im2txt/resize.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from PIL import Image 4 | 5 | image_dir = "api/im2txt/data/train2014/" 6 | output_dir = "api/im2txt/data/resized2014/" 7 | image_size = 256 8 | 9 | 10 | def resize_image(image, size): 11 | """Resize an image to the given size.""" 12 | return image.resize(size, Image.ANTIALIAS) 13 | 14 | 15 | def resize_images(image_dir, output_dir, size): 16 | """Resize the images in 'image_dir' and save into 'output_dir'.""" 17 | if not os.path.exists(output_dir): 18 | os.makedirs(output_dir) 19 | 20 | images = os.listdir(image_dir) 21 | num_images = len(images) 22 | for i, image in enumerate(images): 23 | with open(os.path.join(image_dir, image), "r+b") as f: 24 | with Image.open(f) as img: 25 | img = resize_image(img, size) 26 | img.save(os.path.join(output_dir, image), img.format) 27 | if (i + 1) % 100 == 0: 28 | print( 29 | f"[{i + 1}/{num_images}] Resized the images and saved into '{output_dir}'." 30 | ) 31 | 32 | 33 | def main(): 34 | resize_images(image_dir, output_dir, [image_size, image_size]) 35 | -------------------------------------------------------------------------------- /service/image_captioning/main.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import gevent 4 | from flask import Flask, request 5 | from gevent.pywsgi import WSGIServer 6 | 7 | from api.im2txt.sample import Im2txt 8 | 9 | app = Flask(__name__) 10 | 11 | im2txt_instance = None 12 | last_request_time = None 13 | 14 | 15 | def log(message): 16 | print(f"image_captioning: {message}") 17 | 18 | 19 | @app.route("/generate-caption", methods=["POST"]) 20 | def generate_caption(): 21 | global last_request_time 22 | # Update last request time 23 | last_request_time = time.time() 24 | 25 | try: 26 | data = request.get_json() 27 | image_path = data["image_path"] 28 | onnx = data["onnx"] 29 | blip = data["blip"] 30 | except Exception as e: 31 | print(str(e)) 32 | return "", 400 33 | 34 | global im2txt_instance 35 | 36 | if im2txt_instance is None: 37 | im2txt_instance = Im2txt(blip=blip) 38 | 39 | return { 40 | "caption": im2txt_instance.generate_caption(image_path=image_path, onnx=onnx) 41 | }, 201 42 | 43 | 44 | @app.route("/unload-model", methods=["GET"]) 45 | def unload_model(): 46 | global im2txt_instance 47 | im2txt_instance.unload_models() 48 | im2txt_instance = None 49 | return "", 200 50 | 51 | 52 | @app.route("/health", methods=["GET"]) 53 | def health(): 54 | return {"last_request_time": last_request_time}, 200 55 | 56 | 57 | if __name__ == "__main__": 58 | log("service starting") 59 | server = WSGIServer(("0.0.0.0", 8007), app) 60 | server_thread = gevent.spawn(server.serve_forever) 61 | gevent.joinall([server_thread]) 62 | -------------------------------------------------------------------------------- /service/llm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/service/llm/__init__.py -------------------------------------------------------------------------------- /service/tags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/service/tags/__init__.py -------------------------------------------------------------------------------- /service/tags/main.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import gevent 4 | from flask import Flask, request 5 | from gevent.pywsgi import WSGIServer 6 | from places365.places365 import Places365 7 | 8 | app = Flask(__name__) 9 | 10 | places365_instance = None 11 | last_request_time = None 12 | 13 | 14 | def log(message): 15 | print(f"tags: {message}") 16 | 17 | 18 | @app.route("/generate-tags", methods=["POST"]) 19 | def generate_tags(): 20 | global last_request_time 21 | # Update last request time 22 | last_request_time = time.time() 23 | 24 | try: 25 | data = request.get_json() 26 | image_path = data["image_path"] 27 | confidence = data["confidence"] 28 | except Exception as e: 29 | print(str(e)) 30 | return "", 400 31 | 32 | global places365_instance 33 | 34 | if places365_instance is None: 35 | places365_instance = Places365() 36 | return {"tags": places365_instance.inference_places365(image_path, confidence)}, 201 37 | 38 | 39 | @app.route("/health", methods=["GET"]) 40 | def health(): 41 | return {"last_request_time": last_request_time}, 200 42 | 43 | 44 | if __name__ == "__main__": 45 | log("service starting") 46 | server = WSGIServer(("0.0.0.0", 8011), app) 47 | server_thread = gevent.spawn(server.serve_forever) 48 | gevent.joinall([server_thread]) 49 | -------------------------------------------------------------------------------- /service/tags/places365/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/service/tags/places365/__init__.py -------------------------------------------------------------------------------- /service/thumbnail/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/service/thumbnail/__init__.py -------------------------------------------------------------------------------- /service/thumbnail/main.py: -------------------------------------------------------------------------------- 1 | import gevent 2 | from flask import Flask, request 3 | from gevent.pywsgi import WSGIServer 4 | from wand.image import Image 5 | 6 | app = Flask(__name__) 7 | 8 | 9 | def log(message): 10 | print(f"thumbnail: {message}") 11 | 12 | 13 | @app.route("/", methods=["POST"]) 14 | def create_thumbnail(): 15 | try: 16 | data = request.get_json() 17 | source = data["source"] 18 | destination = data["destination"] 19 | height = data["height"] 20 | except Exception: 21 | return "", 400 22 | log(f"creating for source={source} height={height}") 23 | with Image(filename=source) as img: 24 | with img.clone() as thumbnail: 25 | thumbnail.format = "webp" 26 | thumbnail.transform(resize=f"x{height}") 27 | thumbnail.compression_quality = 95 28 | thumbnail.auto_orient() 29 | thumbnail.save(filename=destination) 30 | log(f"created at location={destination}") 31 | return {"thumbnail": destination}, 201 32 | 33 | 34 | @app.route("/health", methods=["GET"]) 35 | def health(): 36 | return {"status": "OK"}, 200 37 | 38 | 39 | if __name__ == "__main__": 40 | log("service starting") 41 | server = WSGIServer(("0.0.0.0", 8003), app) 42 | server_thread = gevent.spawn(server.serve_forever) 43 | gevent.joinall([server_thread]) 44 | -------------------------------------------------------------------------------- /service/thumbnail/test/.gitignore: -------------------------------------------------------------------------------- 1 | samples/* 2 | !samples/.gitkeep 3 | !samples/README.md 4 | -------------------------------------------------------------------------------- /service/thumbnail/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/service/thumbnail/test/__init__.py -------------------------------------------------------------------------------- /service/thumbnail/test/samples/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/c5ad529d67aad52aa9889438a145c06d857c326b/service/thumbnail/test/samples/.gitkeep -------------------------------------------------------------------------------- /service/thumbnail/test/samples/README.md: -------------------------------------------------------------------------------- 1 | place any *image* files in this directory that you want to use as test data 2 | -------------------------------------------------------------------------------- /service/thumbnail/test/test_thumbnail_worker.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from pytest import fixture 4 | 5 | from service.thumbnail.main import app 6 | 7 | HTTP_BAD_REQUEST = 400 8 | HTTP_CREATED = 201 9 | 10 | 11 | @fixture() 12 | def client(): 13 | return app.test_client() 14 | 15 | 16 | def test_must_fail_when_passing_empty_string(client): 17 | response = client.post("/", data="") 18 | assert response.status_code == HTTP_BAD_REQUEST 19 | 20 | 21 | def test_must_fail_when_passing_invalid_json(client): 22 | response = client.post("/", data="invalid json") 23 | assert response.status_code == HTTP_BAD_REQUEST 24 | 25 | 26 | def test_must_fail_when_passing_incomplete_json(client): 27 | invalid_payloads = [ 28 | {"source": "foo"}, 29 | {"destination": "/tmp/result.webp"}, 30 | {"height": 100}, 31 | {"source": "foo", "destination": "/tmp/result.webp"}, 32 | {"destination": "/tmp/result.webp", "height": 100}, 33 | {"height": 100, "source": "foo"}, 34 | ] 35 | for payload in invalid_payloads: 36 | response = client.post("/", json=payload) 37 | assert response.status_code == HTTP_BAD_REQUEST 38 | 39 | 40 | def test_should_create_thumbnail(client): 41 | samples_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "samples") 42 | samples = [f for f in os.listdir(samples_dir) if f not in [".gitkeep", "README.md"]] 43 | thumbnail_path = "/tmp/result.webp" 44 | for sample in samples: 45 | if os.path.exists(thumbnail_path): 46 | os.remove(thumbnail_path) 47 | source = os.path.join(samples_dir, sample) 48 | json = {"source": source, "destination": thumbnail_path, "height": 100} 49 | response = client.post("/", json=json) 50 | assert response.status_code == HTTP_CREATED 51 | --------------------------------------------------------------------------------