├── nextcloud ├── admin.py ├── __init__.py ├── models.py ├── tests.py ├── apps.py └── views.py ├── api ├── feature │ ├── __init__.py │ ├── tests │ │ └── __init__.py │ └── embedded_media.py ├── tests │ ├── __init__.py │ ├── fixtures │ │ ├── __init__.py │ │ ├── geocode │ │ │ └── __init__.py │ │ ├── niaz.jpg │ │ ├── api_util │ │ │ ├── captions_json.py │ │ │ ├── sunburst_expectation.py │ │ │ └── expectation.py │ │ └── location_timeline_test_data.csv │ ├── test_regenerate_titles.py │ ├── test_photo_list_without_timestamp.py │ ├── test_only_photos_or_only_videos.py │ ├── test_setup_directory.py │ ├── test_photo_viewset_permissions.py │ ├── test_recently_added_photos.py │ ├── test_zip_list_photos_view_v2.py │ ├── test_predefined_rules.py │ ├── test_scan_photos_directories.py │ ├── test_photo_summary.py │ ├── test_delete_duplicate_photos.py │ └── test_retrieve_photo.py ├── views │ ├── __init__.py │ ├── timezone.py │ ├── custom_api_view.py │ ├── geocode.py │ ├── pagination.py │ ├── jobs.py │ └── services.py ├── management │ ├── __init__.py │ └── commands │ │ ├── save_metadata.py │ │ ├── build_similarity_index.py │ │ ├── clear_cache.py │ │ ├── start_cleaning_service.py │ │ ├── start_service.py │ │ └── createadmin.py ├── migrations │ ├── __init__.py │ ├── 0008_remove_image_path.py │ ├── 0048_fix_null_height.py │ ├── 0010_merge_20210725_1547.py │ ├── 0088_remove_folder_album.py │ ├── 0005_add_video_to_photo.py │ ├── 0002_add_confidence.py │ ├── 0021_remove_photo_image.py │ ├── 0039_remove_photo_image_paths.py │ ├── 0046_add_embedded_media.py │ ├── 0069_rename_to_in_trashcan.py │ ├── 0011_c_remove_favorited.py │ ├── 0016_add_transcode_videos.py │ ├── 0015_add_dominant_color.py │ ├── 0040_add_user_public_sharing_flag.py │ ├── 0006_migrate_to_boolean_field.py │ ├── 0022_photo_video_length.py │ ├── 0023_photo_deleted.py │ ├── 0030_user_confidence_person.py │ ├── 0050_person_face_count.py │ ├── 0070_photo_removed.py │ ├── 0024_photo_timestamp.py │ ├── 0029_change_to_text_field.py │ ├── 0047_alter_file_embedded_media.py │ ├── 0064_albumthing_photo_count.py │ ├── 0012_add_favorite_min_rating.py │ ├── 0043_alter_photo_size.py │ ├── 0092_add_skip_raw_files_field.py │ ├── 0094_add_slideshow_interval.py │ ├── 0079_alter_albumauto_title.py │ ├── 0049_fix_metadata_files_as_main_files.py │ ├── 0091_alter_user_scan_directory.py │ ├── 0031_remove_account.py │ ├── 0089_add_text_alignment.py │ ├── 0090_add_header_size.py │ ├── 0011_a_add_rating.py │ ├── 0062_albumthing_cover_photos.py │ ├── 0081_remove_caption_fields_from_photo.py │ ├── 0083_remove_search_fields.py │ ├── 0077_alter_albumdate_title.py │ ├── 0018_user_config_datetime_rules.py │ ├── 0017_add_cover_photo.py │ ├── 0025_add_cover_photo.py │ ├── 0020_add_default_timezone.py │ ├── 0052_alter_person_name.py │ ├── 0003_remove_unused_thumbs.py │ ├── 0004_fix_album_thing_constraint.py │ ├── 0033_add_post_delete_person.py │ ├── 0061_alter_person_name.py │ ├── 0057_remove_face_image_path_and_more.py │ ├── 0034_allow_deleting_person.py │ ├── 0054_user_cluster_selection_epsilon_user_min_samples.py │ ├── 0059_person_cover_face.py │ ├── 0014_add_save_metadata_to_disk.py │ ├── 0019_change_config_datetime_rules.py │ ├── 0041_apply_user_enum_for_person.py │ ├── 0075_alter_face_cluster_person.py │ ├── 0045_alter_face_cluster.py │ ├── 0085_albumuser_public_expires_at_albumuser_public_slug.py │ ├── 0096_add_progress_step_and_result_to_longrunningjob.py │ ├── 0072_alter_face_person.py │ ├── 0065_apply_default_photo_count.py │ ├── 0097_add_duplicate_detection_settings_to_user.py │ ├── 0032_always_have_owner.py │ ├── 0063_apply_default_album_things_cover.py │ ├── 0011_b_migrate_favorited_to_rating.py │ ├── 0007_migrate_to_json_field.py │ ├── 0053_user_confidence_unknown_face_and_more.py │ ├── 0036_handle_missing_files.py │ ├── 0055_alter_longrunningjob_job_type.py │ ├── 0009_add_clip_embedding_field.py │ ├── 0042_alter_albumuser_cover_photo_alter_photo_main_file.py │ ├── 0038_add_main_file.py │ ├── 0009_add_aspect_ratio.py │ ├── 0060_apply_default_face_cover.py │ ├── 0044_alter_cluster_person_alter_person_cluster_owner.py │ ├── 0067_alter_longrunningjob_job_type.py │ ├── 0068_remove_longrunningjob_result_and_more.py │ ├── 0056_user_llm_settings_alter_longrunningjob_job_type.py │ ├── 0066_photo_last_modified_alter_longrunningjob_job_type.py │ ├── 0027_rename_unknown_person.py │ ├── 0086_remove_albumuser_public_and_more.py │ ├── 0093_migrate_photon_to_nominatim.py │ ├── 0058_alter_user_avatar_alter_user_nextcloud_app_password_and_more.py │ ├── 0087_add_folder_album.py │ ├── 0035_add_files_model.py │ ├── 0074_migrate_cluster_person.py │ ├── 0051_set_person_defaults.py │ ├── 0071_rename_person_label_probability_face_cluster_probability_and_more.py │ ├── 0037_migrate_to_files.py │ ├── 0073_remove_unknown_person.py │ ├── 0084_convert_arrayfield_to_json.py │ ├── 0028_add_metadata_fields.py │ ├── 0076_alter_file_path_alter_longrunningjob_job_type_and_more.py │ └── 0095_photo_perceptual_hash_alter_longrunningjob_job_type_and_more.py ├── serializers │ ├── __init__.py │ ├── job.py │ ├── simple.py │ ├── PhotosGroupedByDate.py │ ├── album_place.py │ ├── album_thing.py │ ├── album_auto.py │ └── face.py ├── geocode │ ├── parsers │ │ ├── __init__.py │ │ ├── mapbox.py │ │ ├── nominatim.py │ │ ├── opencage.py │ │ └── tomtom.py │ ├── __init__.py │ └── config.py ├── __init__.py ├── apps.py ├── middleware.py ├── face_recognition.py ├── schemas │ └── site_settings.py ├── nextcloud.py ├── models │ ├── album_place.py │ ├── album_user.py │ ├── __init__.py │ ├── album_user_share.py │ ├── album_date.py │ ├── duplicate_group.py │ ├── album_thing.py │ └── cluster.py ├── exif_tags.py ├── semantic_search.py ├── background_tasks.py ├── social_graph.py ├── image_captioning.py └── filters.py ├── librephotos ├── __init__.py ├── settings │ ├── __init__.py │ ├── development.py │ └── test.py └── wsgi.py ├── service ├── __init__.py ├── llm │ └── __init__.py ├── exif │ ├── __init__.py │ └── main.py ├── tags │ ├── __init__.py │ ├── places365 │ │ └── __init__.py │ └── main.py ├── thumbnail │ ├── __init__.py │ ├── test │ │ ├── __init__.py │ │ ├── samples │ │ │ ├── .gitkeep │ │ │ └── README.md │ │ ├── .gitignore │ │ └── test_thumbnail_worker.py │ └── main.py ├── clip_embeddings │ ├── __init__.py │ ├── semantic_search │ │ └── __init__.py │ └── main.py ├── face_recognition │ ├── __init__.py │ └── main.py └── image_captioning │ ├── __init__.py │ ├── api │ └── im2txt │ │ ├── resize.py │ │ ├── build_vocab.py │ │ └── model.py │ └── main.py ├── image_similarity ├── __init__.py └── utils.py ├── screenshots ├── lp-white.png ├── site-logo.png ├── photo_manage.png ├── lp-square-black.png ├── mockups_main_fhd.png ├── more_to_discover.png └── photo_info_fhd.png ├── .github ├── FUNDING.yml ├── workflows │ ├── pre-commit.yml │ └── docker-publish.yml └── ISSUE_TEMPLATE │ ├── enhancement-request.md │ └── bug_report.md ├── requirements.mlval.txt ├── .coveragerc ├── requirements.dev.txt ├── renovate.json ├── .pre-commit-config.yaml ├── pyproject.toml ├── manage.py ├── LICENSE ├── requirements.txt └── test_empty_scan.py /nextcloud/admin.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/feature/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /librephotos/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nextcloud/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nextcloud/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /service/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /service/llm/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /image_similarity/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /service/exif/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /service/tags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/feature/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/geocode/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /librephotos/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /service/thumbnail/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/tests/fixtures/geocode/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /service/clip_embeddings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /service/face_recognition/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /service/image_captioning/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /service/tags/places365/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /service/thumbnail/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /service/thumbnail/test/samples/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/geocode/__init__.py: -------------------------------------------------------------------------------- 1 | GEOCODE_VERSION = "1" 2 | -------------------------------------------------------------------------------- /service/clip_embeddings/semantic_search/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "api.apps.ApiConfig" 2 | -------------------------------------------------------------------------------- /nextcloud/tests.py: -------------------------------------------------------------------------------- 1 | # from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /service/thumbnail/test/.gitignore: -------------------------------------------------------------------------------- 1 | samples/* 2 | !samples/.gitkeep 3 | !samples/README.md 4 | -------------------------------------------------------------------------------- /screenshots/lp-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/HEAD/screenshots/lp-white.png -------------------------------------------------------------------------------- /api/tests/fixtures/niaz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/HEAD/api/tests/fixtures/niaz.jpg -------------------------------------------------------------------------------- /screenshots/site-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/HEAD/screenshots/site-logo.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: derneuere 2 | custom: https://www.paypal.com/donate/?hosted_button_id=5JWVM2UR4LM96 3 | -------------------------------------------------------------------------------- /screenshots/photo_manage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/HEAD/screenshots/photo_manage.png -------------------------------------------------------------------------------- /screenshots/lp-square-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/HEAD/screenshots/lp-square-black.png -------------------------------------------------------------------------------- /screenshots/mockups_main_fhd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/HEAD/screenshots/mockups_main_fhd.png -------------------------------------------------------------------------------- /screenshots/more_to_discover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/HEAD/screenshots/more_to_discover.png -------------------------------------------------------------------------------- /screenshots/photo_info_fhd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibrePhotos/librephotos/HEAD/screenshots/photo_info_fhd.png -------------------------------------------------------------------------------- /service/thumbnail/test/samples/README.md: -------------------------------------------------------------------------------- 1 | place any *image* files in this directory that you want to use as test data 2 | -------------------------------------------------------------------------------- /nextcloud/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class NextcloudConfig(AppConfig): 5 | name = "nextcloud" 6 | -------------------------------------------------------------------------------- /requirements.mlval.txt: -------------------------------------------------------------------------------- 1 | pycocotools==2.0.10 2 | pycocoevalcap==1.2 3 | nltk==3.9.2 4 | matplotlib==3.10.7 5 | onnx==1.19.0 6 | onnxscript -------------------------------------------------------------------------------- /api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | name = "api" 6 | verbose_name = "LibrePhotos" 7 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | ipdb==0.13.13 2 | ipython==9.6.0 3 | ipython-genutils==0.2.0 4 | Pygments==2.19.2 5 | prompt-toolkit==3.0.52 6 | nose==1.3.7 7 | pre-commit==4.3.0 8 | coverage==7.11.0 9 | Faker==37.12.0 10 | setuptools==80.9.0 11 | pyfakefs==5.9.3 12 | pytest==8.4.2 13 | ruff==0.14.8 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/migrations/0088_remove_folder_album.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.4 on 2025-08-22 15:40 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0087_add_folder_album'), 10 | ] 11 | 12 | operations = [ 13 | migrations.DeleteModel( 14 | name='FolderAlbum', 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/0092_add_skip_raw_files_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.7 on 2025-10-26 21:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0091_alter_user_scan_directory'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='user', 15 | name='skip_raw_files', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /api/migrations/0094_add_slideshow_interval.py: -------------------------------------------------------------------------------- 1 | # Generated manually for slideshow interval feature 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0093_migrate_photon_to_nominatim'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='user', 15 | name='slideshow_interval', 16 | field=models.IntegerField(default=5), 17 | ), 18 | ] 19 | 20 | -------------------------------------------------------------------------------- /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/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/0091_alter_user_scan_directory.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.4 on 2025-08-30 12:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0090_add_header_size'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='user', 15 | name='scan_directory', 16 | field=models.CharField(blank=True, db_index=True, default='', max_length=512), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /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/0089_add_text_alignment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.4 on 2025-08-22 16:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0088_remove_folder_album'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='user', 15 | name='text_alignment', 16 | field=models.TextField(choices=[('left', 'Left'), ('right', 'Right')], default='right'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /api/migrations/0090_add_header_size.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.4 on 2025-08-22 16:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0089_add_text_alignment'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='user', 15 | name='header_size', 16 | field=models.TextField(choices=[('large', 'Large'), ('normal', 'Normal'), ('small', 'Small')], default='large'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/views/geocode.py: -------------------------------------------------------------------------------- 1 | from rest_framework.response import Response 2 | from rest_framework.views import APIView 3 | 4 | from api.geocode.geocode import search_location 5 | 6 | 7 | class GeocodeSearchView(APIView): 8 | """Search for locations by name/address.""" 9 | 10 | def get(self, request, format=None): 11 | query = request.query_params.get("q", "").strip() 12 | if not query: 13 | return Response([]) 14 | limit = int(request.query_params.get("limit", 5)) 15 | results = search_location(query, limit=limit) 16 | return Response(results) 17 | 18 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/views/pagination.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import PageNumberPagination 2 | 3 | 4 | class HugeResultsSetPagination(PageNumberPagination): 5 | page_size = 2500 6 | page_size_query_param = "page_size" 7 | max_page_size = 5000 8 | 9 | 10 | class StandardResultsSetPagination(PageNumberPagination): 11 | page_size = 1000 12 | page_size_query_param = "page_size" 13 | max_page_size = 2000 14 | 15 | 16 | class RegularResultsSetPagination(PageNumberPagination): 17 | page_size = 100 18 | page_size_query_param = "page_size" 19 | max_page_size = 200 20 | 21 | 22 | class TinyResultsSetPagination(PageNumberPagination): 23 | page_size = 20 24 | page_size_query_param = "page_size" 25 | max_page_size = 50 26 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/migrations/0085_albumuser_public_expires_at_albumuser_public_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.4 on 2025-08-16 08:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0084_convert_arrayfield_to_json'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='albumuser', 15 | name='public_expires_at', 16 | field=models.DateTimeField(blank=True, db_index=True, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='albumuser', 20 | name='public_slug', 21 | field=models.SlugField(blank=True, max_length=64, null=True, unique=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /api/migrations/0096_add_progress_step_and_result_to_longrunningjob.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.9 on 2025-12-23 12:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0095_photo_perceptual_hash_alter_longrunningjob_job_type_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='longrunningjob', 15 | name='progress_step', 16 | field=models.CharField(blank=True, max_length=100, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='longrunningjob', 20 | name='result', 21 | field=models.JSONField(blank=True, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /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/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/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/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/migrations/0097_add_duplicate_detection_settings_to_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.9 on 2025-12-23 13:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0096_add_progress_step_and_result_to_longrunningjob'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='user', 15 | name='duplicate_clear_existing', 16 | field=models.BooleanField(default=False), 17 | ), 18 | migrations.AddField( 19 | model_name='user', 20 | name='duplicate_sensitivity', 21 | field=models.TextField(choices=[('strict', 'Strict'), ('normal', 'Normal'), ('loose', 'Loose')], default='normal'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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 | "progress_step", 26 | "result", 27 | "id", 28 | ) 29 | 30 | def get_job_type_str(self, obj) -> str: 31 | return dict(LongRunningJob.JOB_TYPES)[obj.job_type] 32 | -------------------------------------------------------------------------------- /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/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 | def __str__(self): 26 | return f"{self.title} ({self.owner.username})" 27 | 28 | class Meta: 29 | unique_together = ("title", "owner") 30 | -------------------------------------------------------------------------------- /.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@08eba0b27e820071cde6df949e0beb9ba4906955 # 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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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.duplicate_group import DuplicateGroup 8 | from api.models.face import Face 9 | from api.models.file import File 10 | from api.models.long_running_job import LongRunningJob 11 | from api.models.person import Person 12 | from api.models.photo import Photo 13 | from api.models.photo_caption import PhotoCaption 14 | from api.models.photo_search import PhotoSearch 15 | from api.models.thumbnail import Thumbnail 16 | from api.models.user import User 17 | 18 | __all__ = [ 19 | "AlbumAuto", 20 | "AlbumDate", 21 | "AlbumPlace", 22 | "AlbumThing", 23 | "AlbumUser", 24 | "Cluster", 25 | "DuplicateGroup", 26 | "Face", 27 | "LongRunningJob", 28 | "Person", 29 | "Photo", 30 | "PhotoCaption", 31 | "PhotoSearch", 32 | "Thumbnail", 33 | "User", 34 | "File", 35 | ] 36 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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": "/data"}, 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( 33 | response.json(), ["Scan directory must be inside the data root."] 34 | ) 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/tests/test_photo_viewset_permissions.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from rest_framework.test import APIClient 3 | 4 | from api.tests.utils import ( 5 | create_test_photo, 6 | create_test_user, 7 | share_test_photos, 8 | ) 9 | 10 | 11 | class PhotoViewSetPermissionsTest(TestCase): 12 | def setUp(self): 13 | self.owner = create_test_user() 14 | self.other_user = create_test_user() 15 | self.photo = create_test_photo(owner=self.owner) 16 | self.url = f"/api/photos/{self.photo.image_hash}/" 17 | 18 | def test_owner_can_update_photo(self): 19 | client = APIClient() 20 | client.force_authenticate(user=self.owner) 21 | 22 | response = client.patch(self.url, {"rating": 5}, format="json") 23 | 24 | self.assertEqual(200, response.status_code) 25 | self.photo.refresh_from_db() 26 | self.assertEqual(5, self.photo.rating) 27 | 28 | def test_non_owner_cannot_update_photo(self): 29 | share_test_photos([self.photo.image_hash], self.other_user) 30 | client = APIClient() 31 | client.force_authenticate(user=self.other_user) 32 | 33 | response = client.patch(self.url, {"rating": 3}, format="json") 34 | 35 | self.assertEqual(403, response.status_code) 36 | self.photo.refresh_from_db() 37 | self.assertNotEqual(3, self.photo.rating) 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==5.2.9 2 | django-constance==4.3.4 3 | django-cors-headers==4.9.0 4 | git+https://github.com/derneuere/django-chunked-upload@master#egg=django-chunked-upload 5 | django-cryptography-5==2.0.3 6 | django-extensions==4.1 7 | django-filter==25.2 8 | django-bulk-update 9 | django-silk==5.4.3 10 | djangorestframework==3.16.1 11 | djangorestframework-simplejwt==5.5.1 12 | drf-spectacular==0.29.0 13 | face-recognition==1.3.0 14 | faiss-cpu==1.12.0 15 | Flask==3.1.2 16 | Flask-Cors==6.0.1 17 | Flask-RESTful==0.3.10 18 | geopy==2.4.1 19 | gunicorn==23.0.0 20 | hdbscan==0.8.40 21 | networkx==3.4.2 22 | nltk==3.9.2 23 | markupsafe==3.0.3 24 | Pillow==11.3.0 25 | ImageHash==4.3.1 26 | psycopg==3.2.10 27 | https://github.com/owncloud/pyocclient/archive/master.zip 28 | pytz==2025.2 29 | tzdata==2025.2 30 | PyExifTool==0.4.9 31 | pyvips==3.0.0 32 | scikit-learn<1.7.3 33 | seaborn==0.13.2 34 | sentence_transformers==2.7.0 35 | timezonefinder==6.6.3 36 | tqdm==4.67.1 37 | gevent==25.9.1 38 | python-magic==0.4.27 39 | Wand==0.6.13 40 | django-q2==1.9.0 41 | safetensors==0.6.2 42 | py-cpuinfo==9.0.0 43 | psutil==7.1.0 44 | 45 | # Dependencies for blip 46 | timm==1.0.19 47 | # Dependencies for Moondream chat handler (llama-cpp-python multimodal) 48 | transformers==4.56.2 49 | # Dependencies for mistral quantized and multimodal models like Moondream 50 | llama-cpp-python==0.3.16 51 | argon2-cffi==23.1.0 52 | -------------------------------------------------------------------------------- /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/0086_remove_albumuser_public_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.4 on 2025-08-17 17:23 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('api', '0085_albumuser_public_expires_at_albumuser_public_slug'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='albumuser', 16 | name='public', 17 | ), 18 | migrations.RemoveField( 19 | model_name='albumuser', 20 | name='public_expires_at', 21 | ), 22 | migrations.RemoveField( 23 | model_name='albumuser', 24 | name='public_slug', 25 | ), 26 | migrations.CreateModel( 27 | name='AlbumUserShare', 28 | fields=[ 29 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 30 | ('enabled', models.BooleanField(db_index=True, default=False)), 31 | ('slug', models.SlugField(blank=True, max_length=64, null=True, unique=True)), 32 | ('expires_at', models.DateTimeField(blank=True, db_index=True, null=True)), 33 | ('album', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='share', to='api.albumuser')), 34 | ], 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /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/models/album_user_share.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | from django.utils import timezone 4 | 5 | from api.models.album_user import AlbumUser 6 | 7 | 8 | class AlbumUserShare(models.Model): 9 | album = models.OneToOneField( 10 | AlbumUser, on_delete=models.CASCADE, related_name="share" 11 | ) 12 | enabled = models.BooleanField(default=False, db_index=True) 13 | slug = models.SlugField( 14 | max_length=64, unique=True, null=True, blank=True, db_index=True 15 | ) 16 | expires_at = models.DateTimeField(null=True, blank=True, db_index=True) 17 | 18 | def ensure_slug(self) -> None: 19 | if self.enabled and not self.slug: 20 | base = uuid.uuid4().hex[:12] 21 | candidate = base 22 | idx = 0 23 | while ( 24 | AlbumUserShare.objects.filter(slug=candidate) 25 | .exclude(id=self.id) 26 | .exists() 27 | ): 28 | idx += 1 29 | candidate = f"{base}-{idx}" 30 | self.slug = candidate 31 | 32 | def is_active(self) -> bool: 33 | if not self.enabled: 34 | return False 35 | if self.expires_at is None: 36 | return True 37 | return self.expires_at >= timezone.now() 38 | 39 | def save(self, *args, **kwargs): 40 | if self.enabled and not self.slug: 41 | self.ensure_slug() 42 | super().save(*args, **kwargs) 43 | -------------------------------------------------------------------------------- /api/tests/test_recently_added_photos.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from django.test import TestCase 3 | from django.utils import timezone 4 | from rest_framework.test import APIClient 5 | 6 | from api.tests.utils import create_test_photos, create_test_user 7 | 8 | 9 | class RecentlyAddedPhotosTest(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 | def test_retrieve_recently_added_photos(self): 17 | today = timezone.now() 18 | before_today = timezone.now() - timedelta(days=1) 19 | create_test_photos(number_of_photos=3, owner=self.user1, added_on=today) 20 | create_test_photos(number_of_photos=4, owner=self.user1, added_on=before_today) 21 | create_test_photos(number_of_photos=5, owner=self.user2, added_on=today) 22 | 23 | response = self.client.get("/api/photos/recentlyadded/") 24 | json = response.json() 25 | 26 | self.assertEqual(response.status_code, 200) 27 | self.assertEqual(3, len(json["results"])) 28 | 29 | def test_retrieve_empty_result_when_no_photos(self): 30 | create_test_photos(number_of_photos=2, owner=self.user2) 31 | response = self.client.get("/api/photos/recentlyadded/") 32 | json = response.json() 33 | 34 | self.assertEqual(response.status_code, 200) 35 | self.assertEqual([], json["results"]) 36 | self.assertIsNone(json["date"]) 37 | -------------------------------------------------------------------------------- /api/migrations/0093_migrate_photon_to_nominatim.py: -------------------------------------------------------------------------------- 1 | """ 2 | Migration to change MAP_API_PROVIDER from 'photon' to 'nominatim'. 3 | 4 | Photon's public API at photon.komoot.io has become unreliable (502 errors), 5 | so we're switching the default to Nominatim which is more stable. 6 | """ 7 | 8 | from django.db import migrations 9 | 10 | 11 | def migrate_photon_to_nominatim(apps, schema_editor): 12 | """Update constance config from photon to nominatim.""" 13 | try: 14 | Constance = apps.get_model("constance", "Constance") 15 | config = Constance.objects.filter(key="MAP_API_PROVIDER").first() 16 | if config and config.value == '"photon"': 17 | config.value = '"nominatim"' 18 | config.save() 19 | except LookupError: 20 | # constance model not available, skip 21 | pass 22 | 23 | 24 | def reverse_migration(apps, schema_editor): 25 | """Reverse: change nominatim back to photon (not recommended).""" 26 | try: 27 | Constance = apps.get_model("constance", "Constance") 28 | config = Constance.objects.filter(key="MAP_API_PROVIDER").first() 29 | if config and config.value == '"nominatim"': 30 | config.value = '"photon"' 31 | config.save() 32 | except LookupError: 33 | pass 34 | 35 | 36 | class Migration(migrations.Migration): 37 | 38 | dependencies = [ 39 | ("api", "0092_add_skip_raw_files_field"), 40 | ] 41 | 42 | operations = [ 43 | migrations.RunPython(migrate_photon_to_nominatim, reverse_migration), 44 | ] 45 | 46 | -------------------------------------------------------------------------------- /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.tomtom import parse as parse_tomtom 7 | 8 | 9 | def _get_config(): 10 | return { 11 | "mapbox": { 12 | "geocode_args": {"api_key": settings.MAP_API_KEY}, 13 | "parser": parse_mapbox, 14 | }, 15 | "maptiler": { 16 | "geocode_args": {"api_key": settings.MAP_API_KEY}, 17 | "parser": parse_mapbox, 18 | }, 19 | "tomtom": { 20 | "geocode_args": {"api_key": settings.MAP_API_KEY}, 21 | "parser": parse_tomtom, 22 | }, 23 | "nominatim": { 24 | "geocode_args": {"user_agent": "librephotos"}, 25 | "parser": parse_nominatim, 26 | }, 27 | "opencage": { 28 | "geocode_args": { 29 | "api_key": settings.MAP_API_KEY, 30 | }, 31 | "parser": parse_opencage, 32 | }, 33 | } 34 | 35 | 36 | def get_provider_config(provider) -> dict: 37 | config = _get_config() 38 | if provider not in config: 39 | raise Exception(f"Map provider not found: {provider}.") 40 | return config[provider]["geocode_args"] 41 | 42 | 43 | def get_provider_parser(provider) -> callable: 44 | config = _get_config() 45 | if provider not in config: 46 | raise Exception(f"Map provider not found: {provider}.") 47 | return config[provider]["parser"] 48 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /api/migrations/0087_add_folder_album.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.4 on 2025-08-22 14:48 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 | 10 | dependencies = [ 11 | ('api', '0086_remove_albumuser_public_and_more'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='FolderAlbum', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('title', models.CharField(max_length=512)), 20 | ('created_on', models.DateTimeField(auto_now_add=True, db_index=True)), 21 | ('updated_on', models.DateTimeField(auto_now=True)), 22 | ('folder_path', models.TextField()), 23 | ('include_subdirectories', models.BooleanField(default=True)), 24 | ('public', models.BooleanField(db_index=True, default=False)), 25 | ('favorited', models.BooleanField(db_index=True, default=False)), 26 | ('cover_photo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='folder_album_cover', to='api.photo')), 27 | ('owner', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 28 | ], 29 | options={ 30 | 'ordering': ['-created_on'], 31 | 'unique_together': {('folder_path', 'owner')}, 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /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.models.photo_caption import PhotoCaption 6 | from api.util import logger 7 | 8 | 9 | def generate_captions(overwrite=False): 10 | if overwrite: 11 | photos = Photo.objects.all() 12 | else: 13 | # Find photos that don't have search captions in PhotoSearch model 14 | photos = Photo.objects.filter( 15 | models.Q(search_instance__isnull=True) 16 | | models.Q(search_instance__search_captions__isnull=True) 17 | ) 18 | logger.info("%d photos to be processed for caption generation" % photos.count()) 19 | for photo in photos: 20 | logger.info("generating captions for %s" % photo.main_file.path) 21 | caption_instance, created = PhotoCaption.objects.get_or_create(photo=photo) 22 | caption_instance.generate_places365_captions() 23 | photo.save() 24 | 25 | 26 | def geolocate(overwrite=False): 27 | if overwrite: 28 | photos = Photo.objects.all() 29 | else: 30 | photos = Photo.objects.filter(geolocation_json={}) 31 | logger.info("%d photos to be geolocated" % photos.count()) 32 | for photo in photos: 33 | try: 34 | logger.info("geolocating %s" % photo.main_file.path) 35 | photo._geolocate() 36 | photo._add_location_to_album_dates() 37 | except Exception: 38 | logger.exception("could not geolocate photo: %s", photo) 39 | 40 | 41 | def add_photos_to_album_things(): 42 | photos = Photo.objects.all() 43 | for photo in tqdm(photos): 44 | photo._add_to_album_place() 45 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/serializers/PhotosGroupedByDate.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | from itertools import groupby 3 | 4 | utc = pytz.UTC 5 | 6 | 7 | class PhotosGroupedByDate: 8 | def __init__(self, location, date, photos): 9 | self.photos = photos 10 | self.date = date 11 | self.location = location 12 | 13 | 14 | def get_photos_ordered_by_date(photos): 15 | """ 16 | Efficiently group photos by date using itertools.groupby. 17 | Assumes photos are already ordered by exif_timestamp. 18 | """ 19 | # Convert to list once if it's a queryset 20 | if hasattr(photos, "_result_cache") and photos._result_cache is None: 21 | photos = list(photos) 22 | 23 | result = [] 24 | no_timestamp_photos = [] 25 | 26 | def date_key(photo): 27 | """Key function for grouping photos by date""" 28 | if photo.exif_timestamp: 29 | return photo.exif_timestamp.date().strftime("%Y-%m-%d") 30 | return None 31 | 32 | # Group consecutive photos by their date 33 | for date_str, group_photos in groupby(photos, key=date_key): 34 | group_list = list(group_photos) 35 | location = "" 36 | 37 | if date_str is not None: 38 | # Use the first photo's timestamp as the group date 39 | date = group_list[0].exif_timestamp 40 | result.append(PhotosGroupedByDate(location, date, group_list)) 41 | else: 42 | # Collect photos without timestamps 43 | no_timestamp_photos.extend(group_list) 44 | 45 | # Add no timestamp photos as a single group at the end 46 | if no_timestamp_photos: 47 | result.append(PhotosGroupedByDate("", "No timestamp", no_timestamp_photos)) 48 | 49 | return result 50 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /test_empty_scan.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import django 5 | import uuid 6 | import time 7 | 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "librephotos.settings") 9 | django.setup() 10 | 11 | from api.models import User, LongRunningJob 12 | from api.directory_watcher import scan_photos 13 | 14 | # Create test user 15 | user, created = User.objects.get_or_create( 16 | username="empty_scan_user", 17 | defaults={"scan_directory": "/tmp/empty_scan_test"} 18 | ) 19 | user.scan_directory = "/tmp/empty_scan_test" 20 | user.save() 21 | 22 | print(f"Testing scan with empty directory: {user.scan_directory}") 23 | print(f"Files in directory: {len(os.listdir(user.scan_directory))}") 24 | 25 | # Start scan 26 | job_id = uuid.uuid4() 27 | print(f"\nStarting scan with job_id: {job_id}") 28 | 29 | scan_photos(user, full_scan=True, job_id=job_id, scan_directory="/tmp/empty_scan_test") 30 | 31 | # Wait a moment for job to complete 32 | time.sleep(2) 33 | 34 | # Check job status 35 | try: 36 | job = LongRunningJob.objects.get(job_id=job_id) 37 | percentage = (job.progress_current / job.progress_target * 100) if job.progress_target > 0 else 0 38 | 39 | print("\n=== Scan Results ===") 40 | print(f"Job ID: {job.job_id}") 41 | print(f"Job Type: {job.job_type}") 42 | print(f"Progress: {job.progress_current}/{job.progress_target}") 43 | print(f"Percentage: {percentage:.1f}%") 44 | print(f"Started: {job.started_at}") 45 | print(f"Finished: {job.finished}") 46 | print(f"Finished at: {job.finished_at}") 47 | print(f"Failed: {job.failed}") 48 | 49 | if job.progress_target == 0 and job.finished: 50 | print("\n✓ PASS: Empty directory scan handled correctly (0/0, finished=True)") 51 | elif job.progress_target == 0 and not job.finished: 52 | print("\n✗ FAIL: Empty directory scan not marked as finished") 53 | else: 54 | print(f"\n? UNEXPECTED: progress_target={job.progress_target} (expected 0)") 55 | 56 | except LongRunningJob.DoesNotExist: 57 | print(f"\n✗ FAIL: Job {job_id} not found in database") 58 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/migrations/0084_convert_arrayfield_to_json.py: -------------------------------------------------------------------------------- 1 | # Migration to safely convert ArrayField to JSONField for SQLite compatibility 2 | from django.db import migrations, models 3 | 4 | 5 | def copy_arrayfield_to_json(apps, schema_editor): 6 | """ 7 | Copy data from ArrayField to JSONField format. 8 | This handles the conversion for both PostgreSQL and SQLite. 9 | """ 10 | Photo = apps.get_model('api', 'Photo') 11 | 12 | for photo in Photo.objects.all(): 13 | if photo.clip_embeddings is not None: 14 | # ArrayField data is already in list format, just copy it 15 | photo.clip_embeddings_json = photo.clip_embeddings 16 | photo.save(update_fields=['clip_embeddings_json']) 17 | 18 | 19 | def copy_json_to_arrayfield(apps, schema_editor): 20 | """ 21 | Reverse migration: copy JSONField data back to ArrayField format. 22 | """ 23 | Photo = apps.get_model('api', 'Photo') 24 | 25 | for photo in Photo.objects.all(): 26 | if photo.clip_embeddings_json is not None: 27 | photo.clip_embeddings = photo.clip_embeddings_json 28 | photo.save(update_fields=['clip_embeddings']) 29 | 30 | 31 | class Migration(migrations.Migration): 32 | dependencies = [ 33 | ('api', '0083_remove_search_fields'), 34 | ] 35 | 36 | operations = [ 37 | # Step 1: Add new JSONField 38 | migrations.AddField( 39 | model_name='Photo', 40 | name='clip_embeddings_json', 41 | field=models.JSONField(blank=True, null=True), 42 | ), 43 | 44 | # Step 2: Copy data from ArrayField to JSONField 45 | migrations.RunPython( 46 | copy_arrayfield_to_json, 47 | copy_json_to_arrayfield, 48 | ), 49 | 50 | # Step 3: Remove old ArrayField 51 | migrations.RemoveField( 52 | model_name='Photo', 53 | name='clip_embeddings', 54 | ), 55 | 56 | # Step 4: Rename JSONField to original name 57 | migrations.RenameField( 58 | model_name='Photo', 59 | old_name='clip_embeddings_json', 60 | new_name='clip_embeddings', 61 | ), 62 | ] -------------------------------------------------------------------------------- /api/tests/test_scan_photos_directories.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import uuid 4 | from unittest.mock import patch 5 | 6 | from django.test import TestCase, override_settings 7 | 8 | from api.directory_watcher import scan_photos 9 | from api.tests.utils import create_test_user 10 | 11 | 12 | class DummyAsyncTask: 13 | def __init__(self, *args, **kwargs): 14 | pass 15 | 16 | def run(self): 17 | return None 18 | 19 | 20 | class DummyChain: 21 | def __init__(self, *args, **kwargs): 22 | self.appended = [] 23 | 24 | def append(self, *args, **kwargs): 25 | self.appended.append((args, kwargs)) 26 | return self 27 | 28 | def run(self): 29 | return None 30 | 31 | 32 | class ScanPhotosDirectoryCreationTest(TestCase): 33 | def test_existing_thumbnail_directory_does_not_raise(self): 34 | user = create_test_user() 35 | with tempfile.TemporaryDirectory() as media_root: 36 | preexisting_dir = os.path.join(media_root, "square_thumbnails_small") 37 | os.makedirs(preexisting_dir, exist_ok=True) 38 | 39 | user.scan_directory = media_root 40 | user.save(update_fields=["scan_directory"]) 41 | 42 | with override_settings(MEDIA_ROOT=media_root): 43 | with patch("api.directory_watcher.walk_directory"), patch( 44 | "api.directory_watcher.walk_files" 45 | ), patch("api.directory_watcher.photo_scanner"), patch( 46 | "api.directory_watcher.AsyncTask", DummyAsyncTask 47 | ), patch("api.directory_watcher.Chain", DummyChain): 48 | scan_photos(user, full_scan=False, job_id=str(uuid.uuid4())) 49 | 50 | expected_directories = [ 51 | "square_thumbnails_small", 52 | "square_thumbnails", 53 | "thumbnails_big", 54 | ] 55 | for directory_name in expected_directories: 56 | directory_path = os.path.join(media_root, directory_name) 57 | self.assertTrue( 58 | os.path.isdir(directory_path), 59 | msg=f"Expected directory {directory_path} to exist", 60 | ) 61 | -------------------------------------------------------------------------------- /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/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/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/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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/models/duplicate_group.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from api.models.user import User, get_deleted_user 4 | 5 | 6 | class DuplicateGroup(models.Model): 7 | """ 8 | Represents a group of photos that are visually similar (duplicates). 9 | Photos in the same group share similar perceptual hashes. 10 | """ 11 | 12 | class Status(models.TextChoices): 13 | PENDING = "pending", "Pending Review" 14 | REVIEWED = "reviewed", "Reviewed" 15 | DISMISSED = "dismissed", "Dismissed (Not Duplicates)" 16 | 17 | owner = models.ForeignKey( 18 | User, on_delete=models.SET(get_deleted_user), related_name="duplicate_groups" 19 | ) 20 | created_at = models.DateTimeField(auto_now_add=True) 21 | updated_at = models.DateTimeField(auto_now=True) 22 | status = models.CharField( 23 | max_length=20, choices=Status.choices, default=Status.PENDING, db_index=True 24 | ) 25 | # The photo the user has chosen to keep as the "best" version 26 | preferred_photo = models.ForeignKey( 27 | "Photo", 28 | on_delete=models.SET_NULL, 29 | null=True, 30 | blank=True, 31 | related_name="preferred_in_group", 32 | ) 33 | 34 | class Meta: 35 | ordering = ["-created_at"] 36 | 37 | def __str__(self): 38 | return f"DuplicateGroup {self.id} - {self.owner.username} - {self.status}" 39 | 40 | @property 41 | def photo_count(self): 42 | return self.photos.count() 43 | 44 | def get_photos_ordered_by_quality(self): 45 | """ 46 | Returns photos in the group ordered by quality metrics. 47 | Higher resolution and larger file size are considered better quality. 48 | """ 49 | return self.photos.order_by("-width", "-height", "-size") 50 | 51 | def auto_select_preferred(self): 52 | """ 53 | Automatically selects the highest quality photo as preferred. 54 | Quality is determined by resolution (width * height) and file size. 55 | """ 56 | best_photo = self.photos.order_by( 57 | models.F("width") * models.F("height") 58 | ).last() 59 | if best_photo: 60 | self.preferred_photo = best_photo 61 | self.save(update_fields=["preferred_photo"]) 62 | return best_photo 63 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/migrations/0095_photo_perceptual_hash_alter_longrunningjob_job_type_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.9 on 2025-12-23 09:47 2 | 3 | import api.models.user 4 | import django.db.models.deletion 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('api', '0094_add_slideshow_interval'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='photo', 18 | name='perceptual_hash', 19 | field=models.CharField(blank=True, db_index=True, max_length=64, null=True), 20 | ), 21 | migrations.AlterField( 22 | model_name='longrunningjob', 23 | name='job_type', 24 | field=models.PositiveIntegerField(choices=[(1, 'Scan Photos'), (2, 'Generate Event Albums'), (3, 'Regenerate Event Titles'), (4, 'Train Faces'), (5, 'Delete Missing Photos'), (7, 'Scan Faces'), (6, 'Calculate Clip Embeddings'), (8, 'Find Similar Faces'), (9, 'Download Selected Photos'), (10, 'Download Models'), (11, 'Add Geolocation'), (12, 'Generate Tags'), (13, 'Generate Face Embeddings'), (14, 'Scan Missing Photos'), (15, 'Detect Duplicate Photos')]), 25 | ), 26 | migrations.CreateModel( 27 | name='DuplicateGroup', 28 | fields=[ 29 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 30 | ('created_at', models.DateTimeField(auto_now_add=True)), 31 | ('updated_at', models.DateTimeField(auto_now=True)), 32 | ('status', models.CharField(choices=[('pending', 'Pending Review'), ('reviewed', 'Reviewed'), ('dismissed', 'Dismissed (Not Duplicates)')], db_index=True, default='pending', max_length=20)), 33 | ('owner', models.ForeignKey(on_delete=models.SET(api.models.user.get_deleted_user), related_name='duplicate_groups', to=settings.AUTH_USER_MODEL)), 34 | ('preferred_photo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='preferred_in_group', to='api.photo')), 35 | ], 36 | options={ 37 | 'ordering': ['-created_at'], 38 | }, 39 | ), 40 | migrations.AddField( 41 | model_name='photo', 42 | name='duplicate_group', 43 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='photos', to='api.duplicategroup'), 44 | ), 45 | ] 46 | --------------------------------------------------------------------------------