Compare commits

..

1 Commits

Author SHA1 Message Date
Santo Shakil bb8e242fbe feat: add native rust core 2026-06-29 20:53:47 +06:00
113 changed files with 2235 additions and 1734 deletions
+1
View File
@@ -5,3 +5,4 @@
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen @santoshakil
/native/ @santoshakil @mertalev
+1 -1
View File
@@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v3
IMMICH_VERSION=v2
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
+1 -1
View File
@@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`.
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-----: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning |
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
| `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
+1 -1
View File
@@ -29,7 +29,7 @@ docker image prune
## Versioning Policy
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
Switching back to an earlier version, even within the same minor release tag, is not supported.
+2 -2
View File
@@ -1,7 +1,7 @@
[
{
"label": "v3.0.0",
"url": "https://docs.v3.0.0.archive.immich.app"
"label": "v3.0.0-rc.4",
"url": "https://docs.v3.0.0-rc.4.archive.immich.app"
},
{
"label": "v2.7.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "3.0.0",
"version": "3.0.0-rc.4",
"description": "",
"main": "index.js",
"type": "module",
+1 -1
View File
@@ -1304,7 +1304,7 @@
"login_form_handshake_exception": "كان هناك استثناء مصافحة مع الخادم.تمكين دعم الشهادة الموقعة ذاتيا في الإعدادات إذا كنت تستخدم شهادة موقعة ذاتيا.",
"login_form_password_hint": "كلمة المرور",
"login_form_server_empty": "أدخل عنوان URL الخادم.",
"login_form_server_error": "تعذر الاتصال بالخادم.",
"login_form_server_error": "لا يمكن الاتصال بالخادم.",
"login_has_been_disabled": "تم تعطيل تسجيل الدخول.",
"login_password_changed_error": "كان هناك خطأ في تحديث كلمة المرور الخاصة بك",
"login_password_changed_success": "تم تحديث كلمة السر بنجاح",
+3 -3
View File
@@ -1275,9 +1275,9 @@
"login_form_failed_login": "Памылка ўваходу, праверце URL-адрас сервера, электронную пошту і пароль",
"login_form_handshake_exception": "Адбылася памылка ўзаемадзеяння з серверам. Уключыце падтрымку самападпісаных сертыфікатаў у наладах, калі вы карыстаецеся самападпісаным сертыфікатам.",
"login_form_password_hint": "пароль",
"login_form_server_empty": "Увядзіце URL-адрас сервера",
"login_form_server_error": "Не ўдалося падключыцца да сервера",
"login_has_been_disabled": "Уваход быў адключаны",
"login_form_server_empty": "Увядзіце URL-адрас сервера.",
"login_form_server_error": "Не ўдалося падключыцца да сервера.",
"login_has_been_disabled": "Уваход быў адключаны.",
"login_password_changed_error": "Адбылася памылка пры абнаўленні пароля",
"login_password_changed_success": "Пароль паспяхова абноўлены",
"logout_all_device_confirmation": "Вы ўпэўнены, што хочаце выйсці з усіх прылад?",
-3
View File
@@ -1507,9 +1507,6 @@
"notes": "Бележки",
"nothing_here_yet": "Засега тук няма нищо",
"notification_backup_reliability": "Позволете известията, за да подобрите надеждността на архивиране във фонов режим",
"notification_enabled_list_tile_content": "Immich използва механизма за известия при архивиране във фонов режим. Управлявайте ги от менюто за настройки на вашето устройство.",
"notification_enabled_list_tile_open_button": "Отвори настройки",
"notification_enabled_list_tile_title": "Известията са активирани",
"notification_permission_dialog_content": "За да включиш известията, отиди в Настройки и избери Разреши.",
"notification_permission_list_tile_content": "Дай разрешение за активиране на известията.",
"notification_permission_list_tile_enable_button": "Разреши известията",
+1 -4
View File
@@ -53,7 +53,7 @@
"backup_onboarding_1_description": "còpia externa al núvol o en una altra ubicació física.",
"backup_onboarding_2_description": "còpies locals en diferents dispositius. Això inclou els fitxers principals i una còpia de seguretat d'aquests fitxers localment.",
"backup_onboarding_3_description": "còpies totals de les vostres dades, inclosos els fitxers originals. Això inclou 1 còpia externa i 2 còpies locals.",
"backup_onboarding_description": "Es recomana una <backblaze-link>estratègia de còpia de seguretat 3-2-1</backblaze-link> per protegir les vostres dades. Hauríeu de conservar còpies de les vostres fotos/vídeos penjats, així com de la base de dades d'Immich per obtenir una solució de còpia de seguretat completa.",
"backup_onboarding_description": "Es recomana una <backblaze-link>estratègia de còpia de seguretat 3-2-1</backblaze-link> per protegir les vostres dades. Hauríeu de conservar còpies de les vostres fotos/vídeos penjats, així com de la base de dades Immich per obtenir una solució de còpia de seguretat completa.",
"backup_onboarding_footer": "Per obtenir més informació sobre com fer còpies de seguretat d'Immich, consulteu la <link>documentation</link>.",
"backup_onboarding_parts_title": "Una còpia de seguretat 3-2-1 inclou:",
"backup_onboarding_title": "Còpies de seguretat",
@@ -1507,9 +1507,6 @@
"notes": "Notes",
"nothing_here_yet": "No hi ha res encara",
"notification_backup_reliability": "Activa les notificacions per millorar la fiabilitat de les còpies de seguretat en segon pla",
"notification_enabled_list_tile_content": "Immich utilitza les notificacions per a les còpies de seguretat en segon pla. Gestioneu-les a la configuració del vostre dispositiu.",
"notification_enabled_list_tile_open_button": "Obre la configuració",
"notification_enabled_list_tile_title": "Notificacions activades",
"notification_permission_dialog_content": "Per activar les notificacions, aneu a Configuració i seleccioneu permet.",
"notification_permission_list_tile_content": "Atorga permís per a activar les notificacions.",
"notification_permission_list_tile_enable_button": "Activa les notificacions",
+2 -2
View File
@@ -1879,7 +1879,7 @@
"set_slideshow_to_fullscreen": "Nastavit prezentaci na celou obrazovku",
"set_stack_primary_asset": "Nastavit jako hlavní položku",
"setting_image_navigation_enable_subtitle": "Pokud je zapnuto, budete moci přejít na předchozí/další obrázek klepnutím do levé/pravé čtvrtiny obrazovky.",
"setting_image_navigation_enable_title": "Klepnutí pro navigaci",
"setting_image_navigation_enable_title": "Klepněte pro navigaci",
"setting_image_navigation_title": "Navigace mezi obrázky",
"setting_image_viewer_help": "V prohlížeči detailů se nejprve načte malá miniatura, poté se načte náhled střední velikosti (je-li povolen) a nakonec se načte originál (je-li povolen).",
"setting_image_viewer_original_subtitle": "Umožňuje načíst původní obrázek v plném rozlišení (velký!). Zakažte pro snížení využití dat (v síti i v mezipaměti zařízení).",
@@ -2070,7 +2070,7 @@
"theme_setting_primary_color_subtitle": "Zvolte barvu pro hlavní akce a zvýraznění.",
"theme_setting_primary_color_title": "Hlavní barva",
"theme_setting_system_primary_color_title": "Použít systémovou barvu",
"theme_setting_system_theme_switch": "Automaticky (podle systému)",
"theme_setting_system_theme_switch": "Automaticky (podle systemového nastavení)",
"then": "Pak",
"they_will_be_merged_together": "Budou sloučeny dohromady",
"third_party_resources": "Zdroje třetích stran",
+4 -36
View File
@@ -73,7 +73,6 @@
"cron_expression_description": "Indstil skannings intervallet i cron format. For mere information se: <link>Crontab Guru</link>",
"cron_expression_presets": "cron predefinerede indstillinger",
"disable_login": "Deaktiver login",
"download_csv": "Hent CSV",
"duplicate_detection_job_description": "Kør maskinlæring på mediefiler for at opdage lignende billeder. Er afhængig af Smart Søgning",
"exclusion_pattern_description": "Ekskluderingsmønstre lader dig ignorere filer og mapper, når du scanner dit bibliotek. Dette er nyttigt, hvis du har mapper, der indeholder filer, du ikke vil importere, såsom RAW-filer.",
"export_config_as_json_description": "Download den aktuelle systemkonfiguration som en JSON-fil",
@@ -183,25 +182,9 @@
"machine_learning_smart_search_enabled": "Aktiver smart søgning",
"machine_learning_smart_search_enabled_description": "Hvis deaktiveret, vil billeder ikke blive kodet til smart søgning.",
"machine_learning_url_description": "URLen for maskinlæringsserveren. Hvis mere end én URL angives, vil hver server blive forsøgt én ad gangen, indtil en svarer succesfuldt, i rækkefølge fra første til sidste. Servere, der ikke svarer, vil midlertidigt blive ignoreret, indtil de kommer online igen.",
"maintenance_backup_management": "Backup administration",
"maintenance_delete_backup": "Slet Backup",
"maintenance_delete_backup_description": "Denne fil vil blive slettet permanent.",
"maintenance_delete_error": "Sletning af backup fejlede.",
"maintenance_integrity_check": "Kontroller",
"maintenance_integrity_check_all": "Kontroller alle",
"maintenance_integrity_checksum_mismatch": "Checksum uoverensstemmelse",
"maintenance_integrity_checksum_mismatch_description": "Filer hvis checksum på disken ikke stemmer overens med den checksum, Immich har gemt i sin database.",
"maintenance_integrity_checksum_mismatch_job": "Kontroller for uoverensstemmelser i checksum",
"maintenance_integrity_checksum_mismatch_refresh_job": "Opdater rapporter om uoverensstemmelser i checksum",
"maintenance_integrity_missing_file": "Manglende filer",
"maintenance_integrity_missing_file_description": "Filer som Immich har i sin database, men som ikke findes i filsystemet.",
"maintenance_integrity_missing_file_job": "Tjek for manglende filer",
"maintenance_integrity_missing_file_refresh_job": "Opdater rapporter om manglende filer",
"maintenance_integrity_report": "Integritetsrapport",
"maintenance_integrity_untracked_file": "Ikke-registreret filer",
"maintenance_integrity_untracked_file_description": "Filer i Immichs mapper, som Immich ikke har nogen registrering af.",
"maintenance_integrity_untracked_file_job": "Tjek for ikke-registreret filer",
"maintenance_integrity_untracked_file_refresh_job": "Opdater rapporter om ikke-registreret filer",
"maintenance_restore_backup": "Genskab backup",
"maintenance_restore_backup_description": "Immich bliver slettet og genskabt fra den valgte backup. Der vil blive taget en backup før du fortsætter.",
"maintenance_restore_backup_different_version": "Denne backup blev lavet med en anden version af Immich!",
@@ -811,8 +794,6 @@
"day": "Dag",
"days": "Dage",
"deduplicate_all": "Dedubliker alle",
"default_quality_subtitle": "Kvalitet brugt ved tryk på 'del'. Tryk længe på dele-knappen for at vælge hver gang.",
"default_share_quality": "Standard delingskvalitet",
"delete": "Slet",
"delete_action_confirmation_message": "Er du sikker på, at du vil slette dette objekt? Denne handling vil flytte objektet til serverens papirkurv, og vil spørge dig, om du vil slette den lokalt",
"delete_action_prompt": "{count} slettet",
@@ -1095,7 +1076,6 @@
"failed": "Fejlet",
"failed_count": "Fejlede: {count}",
"failed_to_authenticate": "Kunne ikke godkendes",
"failed_to_delete_file": "Kunne ikke slette filen",
"failed_to_load_assets": "Kunne ikke indlæse mediefiler",
"failed_to_load_folder": "Kunne ikke indlæse mappe",
"favorite": "Favorit",
@@ -1200,7 +1180,6 @@
"individual_share": "Individuel andel",
"individual_shares": "Individuelle delinger",
"info": "Info",
"integrity_checks": "Integritetstjek",
"interval": {
"day_at_onepm": "Hver dag kl. 13",
"hours": "Hver {hours, plural, one {time} other {{hours, number} timer}}",
@@ -1262,7 +1241,6 @@
"linked_oauth_account": "Tilsluttet OAuth-konto",
"list": "Liste",
"live": "Live",
"load_more": "Indlæs flere",
"loading": "Indlæser",
"loading_search_results_failed": "Indlæsning af søgeresultater fejlede",
"local": "Lokal",
@@ -1303,9 +1281,9 @@
"login_form_failed_login": "Der opstod en vejl ved at logge ind. Tjek server webadressen, e-mailen og kodeordet",
"login_form_handshake_exception": "Der opstod en fejl med at oprette forbindelse til serveren. Aktiver selvsignerede certifikater i indstillingerne, hvis du bruger et selv signeret certifikat.",
"login_form_password_hint": "kodeord",
"login_form_server_empty": "Indtast server-URL",
"login_form_server_error": "Kunne ikke forbinde til serveren",
"login_has_been_disabled": "Login er blevet deaktiveret",
"login_form_server_empty": "Indtast server-URL.",
"login_form_server_error": "Kunne ikke forbinde til serveren.",
"login_has_been_disabled": "Login er blevet deaktiveret.",
"login_password_changed_error": "Der opstod en fejl i opdateringen af dit kodeord",
"login_password_changed_success": "Kodeordet blev opdateret",
"logout_all_device_confirmation": "Er du sikker på, at du vil logge ud af alle enheder?",
@@ -1356,7 +1334,6 @@
"map_location_picker_page_use_location": "Brug denne placering",
"map_location_service_disabled_content": "Placeringstjenesten skal aktiveres for at vise elementer fra din nuværende placering. Vil du aktivere den nu?",
"map_location_service_disabled_title": "Placeringstjenesten er deaktiveret",
"map_marker_for_image": "Kortmarkør for billede taget i {city}, {country}",
"map_marker_with_image": "Kortmarkør med billede",
"map_no_location_permission_content": "Der kræves tilladelse til placeringen for at vise elementer fra din nuværende placering. Vil du give tilladelse?",
"map_no_location_permission_title": "Placeringstilladelse blev afvist",
@@ -1507,9 +1484,6 @@
"notes": "Noter",
"nothing_here_yet": "Intet her endnu",
"notification_backup_reliability": "Aktivér notifikationer for at forbedre pålideligheden af backup i baggrunden",
"notification_enabled_list_tile_content": "Immich bruger notifikationer til baggrundsbackup. Administrer dem i dine enhedsindstillinger.",
"notification_enabled_list_tile_open_button": "Åbn indstillinger",
"notification_enabled_list_tile_title": "Notifikationer aktiveret",
"notification_permission_dialog_content": "Gå til indstillinger for at slå notifikationer til.",
"notification_permission_list_tile_content": "Tillad at bruge notifikationer.",
"notification_permission_list_tile_enable_button": "Slå notifikationer til",
@@ -1852,7 +1826,6 @@
"select_person": "Vælg person",
"select_person_to_tag": "Vælg en person at tagge",
"select_photos": "Vælg billeder",
"select_quality": "Vælg kvalitet",
"select_trash_all": "Vælg smid alle ud",
"selected": "Valgt",
"selected_count": "{count, plural, one {# valgt} other {# valgte}}",
@@ -1902,8 +1875,6 @@
"share": "Del",
"share_dialog_preparing": "Forbereder...",
"share_link": "Del link",
"share_original": "Brug original (stor)",
"share_preview": "Brug miniaturebillede (lille)",
"shared": "Delt",
"shared_album_activities_input_disable": "Kommentarer er deaktiveret",
"shared_album_activity_remove_content": "Vil du slette denne aktivitet?",
@@ -1996,7 +1967,6 @@
"slideshow_repeat_description": "Hop tilbage til begyndelsen når diasshow stopper",
"slideshow_settings": "Diasshowindstillinger",
"smart_album": "Smart album",
"some_assets_already_have_a_location_warning": "Nogle af de valgte aktiver har allerede en placering",
"sort_albums_by": "Sortér albummer efter...",
"sort_created": "Dato oprettet",
"sort_items": "Antal genstande",
@@ -2101,12 +2071,10 @@
"trash_page_info": "Slettede elementer vil blive slettet permanent efter {days} dage",
"trashed_items_will_be_permanently_deleted_after": "Mediefiler i papirkurven vil blive slettet permanent efter {days, plural, one {# dag} other {# dage}}.",
"trigger": "Udløser",
"trigger_asset_metadata_extraction": "Udtrækning af metadata for mediefil",
"trigger_asset_metadata_extraction_description": "Udløses, når EXIF-metadataene for en mediefil udtrækkes",
"trigger_asset_uploaded": "Mediefil upload",
"trigger_asset_uploaded_description": "Udløses, når et nyt asset bliver uploaded",
"trigger_person_recognized": "Peron genkendt",
"trigger_person_recognized_description": "Udløses, når en person er genkendt",
"trigger_person_recognized_description": "Udløses, når en person er detekteret",
"troubleshoot": "Fejlfinding",
"type": "Type",
"unable_to_change_pin_code": "Kunne ikke ændre PIN kode",
-32
View File
@@ -108,21 +108,6 @@
"image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
"image_thumbnail_title": "Thumbnail Settings",
"import_config_from_json_description": "Import system config by uploading a JSON config file",
"integrity_checks_checksum_files": "Checksum files",
"integrity_checks_checksum_files_description": "Configure the checksum check",
"integrity_checks_checksum_files_enable_description": "Enable the checksum check",
"integrity_checks_checksum_files_percentage_limit": "Percentage limit",
"integrity_checks_checksum_files_percentage_limit_description": "Configure the maximum percentage between 0.01 and 1 for how much the checksum check should run each interval.",
"integrity_checks_checksum_files_time_limit": "Time limit",
"integrity_checks_checksum_files_time_limit_description": "Configure the maximum duration for which the checksum check should run each interval. (ms)",
"integrity_checks_missing_files": "Missing files",
"integrity_checks_missing_files_description": "Configure the frequency and enable or disable the missing files check",
"integrity_checks_missing_files_enable_description": "Enable the missing files check",
"integrity_checks_settings": "Integrity checks",
"integrity_checks_settings_description": "Manage integrity checks intervals",
"integrity_checks_untracked_files": "Untracked files",
"integrity_checks_untracked_files_description": "Configure the frequency and enable or disable the untracked files check",
"integrity_checks_untracked_files_enable_description": "Enable the untracked files check",
"job_concurrency": "{job} concurrency",
"job_created": "Job created",
"job_not_concurrency_safe": "This job is not concurrency-safe.",
@@ -1476,7 +1461,6 @@
"never": "Never",
"new_album": "New Album",
"new_api_key": "New API Key",
"new_feature": "New Feature",
"new_password": "New password",
"new_person": "New person",
"new_pin_code": "New PIN code",
@@ -1537,8 +1521,6 @@
"obtainium_configurator": "Obtainium Configurator",
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
"ocr": "OCR",
"ocr_body": "Immich now reads the text inside your photos, so you can search for them by what they say.",
"ocr_title": "Search text in your photos",
"official_immich_resources": "Official Immich Resources",
"offline": "Offline",
"offset": "Offset",
@@ -1557,8 +1539,6 @@
"open": "Open",
"open_calendar": "Open calendar",
"open_in_browser": "Open in browser",
"open_in_immich_body": "Set Immich as your gallery on Android to open photos straight from other apps.",
"open_in_immich_title": "Open photos in Immich",
"open_in_map_view": "Open in map view",
"open_in_openstreetmap": "Open in OpenStreetMap",
"open_the_search_filters": "Open the search filters",
@@ -1717,9 +1697,7 @@
"recent": "Recent",
"recent_searches": "Recent searches",
"recently_added": "Recently added",
"recently_added_body": "Jump straight to everything you've added lately on a dedicated page.",
"recently_added_page_title": "Recently Added",
"recently_added_title": "Recently added",
"recently_taken": "Recently taken",
"refresh": "Refresh",
"refresh_encoded_videos": "Refresh encoded videos",
@@ -1926,8 +1904,6 @@
"share_link": "Share Link",
"share_original": "Use original (large)",
"share_preview": "Use thumbnail (small)",
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
"share_quality_title": "Choose your share quality",
"shared": "Shared",
"shared_album_activities_input_disable": "Comment is disabled",
"shared_album_activity_remove_content": "Do you want to delete this activity?",
@@ -2009,19 +1985,16 @@
"sign_out": "Sign Out",
"sign_up": "Sign up",
"size": "Size",
"skip": "Skip",
"skip_to_content": "Skip to content",
"skip_to_folders": "Skip to folders",
"skip_to_tags": "Skip to tags",
"slideshow": "Slideshow",
"slideshow_body": "Sit back and watch your photos play in a full-screen slideshow.",
"slideshow_metadata_overlay_mode": "Overlay content",
"slideshow_metadata_overlay_mode_description_only": "Description only",
"slideshow_metadata_overlay_mode_full": "Full",
"slideshow_repeat": "Repeat slideshow",
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings",
"slideshow_title": "Slideshow",
"smart_album": "Smart album",
"some_assets_already_have_a_location_warning": "Some of the selected assets already have a location",
"sort_albums_by": "Sort albums by...",
@@ -2184,8 +2157,6 @@
"upload_status_errors": "Errors",
"upload_status_uploaded": "Uploaded",
"upload_success": "Upload success, refresh the page to see new upload assets.",
"upload_to_album_body": "For users that don't utilize the manual upload feature, you can now choose to add local photos directly into an album as you upload them, no need to upload then add to an album later anymore.",
"upload_to_album_title": "Upload straight to an album",
"upload_to_immich": "Upload to Immich ({count})",
"uploading": "Uploading",
"uploading_media": "Uploading media",
@@ -2253,9 +2224,6 @@
"week": "Week",
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"whats_new": "What's new",
"whats_new_settings_subtitle": "See what's new in Immich",
"whats_new_version": "Version {version}",
"when": "When",
"width": "Width",
"wifi_name": "Wi-Fi Name",
+3 -6
View File
@@ -1303,9 +1303,9 @@
"login_form_failed_login": "Error al iniciar sesión, comprueba la URL del servidor, el correo electrónico y la contraseña",
"login_form_handshake_exception": "Hubo una excepción de handshake con el servidor. Activa la compatibilidad con certificados autofirmados en la configuración si estás utilizando un certificado autofirmado.",
"login_form_password_hint": "contraseña",
"login_form_server_empty": "Introduce la URL del servidor",
"login_form_server_error": "No se pudo conectar al servidor",
"login_has_been_disabled": "El inicio de sesión ha sido deshabilitado",
"login_form_server_empty": "Introduce la URL del servidor.",
"login_form_server_error": "No se pudo conectar al servidor.",
"login_has_been_disabled": "El inicio de sesión ha sido deshabilitado.",
"login_password_changed_error": "Hubo un error actualizando la contraseña",
"login_password_changed_success": "Contraseña cambiado con éxito",
"logout_all_device_confirmation": "¿Estás seguro de que quieres cerrar sesión en todos los dispositivos?",
@@ -1507,9 +1507,6 @@
"notes": "Notas",
"nothing_here_yet": "Sin nada aún",
"notification_backup_reliability": "Activa las notificaciones para mejorar el funcionamiento de las copias de seguridad en segundo plano",
"notification_enabled_list_tile_content": "Immich usa notificaciones para las copias de seguridad en segundo plano. Gestiónelas en los ajustes de su dispositivo.",
"notification_enabled_list_tile_open_button": "Abrir ajustes",
"notification_enabled_list_tile_title": "Notificaciones activadas",
"notification_permission_dialog_content": "Para activar las notificaciones, ve a Configuración y selecciona permitir.",
"notification_permission_list_tile_content": "Concede permiso para habilitar las notificaciones.",
"notification_permission_list_tile_enable_button": "Permitir notificaciones",
+1 -1
View File
@@ -171,7 +171,7 @@
"machine_learning_ocr_max_resolution": "Résolution maximale",
"machine_learning_ocr_max_resolution_description": "Les prévisualisations au-dessus de cette résolution seront retaillées en conservant leur ratio. Des valeurs plus grandes sont plus précises, mais sont plus lentes et utilisent plus de mémoire.",
"machine_learning_ocr_min_detection_score": "Score minimum de détection",
"machine_learning_ocr_min_detection_score_description": "Score de confiance minimum pour la détection du texte, entre 0 et 1. Des valeurs faibles permettront de reconnaître davantage de texte mais peuvent entraîner des faux positifs.",
"machine_learning_ocr_min_detection_score_description": "Score de confiance minimum pour la détection du textew entre 0 et 1. Des valeurs faibles permettront de reconnaître davantage de texte mais peuvent entraîner des faux positifs.",
"machine_learning_ocr_min_recognition_score": "Score de reconnaissance minimum",
"machine_learning_ocr_min_score_recognition_description": "Score de confiance minimum pour la reconnaissance du texte, entre 0 et 1. Des valeurs faible permettront de reconnaître davantage de texte, mais peuvent entraîner des faux positifs.",
"machine_learning_ocr_model": "Modèle de Reconnaissance Optique de Caractères OCR",
+3 -7
View File
@@ -1303,9 +1303,9 @@
"login_form_failed_login": "Earráid ag logáil isteach, seiceáil URL an fhreastalaí, an ríomhphost agus an focal faire",
"login_form_handshake_exception": "Bhí Eisceacht Lámh-Chroith leis an bhfreastalaí. Cumasaigh tacaíocht do theastas féinshínithe sna socruithe má tá teastas féinshínithe in úsáid agat.",
"login_form_password_hint": "pasfhocal",
"login_form_server_empty": "Cuir isteach URL freastalaí",
"login_form_server_error": "Níorbh fhéidir ceangal leis an bhfreastalaí",
"login_has_been_disabled": "Tá logáil isteach díchumasaithe",
"login_form_server_empty": "Cuir isteach URL freastalaí.",
"login_form_server_error": "Níorbh fhéidir ceangal leis an bhfreastalaí.",
"login_has_been_disabled": "Tá logáil isteach díchumasaithe.",
"login_password_changed_error": "Tharla earráid agus do phasfhocal á nuashonrú",
"login_password_changed_success": "Nuashonraíodh an focal faire go rathúil",
"logout_all_device_confirmation": "An bhfuil tú cinnte gur mian leat logáil amach as gach gléas?",
@@ -1356,7 +1356,6 @@
"map_location_picker_page_use_location": "Úsáid an suíomh seo",
"map_location_service_disabled_content": "Ní mór seirbhís suímh a chumasú chun sócmhainní ó do shuíomh reatha a thaispeáint. Ar mhaith leat é a chumasú anois?",
"map_location_service_disabled_title": "Seirbhís Suímh díchumasaithe",
"map_marker_for_image": "Marcóir léarscáile don íomhá a tógadh i {city}, {country}",
"map_marker_with_image": "Marcóir léarscáile le híomhá",
"map_no_location_permission_content": "Tá cead suímh ag teastáil chun sócmhainní a thaispeáint ó do shuíomh reatha. Ar mhaith leat é a cheadú anois?",
"map_no_location_permission_title": "Cead Suímh diúltaithe",
@@ -1507,9 +1506,6 @@
"notes": "Nótaí",
"nothing_here_yet": "Níl aon rud anseo fós",
"notification_backup_reliability": "Cumasaigh fógraí chun iontaofacht cúltaca cúlra a fheabhsú",
"notification_enabled_list_tile_content": "Úsáideann Immich fógraí le haghaidh cúltaca cúlra. Bainistigh iad i socruithe do ghléis.",
"notification_enabled_list_tile_open_button": "Oscail socruithe",
"notification_enabled_list_tile_title": "Fógraí cumasaithe",
"notification_permission_dialog_content": "Chun fógraí a chumasú, téigh go Socruithe agus roghnaigh ceadaigh.",
"notification_permission_list_tile_content": "Tabhair cead fógraí a chumasú.",
"notification_permission_list_tile_enable_button": "Cumasaigh Fógraí",
+3 -7
View File
@@ -1303,9 +1303,9 @@
"login_form_failed_login": "Erro ao iniciar sesión, comprobe a URL do servidor, correo electrónico e contrasinal",
"login_form_handshake_exception": "Houbo unha Excepción de Handshake co servidor. Active o soporte para certificados autofirmados nas configuracións se está a usar un certificado autofirmado.",
"login_form_password_hint": "contrasinal",
"login_form_server_empty": "Introduza unha URL do servidor",
"login_form_server_error": "Non se puido conectar co servidor",
"login_has_been_disabled": "O inicio de sesión foi desactivado",
"login_form_server_empty": "Introduza unha URL do servidor.",
"login_form_server_error": "Non se puido conectar co servidor.",
"login_has_been_disabled": "O inicio de sesión foi desactivado.",
"login_password_changed_error": "Houbo un erro ao actualizar o seu contrasinal",
"login_password_changed_success": "Contrasinal actualizado correctamente",
"logout_all_device_confirmation": "Está seguro de que quere pechar sesión en todos os dispositivos?",
@@ -1356,7 +1356,6 @@
"map_location_picker_page_use_location": "Usar esta localización",
"map_location_service_disabled_content": "O servizo de localización debe estar activado para mostrar activos da súa localización actual. Quere activalo agora?",
"map_location_service_disabled_title": "Servizo de localización deshabilitado",
"map_marker_for_image": "Marcador do mapa para a imaxe tirada en {city}, {country}",
"map_marker_with_image": "Marcador de mapa con imaxe",
"map_no_location_permission_content": "Necesítase permiso de localización para mostrar activos da súa localización actual. Quere permitilo agora?",
"map_no_location_permission_title": "Permiso de localización denegado",
@@ -1507,9 +1506,6 @@
"notes": "Notas",
"nothing_here_yet": "Aínda nada por aquí",
"notification_backup_reliability": "Activa as notificacións para mellorar a fiabilidade da copia de seguridade en segundo plano",
"notification_enabled_list_tile_content": "Immich emprega notificacións para copias de seguridade en segundo plano. Xestióneas nos axustes do seu dispositivo.",
"notification_enabled_list_tile_open_button": "Abrir axustes",
"notification_enabled_list_tile_title": "Notificacións activadas",
"notification_permission_dialog_content": "Para activar as notificacións, vaia a Axustes e seleccione permitir.",
"notification_permission_list_tile_content": "Conceda permiso para activar as notificacións.",
"notification_permission_list_tile_enable_button": "Activar Notificacións",
+1 -10
View File
@@ -23,7 +23,6 @@
"add_location": "સ્થાન ઉમેરો",
"add_partner": "સાથી ઉમેરો",
"add_photos": "ફોટો ઉમેરો",
"add_step": "પગલું ઉમેરો",
"add_tag": "ટેગ ઉમેરો",
"add_to": "માં ઉમેરો",
"add_to_album": "આલ્બમમાં ઉમેરો",
@@ -73,7 +72,6 @@
"cron_expression_description": "ક્રોન ફોર્મેટનો ઉપયોગ કરીને સ્કેનિંગ ઇન્ટરવલ સેટ કરો. વધુ માહિતી માટે કૃપા કરીને <link>Crontab Guru</link> જુઓ.",
"cron_expression_presets": "ક્રોન એક્સપ્રેશન પ્રીસેટ્સ",
"disable_login": "લોગિન ડિસેબલ કરો",
"download_csv": "CSV ડાઉનલોડ કરો",
"duplicate_detection_job_description": "સરખી ઈમેજો શોધવા માટે તમારા સંસાધનો પર મશીન લર્નિંગનો ઉપયોગ કરો. આ સુવિધા સ્માર્ટ સર્ચ પર આધારિત છે",
"exclusion_pattern_description": "તમારા સંગ્રહને સ્કેન કરતી વખતે એક્સક્લુઝન પેટર્ન તમને ફાઇલો અને ફોલ્ડર્સને અવગણવામાં મદદ કરે છે. જો તમારી પાસે એવી ફાઇલો ધરાવતા ફોલ્ડર્સ હોય જેને તમે ઈમ્પોર્ટ કરવા નથી માંગતા, જેમ કે RAW ફાઇલો, તો આ સુવિધા ઉપયોગી છે.",
"export_config_as_json_description": "વર્તમાન સિસ્ટમ કોન્ફિગને JSON ફાઇલ તરીકે ડાઉનલોડ કરો",
@@ -82,13 +80,6 @@
"face_detection_description": "મશીન લર્નિંગનો ઉપયોગ કરીને સંસાધનોમાં ચહેરાની પરખ કરો. વીડિયો માટે, ફક્ત થંબનેલ જ ધ્યાનમાં લેવામાં આવે છે. \"રિફ્રેશ\" બધા સંસાધનો પર ફરીથી પ્રક્રિયા કરે છે. \"રીસેટ\" વધારામાં ચહેરાના તમામ વર્તમાન ડેટાને સાફ કરે છે. \"ખૂટતા\" તેવા સંસાધનોને કતારમાં મૂકે છે જેના પર હજુ સુધી પ્રક્રિયા કરવામાં આવી નથી. ચહેરાની પરખ પૂર્ણ થયા પછી, શોધાયેલા ચહેરાઓને 'ચહેરાની ઓળખ' માટે કતારમાં મૂકવામાં આવશે, જે તેમને હાલની અથવા નવી વ્યક્તિઓના જૂથમાં વિભાજિત કરશે.",
"facial_recognition_job_description": "શોધાયેલા ચહેરાઓને વ્યક્તિઓના જૂથમાં વિભાજિત કરો. આ પગલું 'ચહેરાની પરખ' પૂર્ણ થયા પછી શરૂ થાય છે. \"રીસેટ\" બધા ચહેરાઓનું ફરીથી જૂથીકરણ કરે છે. \"ખૂટતા\" તેવા ચહેરાઓને કતારમાં મૂકે છે જેમને હજુ સુધી કોઈ વ્યક્તિ ફાળવવામાં આવી નથી.",
"failed_job_command": "આ કાર્ય માટે આદેશ {command} નિષ્ફળ રહ્યો: {job}",
"force_delete_user_warning": "ચેતવણી: આ પ્રક્રિયા તરત જ વપરાશકર્તા અને તમામ સંસાધનોને દૂર કરી દેશે. આ નિર્ણય બદલી શકાશે નહીં અને ફાઇલોને ફરીથી મેળવી શકાશે નહીં.",
"image_format": "ગોઠવણ",
"image_format_description": "WebP JPEG કરતા નાની ફાઇલો બનાવે છે, પરંતુ એન્કોડ કરવામાં ધીમી છે.",
"image_prefer_embedded_preview": "એમ્બેડેડ પૂર્વાવલોકન પસંદ કરો",
"image_prefer_wide_gamut": "પહોળા અંતરને પ્રાધાન્ય આપો",
"image_preview_quality_description": "પ્રીવ્યૂ ગુણવત્તા ૧-૧૦૦ સુધી. ઉચ્ચ વધુ સારું છે, પરંતુ તે મોટી ફાઇલો ઉત્પન્ન કરે છે અને એપ્લિકેશન પ્રતિભાવ ઘટાડી શકે છે. ઓછું મૂલ્ય સેટ કરવાથી મશીન લર્નિંગ ગુણવત્તા પર અસર પડી શકે છે.",
"image_preview_title": "પૂર્વાવલોકન સેટિંગ્સ",
"image_progressive": "પ્રગતિશીલ"
"force_delete_user_warning": "ચેતવણી: આ પ્રક્રિયા તરત જ વપરાશકર્તા અને તમામ સંસાધનોને દૂર કરી દેશે. આ નિર્ણય બદલી શકાશે નહીં અને ફાઇલોને ફરીથી મેળવી શકાશે નહીં."
}
}
+5 -13
View File
@@ -1306,8 +1306,7 @@
"map_location_picker_page_use_location": "השתמש במיקום הזה",
"map_location_service_disabled_content": "שירות המיקום צריך להיות מופעל כדי להציג תמונות מהמיקום הנוכחי שלך. האם ברצונך להפעיל אותו עכשיו?",
"map_location_service_disabled_title": "שירות מיקום מבוטל",
"map_marker_for_image": "מרקר מפה לתמונה ממיקום {city}, {country}",
"map_marker_with_image": "סמן מפה מתמונה",
"map_marker_with_image": "סמן מפה עם תמונה",
"map_no_location_permission_content": "יש צורך בהרשאה למיקום כדי להציג תמונות מהמיקום הנוכחי שלך. האם ברצונך לאפשר זאת עכשיו?",
"map_no_location_permission_title": "הרשאה למיקום נדחתה",
"map_settings": "הגדרות מפה",
@@ -1316,9 +1315,9 @@
"map_settings_date_range_option_days": "ב-{days} ימים אחרונים",
"map_settings_date_range_option_year": "שנה אחרונה",
"map_settings_date_range_option_years": "ב-{years} שנים אחרונות",
"map_settings_include_show_archived": "הכללת ארכיון",
"map_settings_include_show_partners": "הכללת שותפים",
"map_settings_only_show_favorites": "הצגת מועדפים בלבד",
"map_settings_include_show_archived": "כלול ארכיון",
"map_settings_include_show_partners": "כלול שותפים",
"map_settings_only_show_favorites": "הצג מועדפים בלבד",
"map_settings_theme_settings": "ערכת נושא למפה",
"mark_all_as_read": "סמן הכל כנקרא",
"marked_all_as_read": "כל ההתראות סומנו כנקראו",
@@ -1328,29 +1327,22 @@
"auto": "אוטומטי",
"captions": "כתוביות",
"captions_off": "כבוי",
"closed_captions": "כתוביות מובנות",
"decode_error": "שגיאת קידוד",
"disable_captions": "ביטול כתוביות",
"enable_captions": "אפשר כתוביות",
"enter_fullscreen_mode": "הפעלת מצב מסך מלא",
"exit_fullscreen_mode": "יציאה ממסך מלא",
"loop": "לולאה",
"media_error_description": "שגיאה במדיה עצדה את הניגון. המדיה אינה תקינה או שהדפדפן אינו תומך בפורמט.",
"media_loading": "טעינת מדיה",
"mute": "השתקה",
"network_error": "שגיאת רשת",
"network_error_description": "ההורדה נכשלה בגלל שגיאת רשת.",
"not_supported_error": "מקור לא נתמך",
"playback_rate": "דירוג ניגון",
"playback_rate_current": "דירוג נוכחי",
"playback_rate_value": "דירוג {playbackRate}",
"playback_time": "זמן ניגון",
"quality": "איכות",
"second": "שניה",
"seconds": "שניות",
"time_value_of_total_time": "{currentTime} מתוך {totalTime}",
"time_value_remaining": "נשאר {time}",
"unmute": "ביטול השתקה"
"time_value_of_total_time": "{currentTime} מתוך {totalTime}"
},
"media_type": "סוג מדיה",
"memories": "זכרונות",
+3 -3
View File
@@ -1303,9 +1303,9 @@
"login_form_failed_login": "Errore nel login, controlla l'URL del server e le credenziali (email e password)",
"login_form_handshake_exception": "Si è verificata un'eccezione di handshake con il server. Abilita il supporto del certificato self-signed nelle impostazioni se si utilizza questo tipo di certificato.",
"login_form_password_hint": "password",
"login_form_server_empty": "Inserisci URL del server",
"login_form_server_error": "Impossibile connettersi al server",
"login_has_been_disabled": "Il login è stato disabilitato",
"login_form_server_empty": "Inserisci URL del server.",
"login_form_server_error": "Non è possibile connettersi al server.",
"login_has_been_disabled": "Il login è stato disabilitato.",
"login_password_changed_error": "C'è stato un errore durante l'aggiornamento della password",
"login_password_changed_success": "Password aggiornata con successo",
"logout_all_device_confirmation": "Sei sicuro di volerti disconnettere da tutti i dispositivi?",
-10
View File
@@ -193,15 +193,6 @@
"maintenance_integrity_checksum_mismatch_description": "ディスク上のチェックサムが、Immichのデータベースに保存されているチェックサムと一致しないファイル。",
"maintenance_integrity_checksum_mismatch_job": "チェックサム不一致を検出する",
"maintenance_integrity_checksum_mismatch_refresh_job": "チェックサム不一致レポートを再生成",
"maintenance_integrity_missing_file": "欠落ファイル",
"maintenance_integrity_missing_file_description": "Immichがデータベースで追跡しているが、ファイルシステム上に存在しないファイル。",
"maintenance_integrity_missing_file_job": "欠落ファイルのチェック",
"maintenance_integrity_missing_file_refresh_job": "欠落ファイルレポートの更新",
"maintenance_integrity_report": "整合性レポート",
"maintenance_integrity_untracked_file": "未追跡ファイル",
"maintenance_integrity_untracked_file_description": "Immichのディレクトリ内に存在するが、Immichに記録がないファイル。",
"maintenance_integrity_untracked_file_job": "未追跡ファイルのチェック",
"maintenance_integrity_untracked_file_refresh_job": "追跡されていないファイルレポートを更新",
"maintenance_restore_backup": "バックアップを復元",
"maintenance_restore_backup_description": "現在のImmichは削除され、選択したバックアップから復元されます。続行前にバックアップが作成されます。",
"maintenance_restore_backup_different_version": "このバックアップは異なるバージョンのImmichにより作成されたものです!",
@@ -555,7 +546,6 @@
"asset_added_to_album": "アルバムに追加",
"asset_adding_to_album": "アルバムに追加しています…",
"asset_created": "項目が作成されました",
"asset_day_count": "{date}: {count, plural, one {# アセット} other {# アセット}}",
"asset_description_updated": "項目の説明文が更新されました",
"asset_hashing": "ハッシュ計算中…",
"asset_list_group_by_sub_title": "グループ分け",
+3 -7
View File
@@ -1303,9 +1303,9 @@
"login_form_failed_login": "Ocorreu um erro ao iniciar sessão, verifique o URL do servidor, o e-mail e a palavra-passe",
"login_form_handshake_exception": "Erro ao conectar com o servidor. Ative o suporte para certificados auto-assinados nas configurações se estiver utilizando um certificado auto-assinado.",
"login_form_password_hint": "Palavra-passe",
"login_form_server_empty": "Insira a URL do servidor",
"login_form_server_error": "Não foi possível ligar ao servidor",
"login_has_been_disabled": "O início de sessão foi desativado",
"login_form_server_empty": "Insira a URL do servidor.",
"login_form_server_error": "Não foi possível ligar ao servidor.",
"login_has_been_disabled": "O início de sessão foi desativado.",
"login_password_changed_error": "Ocorreu um erro ao atualizar a sua palavra-passe",
"login_password_changed_success": "Palavra-passe atualizada com sucesso",
"logout_all_device_confirmation": "Tem a certeza de que deseja terminar a sessão em todos os dispositivos?",
@@ -1356,7 +1356,6 @@
"map_location_picker_page_use_location": "Utilizar esta localização",
"map_location_service_disabled_content": "Serviço de localização precisa de estar ativado para mostrar recursos da localização atual. Deseja ativar agora?",
"map_location_service_disabled_title": "Serviço de localização desativado",
"map_marker_for_image": "Marcador do mapa para a foto tirada em {city}, {country}",
"map_marker_with_image": "Marcador de mapa com imagem",
"map_no_location_permission_content": "A permissão da localização é necessária para mostrar recursos da localização atual. Deseja conceder a permissão agora?",
"map_no_location_permission_title": "Permissão de localização foi negada",
@@ -1507,9 +1506,6 @@
"notes": "Notas",
"nothing_here_yet": "Ainda não existe nada aqui",
"notification_backup_reliability": "Ativar notificações para melhorar a fiabilidade da cópia de segurança em segundo plano",
"notification_enabled_list_tile_content": "O Immich utiliza notificações para cópias de segurança em segundo plano. Faça a gestão destas nas definições do seu dispositivo.",
"notification_enabled_list_tile_open_button": "Abrir Definições",
"notification_enabled_list_tile_title": "Notificações ativadas",
"notification_permission_dialog_content": "Para ativar as notificações, vá em Configurações e selecione permitir.",
"notification_permission_list_tile_content": "Conceder permissões para ativar notificações.",
"notification_permission_list_tile_enable_button": "Ativar notificações",
-4
View File
@@ -73,7 +73,6 @@
"cron_expression_description": "Setați intervalul de scanare folosind formatul cron. Pentru mai multe informații, consultați de ex. <link>Crontab Guru</link>",
"cron_expression_presets": "Presetări de expresie cron",
"disable_login": "Dezactivați autentificarea",
"download_csv": "Descarcă CSV",
"duplicate_detection_job_description": "Rulați învățarea automată pe materiale pentru a detecta imagini similare. Se bazează pe Căutare Inteligentă",
"exclusion_pattern_description": "Modelele de excludere vă permit să ignorați fișierele și folderele atunci când vă scanați biblioteca. Acest lucru este util dacă aveți foldere care conțin fișiere pe care nu doriți să le importați, cum ar fi fișierele RAW.",
"export_config_as_json_description": "Descărcați configurația actuală a sistemului ca fișier JSON",
@@ -183,12 +182,9 @@
"machine_learning_smart_search_enabled": "Activează căutarea inteligentă",
"machine_learning_smart_search_enabled_description": "Dacă este dezactivată, imaginile nu vor fi codificate pentru căutarea inteligentă.",
"machine_learning_url_description": "URL-ul serverului de învățare automată. Dacă sunt furnizate mai multe URL-uri, fiecare server va fi încercat pe rând, până când unul răspunde cu succes, în ordine de la primul până la ultimul. Serverele care nu răspund vor fi ignorate temporar până revin online.",
"maintenance_backup_management": "Gestionare backup",
"maintenance_delete_backup": "Sterge Backup",
"maintenance_delete_backup_description": "Acest fisier va fi sters permanent.",
"maintenance_delete_error": "Stergerea backup-ului nu a reusit.",
"maintenance_integrity_check": "Verifică",
"maintenance_integrity_check_all": "Verifică tot",
"maintenance_restore_backup": "Restaureaza Backup",
"maintenance_restore_backup_description": "Immich va fi șters si restaurat din backup-ul ales. Va fi creat un nou backup înainte de a continua.",
"maintenance_restore_backup_different_version": "Acest backup a fost creat folosind o versiune diferita de Immich!",
+1 -217
View File
@@ -183,23 +183,18 @@
"machine_learning_smart_search_enabled": "Aktivizo kërkimin inteligjent",
"machine_learning_smart_search_enabled_description": "Nëse çaktivizohet, imazhet nuk do të kodohen për kërkim inteligjent.",
"machine_learning_url_description": "URL-ja e serverit të të mësuarit automatik. Nëse jepet më shumë se një URL, secili server do të provohet një nga një derisa njëri të përgjigjet me sukses, në rend nga i pari tek i fundit. Serverët që nuk përgjigjen do të injorohen përkohësisht derisa të kthehen në linjë.",
"maintenance_backup_management": "Menaxhimi i kopjeve rezervë",
"maintenance_delete_backup": "Fshi kopjen rezervë",
"maintenance_delete_backup_description": "Ky skedar do të fshihet në mënyrë të pakthyeshme.",
"maintenance_delete_error": "Fshirja e kopjes rezervë dështoi.",
"maintenance_integrity_check": "Kontrollo",
"maintenance_integrity_check_all": "Kontrollo të gjitha",
"maintenance_integrity_checksum_mismatch": "Mospërputhje e shumës së kontrollit",
"maintenance_integrity_checksum_mismatch_description": "Skedarët për të cilët checksumi në disk nuk përputhet me checksumin që Immich ka ruajtur në bazën e të dhënave të tij.",
"maintenance_integrity_checksum_mismatch_job": "Kontrolloni për mospërputhje të shumës së kontrollit",
"maintenance_integrity_checksum_mismatch_refresh_job": "Rifresko raportet e mospërputhjes së shumës së kontrollit",
"maintenance_integrity_missing_file": "Skedarët që mungojnë",
"maintenance_integrity_missing_file_description": "Skedarët që Immich i ka gjurmuar në bazën e të dhënave të tij, por që nuk ekzistojnë në sistemin e skedarëve.",
"maintenance_integrity_missing_file_job": "Kontrolloni për skedarë që mungojnë",
"maintenance_integrity_missing_file_refresh_job": "Rifresko raportet e skedarëve që mungojnë",
"maintenance_integrity_report": "Raporti i Integritetit",
"maintenance_integrity_untracked_file": "Skedarët e Pagjurmuar",
"maintenance_integrity_untracked_file_description": "Skedarët në direktoritë e Immich-it për të cilët Immich nuk ka asnjë regjistër.",
"maintenance_integrity_untracked_file_job": "Kontrolloni për skedarë të pagjurmuar",
"maintenance_integrity_untracked_file_refresh_job": "Rifresko raportet e skedarëve të pagjurmuar",
"maintenance_restore_backup": "Rivendos rezervën",
@@ -323,219 +318,8 @@
"reset_settings_to_default": "Rivendos cilësimet në ato të parazgjedhura",
"reset_settings_to_recent_saved": "Rivendos cilësimet në cilësimet e ruajtura së fundmi",
"scanning_library": "Duke skanuar bibliotekën",
"search_jobs": "Kërko punë…",
"send_welcome_email": "Dërgo email mirëseardhjeje",
"server_external_domain_settings": "Domeni i jashtëm",
"server_external_domain_settings_description": "Domeni i përdorur për lidhjet e jashtme",
"server_public_users": "Përdoruesit publikë",
"server_public_users_description": "Të gjithë përdoruesit (emri dhe emaili) shfaqen kur shtohet një përdorues në albumet e përbashkëta. Kur është çaktivizuar, lista e përdoruesve do të jetë e disponueshme vetëm për përdoruesit admin.",
"server_settings": "Caktimet e serverit",
"server_settings_description": "Menaxho cilësimet e serverit",
"server_stats_page_description": "Faqja e statistikave të serverit administrativ",
"server_welcome_message": "Mesazh mirëseardhjeje",
"server_welcome_message_description": "Një mesazh që shfaqet në faqen e hyrjes.",
"settings_page_description": "Faqja e cilësimeve të administrimit",
"sidecar_job": "Metadata sidecar",
"sidecar_job_description": "Zbulo ose sinkronizo metadatat sidecar nga sistemi i skedarëve",
"slideshow_duration_description": "Numri i sekondave për me shfaqë çdo imazh",
"smart_search_job_description": "Ekzekuto mësim makine mbi asetet për me mbështetë kërkimin inteligjent",
"storage_template_date_time_description": "Markatimpi i krijimit të asetit përdoret për informacionin e datës dhe orës",
"storage_template_date_time_sample": "Koha e mostrës {date}",
"storage_template_enable_description": "Aktivizo motorin e shabllonit të ruajtjes",
"storage_template_hash_verification_enabled": "Verifikimi i hash-it asht aktivizuar",
"storage_template_hash_verification_enabled_description": "Aktivizon verifikimin e hash-it, mos e çaktivizo këtë nëse nuk je i sigurt për implikacionet",
"storage_template_migration": "Migrimi i shabllonit të ruajtjes",
"storage_template_migration_description": "Apliko <link>{template}</link> aktual te asetet e ngarkuara ma parë",
"storage_template_migration_info": "Shablloni i ruajtjes do t'i konvertojë të gjitha shtesat në shkronja të vogla. Ndryshimet e shabllonit do të aplikohen vetëm për asetet e reja. Për t'i aplikuar retroaktivisht te asetet e ngarkuara ma parë, ekzekuto <link>{job}</link>.",
"storage_template_migration_job": "Puna e migrimit të shabllonit të ruajtjes",
"storage_template_more_details": "Për ma shumë detaje rreth kësaj veçorie, referoju <template-link>Shabllonit të Ruajtjes</template-link> dhe <implications-link>implikacioneve</implications-link> të tij",
"storage_template_onboarding_description_v2": "Kur aktivizohet, kjo veçori do t'i organizojë automatikisht skedarët bazuar në një shabllon të definuar nga përdoruesi. Për ma shumë informacion, shih <link>dokumentacionin</link>.",
"storage_template_path_length": "Kufiri afërsisht i gjatësisë së shtegut: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "Shablloni i ruajtjes",
"storage_template_settings_description": "Menaxho strukturën e dosjeve dhe emrin e skedarit të asetit të ngarkuar",
"storage_template_user_label": "<code>{label}</code> asht Etiketa e Ruajtjes e përdoruesit",
"system_settings": "Cilësimet e sistemit",
"tag_cleanup_job": "Pastrimi i etiketave",
"template_email_available_tags": "Mund të përdorësh variablat e mëposhtme në shabllonin tënd: {tags}",
"template_email_if_empty": "Nëse shablloni asht bosh, do të përdoret emaili i parazgjedhur.",
"template_email_invite_album": "Shablloni i ftesës në album",
"template_email_preview": "Pamja paraprake",
"template_email_settings": "Shabllonet e emailit",
"template_email_update_album": "Shablloni i përditësimit të albumit",
"template_email_welcome": "Shablloni i emailit të mirëseardhjes",
"template_settings": "Shablonet e njoftimeve",
"template_settings_description": "Menaxho shablonet e personalizuara për njoftimet",
"theme_custom_css_settings": "CSS i personalizuar",
"theme_custom_css_settings_description": "Cascading Style Sheets lejojnë personalizimin e dizajnit të Immich.",
"theme_settings": "Cilësimet e temës",
"theme_settings_description": "Menaxho personalizimin e ndërfaqes web të Immich",
"thumbnail_generation_job": "Gjenero miniatura",
"thumbnail_generation_job_description": "Gjenero miniatura të mëdha, të vogla dhe të turbullta për çdo aset, si dhe miniatura për çdo person",
"transcoding_acceleration_api": "API-ja e përshpejtimit",
"transcoding_acceleration_api_description": "API-ja që do të ndërveprojë me pajisjen tënde për me përshpejtuar transkodimin. Ky cilësim asht 'best effort': do të kthehet te transkodimi softuerik nëse dështon. VP9 mund të funksionojë ose jo varësisht nga hardueri yt.",
"transcoding_acceleration_nvenc": "NVENC (kërkon GPU NVIDIA)",
"transcoding_acceleration_qsv": "Quick Sync (kërkon CPU Intel brez 7 ose ma të ri)",
"transcoding_acceleration_rkmpp": "RKMPP (vetëm në SOC-et Rockchip)",
"transcoding_acceleration_vaapi": "VAAPI",
"transcoding_accepted_audio_codecs": "Kodeckët e audios të pranuara",
"transcoding_accepted_audio_codecs_description": "Zgjidh cilët kodeckë audio nuk kanë nevojë të transkodihen. Përdoret vetëm për politika të caktuara transkodimi.",
"transcoding_accepted_containers": "Kontejnerët e pranuar",
"transcoding_accepted_containers_description": "Zgjidh cilët formate kontejneri nuk kanë nevojë të ripakohen në MP4. Përdoret vetëm për politika të caktuara transkodimi.",
"transcoding_accepted_video_codecs": "Kodeckët e videos të pranuara",
"transcoding_accepted_video_codecs_description": "Zgjidh cilët kodeckë video nuk kanë nevojë të transkodihen. Përdoret vetëm për politika të caktuara transkodimi.",
"transcoding_advanced_options_description": "Opsione që shumica e përdoruesve nuk kanë nevojë t'i ndryshojnë",
"transcoding_audio_codec": "Kodeku i audios",
"transcoding_audio_codec_description": "Opus asht opsioni me cilësi ma të lartë, por ka përputhshmëri ma të ulët me pajisje ose softuer të vjetër.",
"transcoding_bitrate_description": "Videot ma të larta se bitrate maksimal ose jo në format të pranuar",
"transcoding_codecs_learn_more": "Për me mësu ma shumë rreth terminologjisë këtu, referoju dokumentacionit FFmpeg për <h264-link>kodekun H.264</h264-link>, <hevc-link>kodekun HEVC</hevc-link> dhe <vp9-link>kodekun VP9</vp9-link>.",
"transcoding_constant_quality_mode": "Mënyra e cilësisë konstante",
"transcoding_constant_quality_mode_description": "ICQ asht ma e mirë se CQP, por disa pajisje të përshpejtimit harduerik nuk e mbështesin këtë mënyrë. Ky opsion do të preferojë mënyrën e specifikuar kur përdor kodim bazuar në cilësi. Injorohet nga NVENC pasi nuk mbështet ICQ.",
"transcoding_constant_rate_factor": "Faktori i normës konstante (-crf)",
"transcoding_constant_rate_factor_description": "Niveli i cilësisë së videos. Vlerat tipike janë 23 për H.264, 28 për HEVC, 31 për VP9 dhe 35 për AV1. Vlera ma e ulët asht ma e mirë, por prodhon skedarë ma të mëdhenj.",
"transcoding_disabled_description": "Mos transkodo asnjë video, mund të prish luajtjen në disa klientë",
"transcoding_encoding_options": "Opsionet e kodimit",
"transcoding_encoding_options_description": "Vendos kodeckë, rezolucion, cilësi dhe opsione të tjera për videot e koduara",
"transcoding_hardware_acceleration": "Përshpejtimi harduerik",
"transcoding_hardware_acceleration_description": "Eksperimental: transkodim ma i shpejtë por mund të ulë cilësinë me të njëjtin bitrate",
"transcoding_hardware_decoding": "Dekodimi harduerik",
"transcoding_hardware_decoding_setting_description": "Aktivizon përshpejtimin nga fillimi në fund në vend se të përshpejtojë vetëm kodimin. Mund të mos funksionojë me të gjitha videot.",
"transcoding_max_b_frames": "B-frames maksimale",
"transcoding_max_b_frames_description": "Vlerat ma të larta përmirësojnë efikasitetin e kompresimit, por ngadalësojnë kodimin. Mund të mos jetë i përputhshëm me përshpejtimin harduerik në pajisje të vjetra. 0 çaktivizonte B-frames, ndërsa -1 e vendos këtë vlerë automatikisht.",
"transcoding_max_bitrate": "Bitrate maksimal",
"transcoding_max_bitrate_description": "Vendosja e bitrate-it maksimal mund t'i bëjë madhësitë e skedarëve ma të parashikueshme me kosto të vogël në cilësi. Në 720p, vlerat tipike janë 2600 kbit/s për VP9 ose HEVC, ose 4500 kbit/s për H.264. Çaktivizohet nëse vendoset në 0. Kur nuk specifikohet njësia, supozohet k (kbit/s).",
"transcoding_max_keyframe_interval": "Intervali maksimal i keyframe",
"transcoding_max_keyframe_interval_description": "Vendos distancën maksimale të kornizave ndërmjet keyframe-ve. Vlerat ma të ulëta përkeqësojnë efikasitetin e kompresimit, por përmirësojnë kohët e kërkimit dhe mund të përmirësojnë cilësinë në skenat me lëvizje të shpejtë. 0 e vendos këtë vlerë automatikisht.",
"transcoding_optimal_description": "Videot ma të larta se rezolucioni i synuar ose jo në format të pranuar",
"transcoding_policy": "Politika e transkodimit",
"transcoding_policy_description": "Vendos kur do të transkodihet një video",
"transcoding_preferred_hardware_device": "Pajisja harduerike e preferuar",
"transcoding_preferred_hardware_device_description": "Aplikohet vetëm për VAAPI dhe QSV. Vendos nyjen dri të përdorur për transkodim harduerik.",
"transcoding_preset_preset": "Paravendosja (-preset)",
"transcoding_preset_preset_description": "Shpejtësia e kompresimit. Paravendosjet ma të ngadalta prodhojnë skedarë ma të vegjël dhe rrisin cilësinë kur synon një bitrate të caktuar. VP9 injoron shpejtësi mbi 'faster'.",
"transcoding_realtime": "Transkodimi në kohë reale [EKSPERIMENTAL]",
"transcoding_realtime_description": "Lejon transkodimin të kryhet në kohë reale ndërsa videoja transmetohet. Aktivizon ndërrimin e cilësisë, por mund të shkaktojë vonesë ma të lartë luajtjeje dhe ngecje varësisht nga kapacitetet e serverit.",
"transcoding_realtime_enabled": "Aktivizo transkodimin në kohë reale",
"transcoding_realtime_enabled_description": "Nëse çaktivizohet, serveri do të refuzojë me fillu sesione të reja transkodimi në kohë reale.",
"transcoding_reference_frames": "Kornizat referencë",
"transcoding_reference_frames_description": "Numri i kornizave për t'u referuar kur kompresohet një kornizë e dhënë. Vlerat ma të larta përmirësojnë efikasitetin e kompresimit, por ngadalësojnë kodimin. 0 e vendos këtë vlerë automatikisht.",
"transcoding_required_description": "Vetëm videot jo në format të pranuar",
"transcoding_settings": "Cilësimet e transkodimit të videos",
"transcoding_settings_description": "Menaxho cilat video të transkodihen dhe si t'i procesosh ato",
"transcoding_target_resolution": "Rezolucioni i synuar",
"transcoding_target_resolution_description": "Rezolucionet ma të larta mund të ruajnë ma shumë detaje por marrin ma shumë kohë për t'u koduar, kanë madhësi skedarësh ma të mëdha dhe mund të reduktojnë reagimin e aplikacionit.",
"transcoding_temporal_aq": "Temporal AQ",
"transcoding_temporal_aq_description": "Aplikohet vetëm për NVENC. Kuantizimi adaptiv temporal rrit cilësinë e skenave me shumë detaje dhe lëvizje të ulët. Mund të mos jetë i përputhshëm me pajisje të vjetra.",
"transcoding_threads": "Threads",
"transcoding_threads_description": "Vlerat ma të larta çojnë në kodim ma të shpejtë, por lënë ma pak hapësirë për serverin me procesuar detyra të tjera ndërsa asht aktiv. Kjo vlerë nuk duhet të jetë ma e madhe se numri i bërthamave CPU. Maksimizon përdorimin nëse vendoset në 0.",
"transcoding_tone_mapping": "Tone-mapping",
"transcoding_tone_mapping_description": "Përpiqet të ruajë pamjen e videove HDR kur konvertohen në SDR. Çdo algoritëm bën kompromise të ndryshme për ngjyrën, detajet dhe ndriçimin. Hable ruan detajet, Mobius ruan ngjyrën dhe Reinhard ruan ndriçimin.",
"transcoding_transcode_policy": "Politika e transkodimit",
"transcoding_transcode_policy_description": "Politika për kur duhet të transkodihet një video. Videot HDR dhe videot me format pikselësh tjetër se YUV 4:2:0 do të transkodihen gjithmonë (përveç nëse transkodimi asht çaktivizuar).",
"transcoding_two_pass_encoding": "Kodim me dy kalime",
"transcoding_two_pass_encoding_setting_description": "Transkodo në dy kalime për me prodhuar video ma mirë të koduara. Kur bitrate maksimal asht aktivizuar (i nevojshëm për të punuar me H.264 dhe HEVC), kjo mënyrë përdor një gamë bitrate bazuar në bitrate maksimal dhe injoron CRF. Për VP9, CRF mund të përdoret nëse bitrate maksimal asht çaktivizuar.",
"transcoding_video_codec": "Kodeku i videos",
"transcoding_video_codec_description": "VP9 ka efikasitet të lartë dhe përputhshmëri web, por merr ma shumë kohë për t'u transkoduar. HEVC funksionon ngjashëm, por ka përputhshmëri ma të ulët web. H.264 asht i përputhshëm gjerësisht dhe i shpejtë për t'u transkoduar, por prodhon skedarë shumë ma të mëdhenj. AV1 asht kodeku ma efikas por mungon mbështetja në pajisje të vjetra.",
"trash_enabled_description": "Aktivizo veçoritë e koshit",
"trash_number_of_days": "Numri i ditëve",
"trash_number_of_days_description": "Numri i ditëve për të mbajtur asetet në kosh para fshirjes përgjithmonë",
"trash_settings": "Cilësimet e koshit",
"trash_settings_description": "Menaxho cilësimet e koshit",
"unlink_all_oauth_accounts": "Shkyç të gjitha llogaritë OAuth",
"unlink_all_oauth_accounts_description": "Mos harro me shkyçë të gjitha llogaritë OAuth para migrimit te një ofrues i ri.",
"unlink_all_oauth_accounts_prompt": "A je i sigurt që dëshiron të shkyçësh të gjitha llogaritë OAuth? Kjo do të rivendosë OAuth ID-në për çdo përdorues dhe nuk mund të zhbëhet.",
"user_cleanup_job": "Pastrimi i përdoruesve",
"user_delete_delay": "Llogaria dhe asetet e <b>{user}</b> do të planifikohen për fshirje të përhershme pas {delay, plural, one {# ditë} other {# ditësh}}.",
"user_delete_delay_settings": "Vonesa e fshirjes",
"user_delete_delay_settings_description": "Numri i ditëve pas heqjes për me fshirë përgjithmonë llogarinë dhe asetet e një përdoruesi. Puna e fshirjes së përdoruesit ekzekutohet në mesnatë për të kontrolluar përdoruesit gati për fshirje. Ndryshimet e këtij cilësimi do të vlerësohen në ekzekutimin tjetër.",
"user_delete_immediately": "Llogaria dhe asetet e <b>{user}</b> do të radhiten për fshirje të përhershme <b>menjëherë</b>.",
"user_delete_immediately_checkbox": "Radho përdoruesin dhe asetet për fshirje të menjëhershme",
"user_details": "Detajet e përdoruesit",
"user_management": "Menaxhimi i përdoruesve",
"user_password_has_been_reset": "Fjalëkalimi i përdoruesit asht rivendosur:",
"user_password_reset_description": "Ju lutem jepja përdoruesit fjalëkalimin e përkohshëm dhe informoje se do të duhet ta ndryshojë fjalëkalimin në hyrjen e tij të ardhshme.",
"user_restore_description": "Llogaria e <b>{user}</b> do të restaurohet.",
"user_restore_scheduled_removal": "Restauro përdoruesin - heqja e planifikuar më {date, date, long}",
"user_settings": "Cilësimet e përdoruesit",
"user_settings_description": "Menaxho cilësimet e përdoruesit",
"user_successfully_removed": "Përdoruesi {email} u hoq me sukses.",
"users_page_description": "Faqja e administratorit për përdoruesit",
"version_check_channel": "Kanali i lëshimit",
"version_check_channel_description": "Zgjidh kanalin e lëshimit për të cilin dëshiron të marrësh njoftime versioni",
"version_check_enabled_description": "Aktivizo kontrollin e versionit",
"version_check_implications": "Veçoria e kontrollit të versionit mbështetet në komunikim periodik me {server}",
"version_check_settings": "Kontrolli i versionit",
"version_check_settings_description": "Aktivizo/çaktivizo njoftimin e versionit të ri",
"video_conversion_job": "Transkodo videot",
"video_conversion_job_description": "Transkodo videot për përputhshmëri ma të gjerë me shfletuesit dhe pajisjet"
"search_jobs": "Kërko punë…"
},
"admin_email": "Email i administratorit",
"admin_password": "Fjalëkalimi i administratorit",
"administration": "Administrimi",
"advanced": "Të avancuara",
"advanced_settings_clear_image_cache": "Pastro cache-in e imazheve",
"advanced_settings_clear_image_cache_error": "Dështoi pastrimi i cache-it të imazheve",
"advanced_settings_clear_image_cache_success": "U pastrua me sukses {size}",
"advanced_settings_log_level_title": "Niveli i regjistrit: {level}",
"advanced_settings_prefer_remote_subtitle": "Disa pajisje janë shumë të ngadalta për të ngarkuar miniatura nga asetet lokale. Aktivizo këtë cilësim për të ngarkuar imazhe nga distanca.",
"advanced_settings_prefer_remote_title": "Prefero imazhe nga distanca",
"advanced_settings_proxy_headers_subtitle": "Defino header-at proxy që Immich duhet t'i dërgojë me çdo kërkesë rrjeti",
"advanced_settings_proxy_headers_title": "Header-at e personalizuara proxy [EKSPERIMENTAL]",
"advanced_settings_readonly_mode_subtitle": "Aktivizon mënyrën vetëm-lexim ku fotot mund të shikohen vetëm, gjëra si zgjedhja e imazheve të shumta, ndarja, transmetimi, fshirja janë të çaktivizuara. Aktivizo/çaktivizo vetëm-lexim nëpërmjet avatarit të përdoruesit nga ekrani kryesor",
"advanced_settings_readonly_mode_title": "Mënyra vetëm-lexim",
"advanced_settings_sync_remote_deletions_subtitle": "Fshi ose restauro automatikisht një aset në këtë pajisje kur kjo veprim kryhet në web",
"advanced_settings_sync_remote_deletions_title": "Sinkronizo fshirjet nga distanca [EKSPERIMENTAL]",
"advanced_settings_tile_subtitle": "Cilësimet e avancuara të përdoruesit",
"advanced_settings_troubleshooting_subtitle": "Aktivizo veçori shtesë për zgjidhjen e problemeve",
"advanced_settings_troubleshooting_title": "Zgjidhja e problemeve",
"age_months": "Mosha {months, plural, one {# muaj} other {# muaj}}",
"age_year_months": "Mosha 1 vit, {months, plural, one {# muaj} other {# muaj}}",
"age_years": "{years, plural, other {Mosha #}}",
"album": "Album",
"album_added": "Albumi u shtua",
"album_added_notification_setting_description": "Merr njoftim me email kur shtohet në një album të ndarë",
"album_cover_updated": "Kopertina e albumit u përditësua",
"album_delete_confirmation": "A je i sigurt që dëshiron të fshish albumin {album}?",
"album_delete_confirmation_description": "Nëse ky album asht i ndarë, përdoruesit e tjerë nuk do të mund ta aksesojnë ma.",
"album_deleted": "Albumi u fshi",
"album_info_updated": "Informacioni i albumit u përditësua",
"album_name": "Emri i albumit",
"album_options": "Opsionet e albumit",
"album_remove_user": "Hiq përdoruesin?",
"album_remove_user_confirmation": "A je i sigurt që dëshiron të heqësh {user}?",
"album_search_not_found": "Nuk u gjet asnjë album që përputhet me kërkimin tënd",
"album_share_no_users": "Duket se e ke ndarë këtë album me të gjithë përdoruesit ose nuk ke asnjë përdorues me të cilin të ndash.",
"album_summary": "Përmbledhja e albumit",
"album_updated": "Albumi u përditësua",
"album_updated_setting_description": "Merr njoftim me email kur një album i ndarë ka asete të reja",
"album_upload_assets": "Ngarko asete nga kompjuteri yt dhe shto në album",
"album_viewer_appbar_share_err_delete": "Dështoi fshirja e albumit",
"album_viewer_page_share_add_users": "Shto përdorues",
"album_with_link_access": "Lejo këdo me lidhjen të shohë fotot dhe njerëzit në këtë album.",
"albums": "Albumet",
"albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albume}}",
"albums_default_sort_order": "Rendi i parazgjedhur i albumeve",
"albums_default_sort_order_description": "Rendi fillestar i aseteve kur krijohen albume të reja.",
"albums_feature_description": "Koleksione asetesh që mund të ndahen me përdoruesit e tjerë.",
"albums_on_device_count": "Albumet në pajisje ({count})",
"all": "Të gjithë",
"all_albums": "Të gjitha albumet",
"all_people": "Të gjithë njerëzit",
"all_photos": "Të gjitha fotot",
"all_videos": "Të gjitha videot",
"allow_dark_mode": "Lejo mënyrën e errët",
"allow_public_user_to_download": "Lejo përdoruesin publik të shkarkojë",
"allow_public_user_to_upload": "Lejo përdoruesin publik të ngarkojë",
"allowed": "I lejuar",
"alt_text_qr_code": "Imazh i kodit QR",
"always_keep": "Mbaj gjithmonë",
"always_keep_photos_hint": "Lirimi i hapësirës do t'i mbajë të gjitha fotot në këtë pajisje.",
"always_keep_videos_hint": "Lirimi i hapësirës do t'i mbajë të gjitha videot në këtë pajisje.",
"api_key": "Çelësi API",
"api_key_description": "Kjo vlerë do të tregohet vetëm një herë. Sigurohu me e kopjuar para se të mbyllësh dritaren.",
"api_key_empty": "Emri i çelësit API nuk duhet të jetë bosh",
"api_keys": "Çelësat API",
"app_architecture_variant": "Varianta (Arkitektura)",
"app_bar_signout_dialog_content": "A je i sigurt që dëshiron të dalësh?",
"download_original": "Shkarko origjinalin",
"download_paused": "Shkarkimi u pezullua",
"download_settings": "Shkarko",
+3 -6
View File
@@ -1303,9 +1303,9 @@
"login_form_failed_login": "Kunde inte logga in. Kontrollera serverns webbadress, email och lösenord",
"login_form_handshake_exception": "Ett Undantag vid Handskakning med servern har skett. Aktivera stöd för självsignerade certifikat i inställningar om du använder ett självsignerat certifikat.",
"login_form_password_hint": "lösenord",
"login_form_server_empty": "Ange en server-URL",
"login_form_server_error": "Kunde inte ansluta till servern",
"login_has_been_disabled": "Inloggning har blivit inaktiverat",
"login_form_server_empty": "Ange en server-URL.",
"login_form_server_error": "Kunde inte ansluta till servern.",
"login_has_been_disabled": "Inloggning har blivit inaktiverat.",
"login_password_changed_error": "Ett fel uppstod vid uppdatering av ditt lösenord",
"login_password_changed_success": "Uppdatering av lösenord lyckades",
"logout_all_device_confirmation": "Är du säker på att du vill logga ut från alla enheter?",
@@ -1507,9 +1507,6 @@
"notes": "Notera",
"nothing_here_yet": "Inget här ännu",
"notification_backup_reliability": "Aktivera aviseringar för att förbättra säkerhetskopieringen i bakgrunden",
"notification_enabled_list_tile_content": "Immich använde notiser för bakgrundssäkerhetskopiering. Hantera dem på din enhets inställningar.",
"notification_enabled_list_tile_open_button": "Öppna inställningar",
"notification_enabled_list_tile_title": "Notiser aktiverade",
"notification_permission_dialog_content": "För att aktivera notiser, gå till Inställningar och välj tillåt.",
"notification_permission_list_tile_content": "Tillåt rättighet för att slå på notiser.",
"notification_permission_list_tile_enable_button": "Aktivera Notiser",
+49 -49
View File
@@ -476,16 +476,16 @@
"advanced_settings_clear_image_cache_error": "Lỗi khi xóa bộ nhớ đệm",
"advanced_settings_clear_image_cache_success": "Đã giải phóng thành công {size}",
"advanced_settings_log_level_title": "Phân loại log: {level}",
"advanced_settings_prefer_remote_subtitle": "Một số thiết bị nạp ảnh thu nhỏ từ tài nguyên thiết bị rất chậm. Bật để nạp ảnh từ máy chủ.",
"advanced_settings_prefer_remote_subtitle": "Việc tải ảnh thu nhỏ từ tài nguyên trên một số thiết bị có thể diễn ra chậm. Kích hoạt cài đặt này để tải ảnh từ máy chủ.",
"advanced_settings_prefer_remote_title": "Ưu tiên ảnh từ máy chủ",
"advanced_settings_proxy_headers_subtitle": "Xác định các tiêu đề proxy Immich sẽ gửi kèm mỗi yêu cầu mạng",
"advanced_settings_proxy_headers_title": "Tùy chỉnh tiêu đề proxy [THỬ NGHIỆM]",
"advanced_settings_readonly_mode_subtitle": "Chỉ dùng ứng dụng để xem ảnh - các tính năng khác như chọn nhiều mục, chia sẻ, phát, xóa đều bị vô hiệu hóa. Nhấn giữ ảnh đại diện người dùng từ màn hình chính để bật/tắt chế độ.",
"advanced_settings_readonly_mode_subtitle": "Chế độ chỉ-xem chỉ cho phép xem ảnh, các tính năng như chọn nhiều ảnh, chia sẻ, phát, xóa đều bị vô hiệu hóa. Bật/Tắt chế độ chỉ-xem thông qua ảnh đại diện người dùng từ màn hình chính",
"advanced_settings_readonly_mode_title": "Chế độ chỉ-xem",
"advanced_settings_sync_remote_deletions_subtitle": "Tự động xóa hoặc khôi phục tài nguyên trên thiết bị này khi bạn thao tác trên web",
"advanced_settings_sync_remote_deletions_title": "Đồng bộ việc xóa từ thiết bị khác [THỬ NGHIỆM]",
"advanced_settings_tile_subtitle": "Dành cho người dùng nâng cao",
"advanced_settings_troubleshooting_subtitle": "Bật tính năng bổ sung để xử lý sự cố",
"advanced_settings_troubleshooting_subtitle": "Bật các tính năng bổ sung để xử lý sự cố",
"advanced_settings_troubleshooting_title": "Xử lý sự cố",
"age_months": "{months, plural, one {# tháng} other {# tháng}} tuổi",
"age_year_months": "1 tuổi, {months, plural, one {# tháng} other {# tháng}}",
@@ -544,7 +544,7 @@
"appears_in": "Xuất hiện trong",
"apply_count": "Áp dụng ({count, number})",
"archive": "Lưu trữ",
"archive_action_prompt": "Đã thêm ({count}) vào Lưu trữ",
"archive_action_prompt": "{count} đã được thêm vào Lưu trữ",
"archive_or_unarchive_photo": "Lưu trữ hoặc bỏ lưu trữ ảnh",
"archive_size": "Dung lượng tệp nén",
"archive_size_description": "Cấu hình dung lượng tệp nén để tải xuống (đơn vị GiB)",
@@ -553,7 +553,7 @@
"are_these_the_same_person": "Đây có phải cùng một người không?",
"are_you_sure_to_do_this": "Bạn có chắc muốn thực hiện điều này?",
"asset_added_to_album": "Đã thêm vào album",
"asset_adding_to_album": "Thêm vào album…",
"asset_adding_to_album": "Đang thêm vào album…",
"asset_created": "Đã tạo tài nguyên",
"asset_day_count": "{date}: {count, plural, one {# tài nguyên} other {# tài nguyên}}",
"asset_description_updated": "Mô tả tài nguyên đã được cập nhật",
@@ -630,7 +630,7 @@
"backup_info_card_assets": "tài nguyên",
"backup_options": "Tùy chọn sao lưu",
"backup_settings_subtitle": "Cài đặt việc tải lên",
"backward": "Lùi",
"backward": "Lùi lại",
"battery_optimization_backup_reliability": "Tắt tính năng tiết kiệm pin để đảm bảo quá trình sao lưu nền",
"biometric_auth_enabled": "Đã bật xác thực sinh trắc học",
"biometric_locked_out": "Bạn đã bị khóa xác thực bằng sinh trắc học",
@@ -684,11 +684,11 @@
"choose": "Chọn",
"choose_matching_people_to_merge": "Chọn những người trùng khớp để hợp nhất",
"city": "Thành phố",
"cleanup_confirm_description": "Immich tìm thấy {count} tài nguyên (được tạo ra trước {date}) được sao lưu trên máy chủ. Bạn có muốn xóa phiên bản của chúng lưu trên thiết bị này không?",
"cleanup_confirm_description": "Immich phát hiện {count} tài nguyên (được tạo ra trước {date}) được sao lưu trên máy chủ. Bạn có muốn xóa bản sao được lưu trên thiết bị này không?",
"cleanup_confirm_prompt_title": "Xóa khỏi thiết bị này?",
"cleanup_deleted_assets": "Đã chuyển {count} tài nguyên vào thùng rác",
"cleanup_deleting": "Đang chuyển vào thùng rác...",
"cleanup_found_assets": "Tìm thấy {count} tài nguyên đã sao lưu",
"cleanup_found_assets": "Phát hiện {count} tài nguyên được sao lưu",
"cleanup_found_assets_with_size": "Phát hiện {count} tài nguyên đã sao lưu ({size})",
"cleanup_icloud_shared_albums_excluded": "Những album được chia sẻ trên iCloud không nằm trong phạm vi quét",
"cleanup_no_assets_found": "Không tìm thấy tài nguyên nào phù hợp với điều kiện trên. Tính năng Giải phóng dung lượng chỉ có thể xóa các tài nguyên đã được sao lưu lên máy chủ",
@@ -744,9 +744,9 @@
"continue": "Tiếp tục",
"control_bottom_app_bar_add_tags": "Thêm thẻ",
"control_bottom_app_bar_delete_from_local": "Xóa khỏi thiết bị",
"control_bottom_app_bar_edit_location": "Sửa địa điểm",
"control_bottom_app_bar_edit_time": "Sửa ngày",
"control_bottom_app_bar_trash_from_immich": "Xóa",
"control_bottom_app_bar_edit_location": "Chỉnh sửa vị trí",
"control_bottom_app_bar_edit_time": "Chỉnh sửa Ngày và Giờ",
"control_bottom_app_bar_trash_from_immich": "Di chuyển vào Thùng rác",
"copied_image_to_clipboard": "Đã sao chép ảnh vào clipboard.",
"copied_to_clipboard": "Đã sao chép vào clipboard!",
"copy_error": "Sao chép lỗi",
@@ -791,11 +791,11 @@
"current_device": "Thiết bị hiện tại",
"current_pin_code": "Mã PIN hiện tại",
"current_server_address": "Địa chủ máy chủ hiện tại",
"custom_date": "Tùy chỉnh mốc",
"custom_date": "Thiết lập ngày tùy chỉnh",
"custom_locale": "Khu vực tùy chỉnh",
"custom_locale_description": "Định dạng ngày, thời gian và số dựa trên ngôn ngữ và khu vực đã chọn",
"custom_url": "URL tùy chỉnh",
"cutoff_date_description": "Giữ lại các ảnh t…",
"cutoff_date_description": "Giữ lại ảnh trong vòng…",
"cutoff_day": "{count, plural, one {ngày} other {ngày}}",
"cutoff_year": "{count, plural, one {năm} other {năm}}",
"dark": "Tối",
@@ -815,7 +815,7 @@
"default_share_quality": "Chất lượng chia sẻ mặc định",
"delete": "Xóa",
"delete_action_confirmation_message": "Bạn có chắc muốn xóa tài nguyên này? Tài nguyên sẽ được chuyển vào thùng rác của máy chủ và sẽ hỏi bạn có muốn xóa nó trên đó không",
"delete_action_prompt": "Đã xóa ({count})",
"delete_action_prompt": "{count} đã xóa",
"delete_album": "Xóa album",
"delete_api_key_prompt": "Bạn có chắc muốn xóa khóa API này?",
"delete_dialog_alert": "Những mục này sẽ bị xóa vĩnh viễn khỏi Immich và thiết bị của bạn",
@@ -827,12 +827,12 @@
"delete_key": "Xóa khóa",
"delete_library": "Xóa Thư viện",
"delete_link": "Xóa link",
"delete_local_action_prompt": "Đã xóa ({count}) trên thiết bị",
"delete_local_action_prompt": "{count} đã xóa trên thiết bị",
"delete_local_dialog_ok_backed_up_only": "Xóa ảnh đã sao lưu",
"delete_local_dialog_ok_force": "Vẫn xóa",
"delete_others": "Xóa ảnh còn lại",
"delete_permanently": "Xóa vĩnh viễn",
"delete_permanently_action_prompt": "Đã xóa vĩnh viễn ({count})",
"delete_permanently_action_prompt": "{count} đã xóa vĩnh viễn",
"delete_shared_link": "Xóa link đã chia sẻ",
"delete_shared_link_dialog_title": "Xóa link đã chia sẻ",
"delete_tag": "Xóa thẻ",
@@ -842,7 +842,7 @@
"description": "Mô tả",
"deselect_all": "Bỏ chọn tất cả",
"details": "Chi tiết",
"direction": "Điều hướng",
"direction": "Hướng",
"disable": "Vô hiệu hóa",
"disabled": "Đã tắt",
"discord": "Discord",
@@ -887,7 +887,7 @@
"edit_birthday": "Sửa ngày sinh",
"edit_date": "Chỉnh sửa ngày",
"edit_date_and_time": "Chỉnh sửa ngày và giờ",
"edit_date_and_time_action_prompt": "Đã sửa ngày và giờ ({count})",
"edit_date_and_time_action_prompt": "{count} đã sửa ngày và giờ",
"edit_date_and_time_by_offset": "Thay đổi ngày theo độ lệch",
"edit_description": "Chỉnh sửa mô tả",
"edit_exclusion_pattern": "Chỉnh sửa quy tắc loại trừ",
@@ -895,7 +895,7 @@
"edit_key": "Chỉnh sửa khóa",
"edit_link": "Chỉnh sửa link",
"edit_location": "Chỉnh sửa vị trí",
"edit_location_action_prompt": "Đã sửa địa điểm ({count})",
"edit_location_action_prompt": "{count} đã sửa vị trí",
"edit_location_dialog_title": "Vị trí",
"edit_name": "Chỉnh sửa tên",
"edit_people": "Chỉnh sửa người",
@@ -930,7 +930,7 @@
"enqueued": "Đã xếp hàng",
"enter_wifi_name": "Nhập tên Wi-Fi",
"enter_your_pin_code": "Nhập mã PIN của bạn",
"enter_your_pin_code_subtitle": "Nhập mã PIN của bạn để truy cập thư mục Bí mật",
"enter_your_pin_code_subtitle": "Nhập mã PIN của bạn để truy cập thư mục Khóa",
"error": "Lỗi",
"error_delete_face": "Lỗi khi xóa khuôn mặt khỏi tài nguyên",
"error_loading_albums": "Xảy ra lỗi khi tải các album",
@@ -1099,7 +1099,7 @@
"failed_to_load_assets": "Không tải được tài nguyên",
"failed_to_load_folder": "Không tải được thư mục",
"favorite": "Thích",
"favorite_action_prompt": "Đã thêm ({count}) vào Yêu thích",
"favorite_action_prompt": "{count} đã thêm vào Yêu thích",
"favorite_or_unfavorite_photo": "Thích hoặc bỏ thích ảnh",
"favorites": "Yêu thích",
"feature_photo_updated": "Đã cập nhật ảnh nổi bật",
@@ -1123,10 +1123,10 @@
"folders": "Thư mục",
"folders_feature_description": "Duyệt ảnh và video theo thư mục trên hệ thống tệp",
"forgot_pin_code_question": "Quên mã PIN?",
"forward": "Tiến",
"forward": "Tiến tới",
"free_up_space": "Giải phóng dung lượng",
"free_up_space_description": "Chuyển các hình ảnh và video đã được sao lưu vào thùng rác thiết bị. Bản sao lưu trên máy chủ không bị ảnh hưởng.",
"free_up_space_settings_subtitle": "Tiết kiệm bộ nhớ thiết bị",
"free_up_space_description": "Chuyển hình ảnh và video đã được sao lưu vào thùng rác của thiết bị để giải phóng dung lượng. Bản sao lưu trên máy chủ không bị ảnh hưởng.",
"free_up_space_settings_subtitle": "Tiết kiệm dung lượng bộ nhớ thiết bị",
"full_path": "Đường dẫn đầy đủ: {path}",
"full_path_or_folder": "Đường dẫn đầy đủ hoặc thư mục",
"gcast_enabled": "Google Cast",
@@ -1215,13 +1215,13 @@
"items_count": "{count, plural, one {# mục} other {# mục}}",
"jobs": "Tác vụ",
"keep": "Giữ",
"keep_albums": "Giữ lại các album",
"keep_albums": "Giữ lại các tập ảnh",
"keep_albums_count": "Giữ lại {count} {count, plural, one {album} other {album}}",
"keep_all": "Giữ tất cả",
"keep_description": "Chọn giữ lại những gì trên thiết bị khi giải phóng dung lượng.",
"keep_favorites": "Giữ lại các mục yêu thích",
"keep_on_device": "Giữ lại trên thiết bị",
"keep_on_device_hint": "Chọn các mục sẽ không xóa",
"keep_on_device_hint": "Chọn các tệp sẽ được giữ lại trên thiết bị",
"keep_this_delete_others": "Giữ tệp này, xóa các tệp khác",
"keeping": "Giữ lại: {items}",
"kept_this_deleted_others": "Đã giữ lại tài nguyên này và xóa {count, plural, one {# tài nguyên} other {# tài nguyên}}",
@@ -1281,7 +1281,7 @@
"location_picker_longitude_error": "Nhập kinh độ hợp lệ",
"location_picker_longitude_hint": "Nhập kinh độ của bạn",
"lock": "Khóa",
"locked_folder": "Bí mật",
"locked_folder": "Khóa",
"log_detail_title": "Chi tiết log",
"log_out": "Đăng xuất",
"log_out_all_devices": "Đăng xuất tất cả thiết bị",
@@ -1312,7 +1312,7 @@
"logout_this_device_confirmation": "Bạn có chắc muốn đăng xuất thiết bị này?",
"logs": "Log",
"longitude": "Kinh độ",
"look": "Hiển thị",
"look": "Xem",
"loop_videos": "Lặp video",
"loop_videos_description": "Bật để video tự động lặp lại trong trình xem chi tiết.",
"main_branch_warning": "Bạn đang dùng phiên bản đang phát triển; chúng tôi khuyên bạn nên dùng phiên bản phát hành!",
@@ -1344,7 +1344,7 @@
"manage_media_access_rationale": "Để có thể di chuyển tài nguyên vào thùng rác và khôi phục chúng từ đó.",
"manage_media_access_settings": "Mở cài đặt",
"manage_media_access_subtitle": "Cho phép ứng dụng [Immich] quản lý và di chuyển tệp.",
"manage_media_access_title": "Quản lý media",
"manage_media_access_title": "Quản lý phương tiện",
"manage_sharing_with_partners": "Quản lý chia sẻ với người thân",
"manage_the_app_settings": "Quản lý cài đặt ứng dụng",
"manage_your_account": "Quản lý tài khoản của bạn",
@@ -1439,9 +1439,9 @@
"move_off_locked_folder": "Di chuyển ra khỏi thư mục Khóa",
"move_to": "Di chuyển đến",
"move_to_device_trash": "Chuyển vào thùng rác thiết bị",
"move_to_lock_folder_action_prompt": "Đã thêm {count} vào thư mục Bí mật",
"move_to_locked_folder": "Di chuyển đến thư mục Bí mật",
"move_to_locked_folder_confirmation": "Ảnh và video này sẽ bị xóa khỏi mọi album chỉ có thể xem được trong thư mục Bí mật",
"move_to_lock_folder_action_prompt": "{count} đã được thêm vào thư mục Khóa",
"move_to_locked_folder": "Di chuyển đến thư mục Khóa",
"move_to_locked_folder_confirmation": "Ảnh và video này sẽ bị xóa khỏi các album, chỉ có thể xem được trong thư mục Khóa",
"moved_to_trash": "Đã chuyển vào thùng rác",
"mute_memories": "Tắt tiếng Kỷ niệm",
"my_albums": "Album của tôi",
@@ -1696,8 +1696,8 @@
"reassing_hint": "Gán các tài nguyên đã chọn cho một người hiện có",
"recent": "Gần đây",
"recent_searches": "Tìm kiếm gần đây",
"recently_added": "Thêm gần đây",
"recently_added_page_title": "Thêm gần đây",
"recently_added": "Được thêm gần đây",
"recently_added_page_title": "Được thêm gần đây",
"recently_taken": "Chụp gần đây",
"refresh": "Làm mới",
"refresh_encoded_videos": "Làm mới video đã mã hóa",
@@ -1719,11 +1719,11 @@
"remove_custom_date_range": "Bỏ chọn khoảng ngày tùy chỉnh",
"remove_filter": "Xóa bộ lọc",
"remove_from_album": "Xóa khỏi album",
"remove_from_album_action_prompt": "Đã gỡ ({count}) khỏi album",
"remove_from_album_action_prompt": "{count} đã gỡ khỏi album",
"remove_from_favorites": "Xóa khỏi mục Yêu thích",
"remove_from_lock_folder_action_prompt": "Đã xóa {count} khỏi thư mục Bí mật",
"remove_from_locked_folder": "Xóa khỏi thư mục Bí mật",
"remove_from_locked_folder_confirmation": "Bạn có chắc muốn di chuyển ảnh và video này khỏi thư mục Bí mật? Chúng sẽ hiện trong thư viện của bạn.",
"remove_from_lock_folder_action_prompt": "{count} đã được xóa khỏi thư mục Khóa",
"remove_from_locked_folder": "Xóa khỏi thư mục Khóa",
"remove_from_locked_folder_confirmation": "Bạn có chắc muốn di chuyển ảnh và video này khỏi thư mục Khóa? Chúng sẽ hiện trong thư viện của bạn.",
"remove_from_shared_link": "Xóa khỏi link chia sẻ",
"remove_memory": "Xóa kỷ niệm",
"remove_photo_from_memory": "Xóa ảnh khỏi kỷ niệm này",
@@ -1842,7 +1842,7 @@
"select_all_duplicates": "Chọn tất cả các bản trùng lặp",
"select_avatar_color": "Chọn màu ảnh đại diện",
"select_count": "{count, plural, one {Chọn #} other {Chọn #}}",
"select_cutoff_date": "Chọn mốc thời gian",
"select_cutoff_date": "Chọn giới hạn thời gian",
"select_face": "Chọn khuôn mặt",
"select_featured_photo": "Chọn ảnh nổi bật",
"select_from_computer": "Chọn từ máy tính",
@@ -1901,7 +1901,7 @@
"setup_pin_code": "Thiết lập mã PIN",
"share": "Chia sẻ",
"share_dialog_preparing": "Đang xử lý...",
"share_link": "Chia sẻ link",
"share_link": "Link chia sẻ",
"share_original": "Chất lượng gốc (lớn)",
"share_preview": "Dùng ảnh thu nhỏ (nhỏ)",
"shared": "Đã chia sẻ",
@@ -1915,7 +1915,7 @@
"shared_by_you": "Được chia sẻ bởi bạn",
"shared_from_partner": "Ảnh từ {partner}",
"shared_intent_upload_button_progress_text": "{current} / {total} Đã tải lên",
"shared_link_app_bar_title": "Những link đã chia sẻ",
"shared_link_app_bar_title": "Liên kết đã chia sẻ",
"shared_link_clipboard_copied_massage": "Đã sao chép vào clipboard",
"shared_link_create_error": "Tạo liên kết chia sẻ không thành công",
"shared_link_custom_url_description": "Truy cập link đã chia sẻ với một URL tùy chỉnh",
@@ -1945,7 +1945,7 @@
"shared_link_manage_links": "Quản lý các link đã chia sẻ",
"shared_link_options": "Tùy chọn chia sẻ link",
"shared_link_password_description": "Bắt buộc để truy cập link chia sẻ này",
"shared_links": "Đã chia sẻ",
"shared_links": "Chia sẻ link",
"shared_links_description": "Chia sẻ ảnh và video bằng link",
"shared_photos_and_videos_count": "{assetCount, plural, other {# ảnh & video đã chia sẻ.}}",
"shared_with_me": "Chia sẻ với tôi",
@@ -1992,7 +1992,7 @@
"slideshow_metadata_overlay_mode": "Lớp phủ nội dung",
"slideshow_metadata_overlay_mode_description_only": "Chỉ mô tả",
"slideshow_metadata_overlay_mode_full": "Đầy đủ",
"slideshow_repeat": "Lặp lại trình chiếu",
"slideshow_repeat": "Trình chiếu lại",
"slideshow_repeat_description": "Phát lại từ đầu khi trình chiếu kết thúc",
"slideshow_settings": "Cài đặt trình chiếu",
"smart_album": "Album thông minh",
@@ -2007,7 +2007,7 @@
"sort_title": "Tiêu đề",
"source": "Mã nguồn",
"stack": "Nhóm ảnh",
"stack_action_prompt": "Đã nhóm ({count}) tương tự",
"stack_action_prompt": "{count} đã được nhóm",
"stack_duplicates": "Nhóm mục trùng lặp",
"stack_selected_photos": "Nhóm các ảnh đã chọn",
"stacked_assets_count": "Đã nhóm {count, plural, one {# tài nguyên} other {# tài nguyên}}",
@@ -2092,7 +2092,7 @@
"total": "Tổng cộng",
"total_usage": "Dung lượng đã dùng",
"trash": "Thùng rác",
"trash_action_prompt": "Đã chuyển ({count}) vào thùng rác",
"trash_action_prompt": "{count} đã chuyển vào thùng rác",
"trash_all": "Xóa hết",
"trash_count": "Xóa {count, number} mục",
"trash_delete_asset": "Chuyển vào thùng rác/Xóa tài nguyên",
@@ -2113,11 +2113,11 @@
"unable_to_check_version": "Không thể kiểm tra phiên bản ứng dụng hoặc máy chủ",
"unable_to_setup_pin_code": "Thiết lập mã PIN thất bại",
"unarchive": "Bỏ lưu trữ",
"unarchive_action_prompt": "Đã bỏ lưu trữ ({count})",
"unarchive_action_prompt": "{count} đã bỏ khỏi Lưu trữ",
"unarchived_count": "{count, plural, other {Đã bỏ lưu trữ # mục}}",
"undo": "Hoàn tác",
"unfavorite": "Bỏ thích",
"unfavorite_action_prompt": "Đã bỏ yêu thích ({count})",
"unfavorite_action_prompt": "{count} đã bỏ khỏi Yêu thích",
"unhide_person": "Hiện người",
"unknown": "Không xác định",
"unknown_country": "Quốc gia chưa rõ",
@@ -2135,7 +2135,7 @@
"unselect_all": "Bỏ chọn tất cả",
"unselect_all_duplicates": "Bỏ chọn tất cả các bản trùng lặp",
"unstack": "Hủy xếp nhóm",
"unstack_action_prompt": "Đã bỏ tương tự ({count})",
"unstack_action_prompt": "{count} đã bỏ nhóm",
"unstacked_assets_count": "Đã hủy xếp nhóm {count, plural, one {# tài nguyên} other {# tài nguyên}}",
"unsupported_field_type": "Loại trường không được hỗ trợ",
"unsupported_file_type": "Tệp {file} không thể được tải lên vì loại tệp {type} không được hỗ trợ.",
@@ -2151,7 +2151,7 @@
"upload_error_with_count": "Không thể tải lên {count, plural, one {# tài nguyên} other {# tài nguyên}}",
"upload_errors": "Đã hoàn tất tải lên với {count, plural, one {# lỗi} other {# lỗi}}, làm mới trang để xem các tài nguyên mới tải lên.",
"upload_finished": "Đã hoàn tất tải lên",
"upload_progress": "Còn {remaining, number} - Đã xử lý {processed, number}/{total, number}",
"upload_progress": "Còn lại {remaining, number} - Đã xử lý {processed, number}/{total, number}",
"upload_skipped_duplicates": "Đã bỏ qua {count, plural, one {# tài nguyên trùng lặp} other {# tài nguyên trùng lặp}}",
"upload_status_duplicates": "Trùng lặp",
"upload_status_errors": "Lỗi",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "3.0.0"
version = "3.0.0rc4"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -974,7 +974,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "3.0.0"
version = "3.0.0rc4"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
-1
View File
@@ -7,7 +7,6 @@ project(native_buffer LANGUAGES C)
add_library(native_buffer SHARED
src/main/cpp/native_buffer.c
src/main/cpp/native_image.c
)
target_link_libraries(native_buffer jnigraphics)
@@ -1,109 +0,0 @@
#include <jni.h>
#include <stdlib.h>
#include <stdint.h>
#include <android/bitmap.h>
// Cache-friendly block size for the tiled rotation (in pixels). 32x32 uint32 = 4KB, fits L1.
#define TILE 32
// EXIF orientation values (androidx.exifinterface.media.ExifInterface.ORIENTATION_*).
enum {
ORIENTATION_FLIP_HORIZONTAL = 2,
ORIENTATION_ROTATE_180 = 3,
ORIENTATION_FLIP_VERTICAL = 4,
ORIENTATION_TRANSPOSE = 5,
ORIENTATION_ROTATE_90 = 6,
ORIENTATION_TRANSVERSE = 7,
ORIENTATION_ROTATE_270 = 8,
};
// The orientations that swap width and height. Must stay in sync with affine_for's dim usage.
static int swaps_dims(int o) {
return o == ORIENTATION_ROTATE_90 || o == ORIENTATION_ROTATE_270 ||
o == ORIENTATION_TRANSPOSE || o == ORIENTATION_TRANSVERSE;
}
// A source pixel (sx, sy) maps to destination index base + sx*stepX + sy*stepY, where dw is the
// destination width. This affine form covers all 8 EXIF orientations and matches the pixel layout
// of Bitmap.createBitmap(src, matrixForExifOrientation(o)). int64_t so it stays correct on
// armeabi-v7a (32-bit long) regardless of how large MAX_RAW_DECODE_PIXELS grows.
static void affine_for(int o, int sw, int sh, int dw, int64_t *base, int64_t *stepX, int64_t *stepY) {
switch (o) {
case ORIENTATION_ROTATE_90: *base = sh - 1; *stepX = dw; *stepY = -1; break;
case ORIENTATION_ROTATE_270: *base = (int64_t) (sw - 1) * dw; *stepX = -dw; *stepY = 1; break;
case ORIENTATION_ROTATE_180: *base = (int64_t) (sh - 1) * dw + (sw - 1); *stepX = -1; *stepY = -dw; break;
case ORIENTATION_FLIP_HORIZONTAL: *base = sw - 1; *stepX = -1; *stepY = dw; break;
case ORIENTATION_FLIP_VERTICAL: *base = (int64_t) (sh - 1) * dw; *stepX = 1; *stepY = -dw; break;
case ORIENTATION_TRANSPOSE: *base = 0; *stepX = dw; *stepY = 1; break;
case ORIENTATION_TRANSVERSE: *base = (int64_t) (sw - 1) * dw + (sh - 1); *stepX = -dw; *stepY = -1; break;
default: *base = 0; *stepX = 1; *stepY = dw; break;
}
}
// Copy each source pixel (whole uint32, so channel order/premult is irrelevant) to its rotated
// destination, walking TILE x TILE blocks so the scattered writes of a 90/270 transpose stay
// cache-resident. dst is densely packed (rowBytes == dw*4, no padding), which the affine math relies on.
static void rotate_tiled(const uint8_t *src, int srcStride, uint32_t *dst,
int sw, int sh, int64_t base, int64_t stepX, int64_t stepY) {
for (int ty = 0; ty < sh; ty += TILE) {
int yEnd = ty + TILE < sh ? ty + TILE : sh;
for (int tx = 0; tx < sw; tx += TILE) {
int xEnd = tx + TILE < sw ? tx + TILE : sw;
for (int sy = ty; sy < yEnd; sy++) {
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) sy * srcStride);
int64_t idx = base + (int64_t) sy * stepY + (int64_t) tx * stepX;
for (int sx = tx; sx < xEnd; sx++) {
dst[idx] = srcRow[sx];
idx += stepX;
}
}
}
}
}
// Rotates an RGBA_8888 bitmap to the given EXIF orientation into a freshly malloc'd buffer (free it
// via NativeBuffer.free). Fills outInfo with {width, height, rowBytes} and returns the buffer
// address, or 0 if the bitmap can't be handled (e.g. a non-8888 format) so the caller can fall back.
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeImage_rotate(
JNIEnv *env, jclass clazz, jobject bitmap, jint orientation, jintArray outInfo) {
AndroidBitmapInfo info;
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
return 0;
}
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
return 0;
}
int sw = (int) info.width;
int sh = (int) info.height;
int dw = swaps_dims(orientation) ? sh : sw;
int dh = swaps_dims(orientation) ? sw : sh;
uint32_t *dst = (uint32_t *) malloc((size_t) dw * dh * 4);
if (dst == NULL) {
return 0;
}
void *srcPixels = NULL;
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
free(dst);
return 0;
}
int64_t base, stepX, stepY;
affine_for(orientation, sw, sh, dw, &base, &stepX, &stepY);
rotate_tiled((const uint8_t *) srcPixels, (int) info.stride, dst, sw, sh, base, stepX, stepY);
AndroidBitmap_unlockPixels(env, bitmap);
jint dims[3] = {dw, dh, dw * 4};
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
// Keep ownership in C until the buffer is safely handed back: if outInfo was somehow too small,
// SetIntArrayRegion left a pending exception and Kotlin will never receive (or free) dst.
if ((*env)->ExceptionCheck(env)) {
free(dst);
return 0;
}
return (jlong) dst;
}
@@ -1,19 +0,0 @@
package app.alextran.immich
import android.graphics.Bitmap
object NativeImage {
init {
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
System.loadLibrary("native_buffer")
}
/**
* Rotates an RGBA_8888 [bitmap] to the given EXIF [orientation], writing the result into a freshly
* malloc'd native buffer. Returns the buffer address (free it with [NativeBuffer.free]) and fills
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap can't be handled (e.g. a
* non-8888 config) so the caller can fall back.
*/
@JvmStatic
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
}
@@ -12,9 +12,7 @@ import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.util.Size
import androidx.annotation.RequiresApi
import androidx.exifinterface.media.ExifInterface
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeImage
import kotlin.math.*
import java.io.IOException
import java.util.concurrent.Executors
@@ -183,88 +181,35 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
val id = assetId.toLong()
signal.throwIfCanceled()
val bitmap = if (isVideo) {
decodeVideoThumbnail(id, size, signal)
} else {
decodeImage(id, size, signal)
}
try {
val res = if (isVideo) {
decodeVideoThumbnail(id, size, signal).toNativeBuffer()
} else {
val (bitmap, orientation) = decodeImage(id, size, signal)
signal.throwIfCanceled()
if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) {
bitmap.toNativeBuffer()
} else {
rotateToNativeBuffer(bitmap, orientation, signal)
}
}
// Don't re-check cancellation here: res owns a malloc'd buffer, and bailing to CANCELLED would
// orphan it. Deliver it; Dart frees the buffer itself if the request was cancelled meanwhile.
signal.throwIfCanceled()
val res = bitmap.toNativeBuffer()
signal.throwIfCanceled()
callback(Result.success(res))
} catch (e: Exception) {
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
}
}
// Returns the decoded bitmap plus the EXIF orientation that still needs applying. Only Q+ raw
// decodes come back unrotated (ImageDecoder / loadThumbnail skip EXIF for raw like DNG); every
// other path already orients itself, so it reports ORIENTATION_NORMAL.
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Pair<Bitmap, Int> {
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Bitmap {
signal.throwIfCanceled()
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
val handleRaw = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isRawMime(uri)
val orientation = if (handleRaw) rawOrientation(uri) else ExifInterface.ORIENTATION_NORMAL
if (size.width <= 0 || size.height <= 0 || size.width > 768 || size.height > 768) {
// A "load original" request is unsized -> a full-res decode (a sized > 768 just samples to target).
return decodeSource(uri, size, signal) to orientation
return decodeSource(uri, size, signal)
}
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolver.loadThumbnail(uri, size, signal)
} else {
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
}
return bitmap to orientation
}
private fun isRawMime(uri: Uri): Boolean {
val mime = resolver.getType(uri) ?: return false
return mime.startsWith("image/x-") || mime == "image/dng"
}
private fun rawOrientation(uri: Uri): Int {
return resolver.openInputStream(uri)?.use {
ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
} ?: ExifInterface.ORIENTATION_NORMAL
}
// ImageDecoder / loadThumbnail skip EXIF orientation for raw (e.g. DNG) on Q+, so the decoded
// bitmap comes back unrotated. Rotate it into the output buffer in native code (one pass, no
// intermediate rotated bitmap).
private fun rotateToNativeBuffer(bitmap: Bitmap, orientation: Int, signal: CancellationSignal): Map<String, Long> {
signal.throwIfCanceled()
// Force ARGB_8888: the native rotate needs a lockable 8888 buffer, and toNativeBuffer() below
// allocates width*height*4 (an F16/HDR decode would otherwise under-allocate). No-op for the
// common already-8888 case.
val src = if (bitmap.config != Bitmap.Config.ARGB_8888) {
val converted = bitmap.copy(Bitmap.Config.ARGB_8888, false)
bitmap.recycle()
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
} else {
bitmap
}
try {
val info = IntArray(3)
val pointer = NativeImage.rotate(src, orientation, info)
if (pointer == 0L) throw IOException("native rotate failed for orientation $orientation")
return mapOf(
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
} finally {
if (!src.isRecycled) src.recycle()
}
}
private fun decodeVideoThumbnail(id: Long, target: Size, signal: CancellationSignal): Bitmap {
+4 -4
View File
@@ -22,8 +22,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3053,
"android.injected.version.name" => "3.0.0",
"android.injected.version.code" => 3052,
"android.injected.version.name" => "3.0.0-rc.4",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3053,
"android.injected.version.name" => "3.0.0",
"android.injected.version.code" => 3052,
"android.injected.version.name" => "3.0.0-rc.4",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,33 @@
// Plumbing check: proves immich_native_core is usable from the real immich app on
// a real device/sim the build-hook compiled the Rust for this target, the code
// asset bundled into the app, and the @Native symbols resolve at runtime.
// Self-contained: does NOT boot the immich app or need a server.
//
// Run: flutter test integration_test/native_core_test.dart -d <device>
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_native_core/immich_native_core.dart';
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
test('native core loads: coreVersion is non-empty', () {
expect(coreVersion(), isNotEmpty);
});
test('sha1Hex matches the FIPS-180 vector', () {
expect(
sha1Hex(Uint8List.fromList(utf8.encode('abc'))),
'a9993e364706816aba3e25717850c26c9cd0d89d',
);
});
test('rotateRgba8888 (the PR #29337 algorithm) rotates 180', () {
// 2x1: red, green -> green, red
final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]);
expect(rotateRgba8888(src, 8, 2, 1, 3), [0, 255, 0, 255, 255, 0, 0, 255]);
});
}
@@ -4,7 +4,6 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/album_config.dart';
import 'package:immich_mobile/domain/models/config/backup_config.dart';
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
import 'package:immich_mobile/domain/models/config/feature_message_config.dart';
import 'package:immich_mobile/domain/models/config/image_config.dart';
import 'package:immich_mobile/domain/models/config/map_config.dart';
import 'package:immich_mobile/domain/models/config/network_config.dart';
@@ -17,7 +16,6 @@ import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/semver.dart';
const defaultConfig = AppConfig();
@@ -34,7 +32,6 @@ class AppConfig {
final BackupConfig backup;
final NetworkConfig network;
final ShareConfig share;
final FeatureMessageConfig featureMessage;
const AppConfig({
this.logLevel = .info,
@@ -49,7 +46,6 @@ class AppConfig {
this.backup = const .new(),
this.network = const .new(),
this.share = const .new(),
this.featureMessage = const .new(),
});
AppConfig copyWith({
@@ -65,7 +61,6 @@ class AppConfig {
BackupConfig? backup,
NetworkConfig? network,
ShareConfig? share,
FeatureMessageConfig? featureMessage,
}) => .new(
logLevel: logLevel ?? this.logLevel,
theme: theme ?? this.theme,
@@ -79,7 +74,6 @@ class AppConfig {
backup: backup ?? this.backup,
network: network ?? this.network,
share: share ?? this.share,
featureMessage: featureMessage ?? this.featureMessage,
);
@override
@@ -97,29 +91,15 @@ class AppConfig {
other.album == album &&
other.backup == backup &&
other.network == network &&
other.share == share &&
other.featureMessage == featureMessage);
other.share == share);
@override
int get hashCode => Object.hash(
logLevel,
theme,
cleanup,
map,
timeline,
image,
viewer,
slideshow,
album,
backup,
network,
share,
featureMessage,
);
int get hashCode =>
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network, share);
@override
String toString() =>
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)';
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share)';
T read<T>(SettingsKey<T> key) =>
(switch (key) {
@@ -166,7 +146,6 @@ class AppConfig {
.slideshowDuration => slideshow.duration,
.slideshowLook => slideshow.look,
.slideshowDirection => slideshow.direction,
.featureMessageSeenRelease => featureMessage.seenRelease,
})
as T;
@@ -220,7 +199,6 @@ class AppConfig {
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
.slideshowDirection => copyWith(slideshow: slideshow.copyWith(direction: value as SlideshowDirection)),
.featureMessageSeenRelease => copyWith(featureMessage: featureMessage.copyWith(seenRelease: value as SemVer)),
};
}
}
@@ -1,20 +0,0 @@
import 'package:immich_mobile/utils/semver.dart';
class FeatureMessageConfig {
final SemVer seenRelease;
const FeatureMessageConfig({this.seenRelease = const SemVer(major: 0, minor: 0, patch: 0)});
FeatureMessageConfig copyWith({SemVer? seenRelease}) =>
FeatureMessageConfig(seenRelease: seenRelease ?? this.seenRelease);
@override
bool operator ==(Object other) =>
identical(this, other) || (other is FeatureMessageConfig && other.seenRelease == seenRelease);
@override
int get hashCode => seenRelease.hashCode;
@override
String toString() => 'FeatureMessageConfig(seenRelease: $seenRelease)';
}
@@ -1,54 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/utils/semver.dart';
class FeatureHighlight {
/// Asset path of the feature screenshot, or null to show a placeholder.
final String? image;
final String titleKey;
final String bodyKey;
final List<TargetPlatform> platform;
const FeatureHighlight({
this.image,
required this.titleKey,
required this.bodyKey,
this.platform = const [.iOS, .android],
});
bool get isVisibleOnCurrentPlatform => platform.contains(defaultTargetPlatform);
}
/// The release this batch of highlights was authored for. Content-defined:
/// bump it only when publishing a new batch, never from the running app version.
const featureMessageRelease = SemVer(major: 3, minor: 0, patch: 0);
/// Highlights relevant to the current platform.
List<FeatureHighlight> get visibleFeatureMessageHighlights =>
featureMessageHighlights.where((h) => h.isVisibleOnCurrentPlatform).toList();
const List<FeatureHighlight> featureMessageHighlights = [
FeatureHighlight(
image: 'assets/feature_message/share_quality.webp',
titleKey: 'share_quality_title',
bodyKey: 'share_quality_body',
),
FeatureHighlight(
image: 'assets/feature_message/slideshow.webp',
titleKey: 'slideshow_title',
bodyKey: 'slideshow_body',
),
FeatureHighlight(
image: 'assets/feature_message/recently_added.webp',
titleKey: 'recently_added_title',
bodyKey: 'recently_added_body',
),
FeatureHighlight(image: 'assets/feature_message/ocr.webp', titleKey: 'ocr_title', bodyKey: 'ocr_body'),
FeatureHighlight(
image: 'assets/feature_message/open_in_immich.webp',
titleKey: 'open_in_immich_title',
bodyKey: 'open_in_immich_body',
platform: [.android],
),
FeatureHighlight(titleKey: 'upload_to_album_title', bodyKey: 'upload_to_album_body'),
];
+1 -15
View File
@@ -6,7 +6,6 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/semver.dart';
enum SettingsKey<T> {
// Theme
@@ -74,10 +73,7 @@ enum SettingsKey<T> {
slideshowRepeat<bool>(),
slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)),
// Feature message
featureMessageSeenRelease<SemVer>(codec: _SemVerCodec());
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
final _SettingsCodec<T>? _codecOverride;
@@ -143,16 +139,6 @@ final class _DateTimeCodec extends _SettingsCodec<DateTime> {
DateTime decode(String raw) => DateTime.parse(raw);
}
final class _SemVerCodec extends _SettingsCodec<SemVer> {
const _SemVerCodec();
@override
String encode(SemVer value) => value.toString();
@override
SemVer decode(String raw) => SemVer.fromString(raw);
}
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
final _SettingsCodec<K> _keyCodec;
final _SettingsCodec<V> _valueCodec;
@@ -1,16 +0,0 @@
import 'package:immich_mobile/domain/models/feature_message.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
class FeatureMessageService {
final SettingsRepository _settingsRepository;
const FeatureMessageService(this._settingsRepository);
bool shouldShow() {
final seen = _settingsRepository.appConfig.featureMessage.seenRelease;
return featureMessageHighlights.isNotEmpty && featureMessageRelease > seen;
}
Future<void> markSeen() => _settingsRepository.write(SettingsKey.featureMessageSeenRelease, featureMessageRelease);
}
@@ -3,7 +3,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/settings/advanced_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_settings.dart';
@@ -88,14 +87,6 @@ class _MobileLayout extends StatelessWidget {
],
)
.toList();
settings.add(
SettingsCard(
icon: Icons.auto_awesome_outlined,
title: context.t.whats_new,
subtitle: context.t.whats_new_settings_subtitle,
settingRoute: const WhatsNewRoute(),
),
);
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: [...settings]);
}
}
@@ -125,13 +116,6 @@ class _TabletLayout extends HookWidget {
),
),
),
SliverToBoxAdapter(
child: ListTile(
title: Text('whats_new'.tr()),
leading: const Icon(Icons.auto_awesome_outlined),
onTap: () => context.pushRoute(const WhatsNewRoute()),
),
),
],
),
),
@@ -3,42 +3,14 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart';
import 'package:immich_mobile/providers/feature_message.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
@RoutePage()
class MainTimelinePage extends ConsumerStatefulWidget {
class MainTimelinePage extends ConsumerWidget {
const MainTimelinePage({super.key});
@override
ConsumerState<MainTimelinePage> createState() => _MainTimelinePageState();
}
class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) {
return;
}
final service = ref.read(featureMessageServiceProvider);
if (!service.shouldShow()) {
return;
}
await service.markSeen();
if (!mounted) {
return;
}
await showFeatureMessageDialog(context);
});
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
return Timeline(
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
@@ -1,73 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/feature_message.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_placeholder.widget.dart';
@RoutePage()
class WhatsNewPage extends StatelessWidget {
const WhatsNewPage({super.key});
@override
Widget build(BuildContext context) {
final highlights = visibleFeatureMessageHighlights;
return Scaffold(
appBar: AppBar(centerTitle: false, title: Text(context.t.whats_new)),
body: ListView.separated(
padding: const EdgeInsets.only(top: 16, bottom: 64),
itemCount: highlights.length,
separatorBuilder: (_, __) => const SizedBox(height: 24),
itemBuilder: (_, index) => _HighlightCard(highlight: highlights[index]),
),
);
}
}
class _HighlightCard extends StatelessWidget {
final FeatureHighlight highlight;
const _HighlightCard({required this.highlight});
@override
Widget build(BuildContext context) {
final scheme = context.colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(18)),
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.5)),
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(18)),
child: SizedBox(
width: double.infinity,
height: 256,
child: highlight.image == null
? const FeatureMessagePlaceholder()
: Image.asset(
highlight.image!,
fit: BoxFit.contain,
errorBuilder: (context, _, __) => const FeatureMessagePlaceholder(),
),
),
),
),
const SizedBox(height: 12),
Text(highlight.titleKey.tr(), style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(
highlight.bodyKey.tr(),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
],
),
);
}
}
@@ -1,316 +0,0 @@
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/feature_message.model.dart';
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_placeholder.widget.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
Future<void> showFeatureMessageDialog(BuildContext context) {
return showGeneralDialog<void>(
context: context,
useRootNavigator: true,
barrierDismissible: true,
barrierLabel: context.t.whats_new,
barrierColor: Colors.black.withValues(alpha: 0.55),
transitionDuration: const Duration(milliseconds: 280),
pageBuilder: (_, __, ___) => const _FeatureMessageDialog(),
transitionBuilder: (_, animation, __, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic);
return FadeTransition(
opacity: animation,
child: ScaleTransition(scale: Tween<double>(begin: 0.94, end: 1.0).animate(curved), child: child),
);
},
);
}
class _FeatureMessageDialog extends StatefulWidget {
const _FeatureMessageDialog();
@override
State<_FeatureMessageDialog> createState() => _FeatureMessageDialogState();
}
class _FeatureMessageDialogState extends State<_FeatureMessageDialog> with SingleTickerProviderStateMixin {
static const double _radius = 24;
final PageController _controller = PageController();
late final AnimationController _borderController = AnimationController(
vsync: this,
duration: const Duration(seconds: 7),
)..repeat();
final List<FeatureHighlight> _highlights = visibleFeatureMessageHighlights;
int _index = 0;
bool get _isLast => _index >= _highlights.length - 1;
@override
void dispose() {
_controller.dispose();
_borderController.dispose();
super.dispose();
}
void _advance() {
if (_isLast) {
Navigator.of(context).pop();
return;
}
_controller.nextPage(duration: const Duration(milliseconds: 320), curve: Curves.easeOutCubic);
}
List<Color> _borderColors(BuildContext context) {
final scheme = context.colorScheme;
// Mute the hues toward the surface and drop opacity in dark mode to keep it gentle.
Color tone(Color c) => context.isDarkTheme ? Color.lerp(c, scheme.surface, 0.45)!.withValues(alpha: 0.6) : c;
return [tone(scheme.primary), tone(scheme.tertiary), tone(scheme.secondary), tone(scheme.primary)];
}
@override
Widget build(BuildContext context) {
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 64),
clipBehavior: Clip.antiAlias,
backgroundColor: context.isDarkTheme ? context.colorScheme.surfaceContainerLow : Colors.white,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(_radius))),
child: AnimatedBuilder(
animation: _borderController,
builder: (context, child) => CustomPaint(
foregroundPainter: _GradientBorderPainter(
rotation: _borderController.value,
colors: _borderColors(context),
radius: _radius,
strokeWidth: 3,
),
child: child,
),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: context.height * 0.9, maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.t.whats_new,
style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 2),
Text(
context.t.whats_new_version(version: featureMessageRelease.toString()),
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
),
),
const SizedBox(height: 32),
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: _highlights.length,
onPageChanged: (i) => setState(() => _index = i),
itemBuilder: (_, index) => _FeaturePage(highlight: _highlights[index]),
),
),
const SizedBox(height: 8),
_PageDots(controller: _controller, index: _index, count: _highlights.length),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 26),
child: Row(
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14)),
child: Text(context.t.skip),
),
const SizedBox(width: 8),
Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(100)),
boxShadow: [
// Soft wide primary glow.
BoxShadow(
color: context.primaryColor.withValues(alpha: 0.38),
blurRadius: 22,
spreadRadius: -4,
offset: const Offset(0, 10),
),
// Tight contact shadow for grounding.
BoxShadow(
color: context.primaryColor.withValues(alpha: 0.22),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: FilledButton(
onPressed: _advance,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
elevation: 0,
textStyle: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(_isLast ? context.t.ok : context.t.next, key: ValueKey(_isLast)),
),
),
),
),
],
),
),
],
),
),
),
);
}
}
class _GradientBorderPainter extends CustomPainter {
const _GradientBorderPainter({
required this.rotation,
required this.colors,
required this.radius,
this.strokeWidth = 3,
});
final double rotation;
final List<Color> colors;
final double radius;
final double strokeWidth;
@override
void paint(Canvas canvas, Size size) {
final inset = strokeWidth / 2;
final rect = (Offset.zero & size).deflate(inset);
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(radius - inset));
final shader = SweepGradient(
transform: GradientRotation(rotation * 2 * math.pi),
colors: colors,
).createShader(rect);
final paint = Paint()
..shader = shader
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas.drawRRect(rrect, paint);
}
@override
bool shouldRepaint(_GradientBorderPainter oldDelegate) =>
oldDelegate.rotation != rotation || !listEquals(oldDelegate.colors, colors);
}
class _FeaturePage extends StatelessWidget {
final FeatureHighlight highlight;
const _FeaturePage({required this.highlight});
@override
Widget build(BuildContext context) {
final scheme = context.colorScheme;
return SingleChildScrollView(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(18)),
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.5)),
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(18)),
child: SizedBox(
width: double.infinity,
height: 256,
child: highlight.image == null
? const FeatureMessagePlaceholder()
: Image.asset(
highlight.image!,
fit: BoxFit.contain,
errorBuilder: (context, _, __) => const FeatureMessagePlaceholder(),
),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 18, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
highlight.titleKey.tr(),
style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700, fontSize: 24),
),
const SizedBox(height: 8),
Text(
highlight.bodyKey.tr(),
style: context.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant, height: 1.4),
),
],
),
),
],
),
);
}
}
class _PageDots extends StatelessWidget {
final PageController controller;
final int index;
final int count;
const _PageDots({required this.controller, required this.index, required this.count});
@override
Widget build(BuildContext context) {
final primary = context.primaryColor;
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final page = controller.hasClients ? (controller.page ?? index.toDouble()) : index.toDouble();
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(count, (i) {
final activeness = (1 - (page - i).abs()).clamp(0.0, 1.0);
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
height: 7,
width: 7 + 16 * activeness,
decoration: BoxDecoration(
color: Color.lerp(context.colorScheme.surfaceContainerHighest, primary, activeness),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
);
}),
);
},
);
}
}
@@ -1,105 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
class _SplatColors {
static const primary = Color(0xFF4250AF);
static const info = Color(0xFF3B82F6);
static const success = Color(0xFF2FB457);
static const warning = Color(0xFFF2A73B);
static const danger = Color(0xFFE5484D);
}
class FeatureMessagePlaceholder extends StatelessWidget {
const FeatureMessagePlaceholder({super.key});
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
final cardColor = dark ? const Color(0xFF232228) : const Color(0xFFEEEDF4);
final tileColor = dark ? const Color(0xFF2B2A32) : const Color(0xFFFBFAFE);
final inkColor = dark ? const Color(0xFFE7E7EC) : const Color(0xFF1A1A1E);
return Container(
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.antiAlias,
// Fill a plain rectangle the parent's ClipRRect handles the corner rounding,
// so the placeholder doesn't round its own corners inside that clip.
decoration: BoxDecoration(color: cardColor),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// ---- confetti motif (168 × 120 region) ----
SizedBox(
width: 168,
height: 120,
child: Stack(
clipBehavior: Clip.none,
children: [
// scattered confetti
Positioned(left: 6, top: 24, child: _dot(12, _SplatColors.primary)),
Positioned(left: 80, top: -2, child: _dot(9, _SplatColors.danger)),
Positioned(left: 148, top: 84, child: _dot(11, _SplatColors.success)),
Positioned(left: 140, top: 14, child: _bar(22, 8, 0.49, _SplatColors.danger)), // ~28°
Positioned(left: 2, top: 90, child: _bar(20, 8, -0.31, _SplatColors.info)), // ~-18°
// tilted spark tile
Positioned(
left: 46,
top: 18,
child: Transform.rotate(
angle: -0.105, // ~-6°
child: Container(
width: 84,
height: 84,
decoration: BoxDecoration(
color: tileColor,
borderRadius: const BorderRadius.all(Radius.circular(18)),
boxShadow: [
BoxShadow(
color: const Color(0xFF0F122D).withValues(alpha: 0.22),
blurRadius: 22,
offset: const Offset(0, 10),
),
],
),
child: Stack(
alignment: Alignment.center,
children: [
Positioned(left: 12, top: 12, child: _dot(12, _SplatColors.warning)),
const ImmichLogo(size: 40),
],
),
),
),
),
],
),
),
const SizedBox(height: 16),
Text(
context.t.new_feature,
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: inkColor),
),
],
),
);
}
static Widget _dot(double d, Color c) => Container(
width: d,
height: d,
decoration: BoxDecoration(color: c, shape: BoxShape.circle),
);
static Widget _bar(double w, double h, double angle, Color c) => Transform.rotate(
angle: angle,
child: Container(
width: w,
height: h,
decoration: BoxDecoration(color: c, borderRadius: const BorderRadius.all(Radius.circular(99))),
),
);
}
@@ -1,7 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/feature_message.service.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
final featureMessageServiceProvider = Provider<FeatureMessageService>(
(ref) => FeatureMessageService(ref.read(settingsProvider)),
);
-2
View File
@@ -38,7 +38,6 @@ import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
import 'package:immich_mobile/presentation/pages/feature_message/whats_new.page.dart';
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
@@ -132,7 +131,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: ProfilePictureCropRoute.page),
AutoRoute(page: SettingsRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: WhatsNewRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: FolderRoute.page, guards: [_authGuard]),
-16
View File
@@ -1872,19 +1872,3 @@ class TabShellRoute extends PageRouteInfo<void> {
},
);
}
/// generated route for
/// [WhatsNewPage]
class WhatsNewRoute extends PageRouteInfo<void> {
const WhatsNewRoute({List<PageRouteInfo>? children})
: super(WhatsNewRoute.name, initialChildren: children);
static const String name = 'WhatsNewRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const WhatsNewPage();
},
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:immich_mobile/utils/semver.dart';
String? getVersionCompatibilityMessage({required SemVer serverVersion, required SemVer appVersion}) {
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
// Add latest compat info up top
// ensure mobile app major version is not behind server major version
+5 -18
View File
@@ -18,7 +18,6 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/feature_message.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -92,7 +91,7 @@ class LoginForm extends HookConsumerWidget {
final packageInfo = await PackageInfo.fromPlatform();
final appSemVer = SemVer.fromString(packageInfo.version);
final serverSemVer = serverInfo.serverVersion;
warningMessage.value = getVersionCompatibilityMessage(serverVersion: serverSemVer, appVersion: appSemVer);
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
} catch (error) {
warningMessage.value = 'Error checking version compatibility';
}
@@ -255,7 +254,6 @@ class LoginForm extends HookConsumerWidget {
}
unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(ref.read(featureMessageServiceProvider).markSeen());
unawaited(context.router.replaceAll([const TabShellRoute()]));
return;
}
@@ -343,7 +341,6 @@ class LoginForm extends HookConsumerWidget {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
unawaited(ref.read(featureMessageServiceProvider).markSeen());
unawaited(context.router.replaceAll([const TabShellRoute()]));
return;
}
@@ -380,21 +377,11 @@ class LoginForm extends HookConsumerWidget {
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.isDarkTheme ? Colors.amber.shade700 : Colors.amber.shade100,
borderRadius: const BorderRadius.all(Radius.circular(12)),
border: Border.all(color: context.isDarkTheme ? Colors.amber.shade800 : Colors.amber[200]!, width: 2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, color: Colors.amber.shade800),
const SizedBox(width: 8),
Expanded(
child: Padding(padding: const EdgeInsets.only(top: 2), child: Text(warningMessage.value!)),
),
],
color: context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!),
),
child: Text(warningMessage.value!, textAlign: TextAlign.center),
),
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 3.0.0
- API version: 3.0.0-rc.4
- Generator version: 7.22.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
+23
View File
@@ -912,6 +912,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.2"
immich_native_core:
dependency: "direct main"
description:
path: "../native/immich_native_core"
relative: true
source: path
version: "0.1.0"
immich_ui:
dependency: "direct main"
description:
@@ -1124,6 +1131,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.19.1"
native_toolchain_rust:
dependency: transitive
description:
name: native_toolchain_rust
sha256: faa57d2258a3b0fd2a634054f54e4496c9fcbd971977e7d2b7e6916d56892857
url: "https://pub.dev"
source: hosted
version: "1.0.4+0"
native_video_player:
dependency: "direct main"
description:
@@ -1755,6 +1770,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.4"
toml:
dependency: transitive
description:
name: toml
sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
url: "https://pub.dev"
source: hosted
version: "0.18.0"
typed_data:
dependency: transitive
description:
+3 -2
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.0.0+3053
version: 3.0.0-rc.4+3052
environment:
sdk: '>=3.12.0 <4.0.0'
@@ -39,6 +39,8 @@ dependencies:
hooks_riverpod: ^2.6.1
http: ^1.6.0
image_picker: ^1.2.1
immich_native_core:
path: ../native/immich_native_core
immich_ui:
path: './packages/ui'
intl: ^0.20.2
@@ -119,7 +121,6 @@ flutter:
uses-material-design: true
assets:
- assets/
- assets/feature_message/
fonts:
- family: GoogleSans
fonts:
@@ -9,16 +9,16 @@ void main() {
test('returns message when app major is behind server major', () {
final result = getVersionCompatibilityMessage(
serverVersion: const SemVer(major: 2, minor: 0, patch: 0),
appVersion: const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 1, minor: 200, patch: 0),
);
expect(result, message);
});
test('returns null when app major matches server major', () {
final result = getVersionCompatibilityMessage(
serverVersion: const SemVer(major: 2, minor: 0, patch: 0),
appVersion: const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
@@ -30,16 +30,16 @@ void main() {
test('returns message when app major is more than one ahead of server', () {
final result = getVersionCompatibilityMessage(
serverVersion: const SemVer(major: 1, minor: 200, patch: 0),
appVersion: const SemVer(major: 3, minor: 0, patch: 0),
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 3, minor: 0, patch: 0),
);
expect(result, message);
});
test('returns null when app major is exactly one ahead of server', () {
final result = getVersionCompatibilityMessage(
serverVersion: const SemVer(major: 1, minor: 200, patch: 0),
appVersion: const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
+5
View File
@@ -0,0 +1,5 @@
/target
smoke/*.node
# generated + committed (regen via `mise run codegen`):
# crates/immich_core_dart/include/immich_core.h (cbindgen)
# immich_native_core/lib/immich_native_core_bindings_generated.dart (ffigen)
+619
View File
@@ -0,0 +1,619 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cbindgen"
version = "0.29.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
dependencies = [
"heck",
"indexmap",
"log",
"proc-macro2",
"quote",
"serde",
"serde_json",
"syn",
"tempfile",
"toml",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "ctor"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hybrid-array"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
]
[[package]]
name = "immich_core"
version = "0.1.0"
dependencies = [
"memmap2",
"sha1",
]
[[package]]
name = "immich_core_dart"
version = "0.1.0"
dependencies = [
"cbindgen",
"immich_core",
]
[[package]]
name = "immich_core_napi"
version = "0.1.0"
dependencies = [
"immich_core",
"napi",
"napi-build",
"napi-derive",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "memmap2"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
[[package]]
name = "napi"
version = "3.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b41bda2ac390efb5e8d22025d925ccc3f3807d8c1bea6d19b36127247c4b8f83"
dependencies = [
"bitflags",
"ctor",
"futures",
"napi-build",
"napi-sys",
"nohash-hasher",
"rustc-hash",
]
[[package]]
name = "napi-build"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
[[package]]
name = "napi-derive"
version = "3.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61d66f70256ad5aef58659966064471d0ad90e2897bc36a5a5e0389c85aabc1e"
dependencies = [
"convert_case",
"ctor",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "napi-derive-backend"
version = "5.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81b4b08f15eed7a2a20c3f4c6314013fc3ac890a3afa9892b594485299ebdb2d"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"semver",
"syn",
]
[[package]]
name = "napi-sys"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
dependencies = [
"libloading",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.3",
]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+44
View File
@@ -0,0 +1,44 @@
[workspace]
resolver = "2"
members = [
"crates/immich_core",
"crates/immich_core_dart",
"crates/immich_core_napi",
]
# shared logic lives in immich_core (no binding deps). each binding crate is a
# thin wrapper that picks its own crate-type: immich_core_dart -> cdylib/staticlib
# for dart:ffi (mobile), immich_core_napi -> cdylib (.node) for the node server.
# capabilities (hashing, exif, ...) are cargo features on immich_core so both
# bindings opt into the same set. crate-type can't be feature-gated, which is why
# the bindings are separate crates rather than one crate with feature flags.
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "AGPL-3.0-only"
# single source of truth for all external dep versions. inner crates reference
# these with `{ workspace = true }` and never hardcode a version.
# default-features = false MUST live here (workspace level) — cargo ignores it if
# set only on the inner crate. inner crates then add the minimal features they need.
[workspace.dependencies]
sha1 = { version = "0.11", default-features = false }
memmap2 = { version = "0.9", default-features = false }
napi = { version = "3", default-features = false }
napi-derive = "3"
napi-build = "2"
cbindgen = { version = "0.29", default-features = false }
# CI-enforced (not review-hoped): the boundary crate also #![deny]s unwrap/expect.
[workspace.lints.clippy]
undocumented_unsafe_blocks = "deny"
# NB: no `panic = "abort"` — the FFI boundary relies on catch_unwind, which is a
# no-op under abort. default unwind is what lets a boundary panic become a null
# return instead of taking down the host (Flutter app / node server).
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
+61
View File
@@ -0,0 +1,61 @@
# immich_native_core (PoC)
Shared Rust core consumed by the **mobile** app (Flutter, dart:ffi) and the
**server** (Node, napi `.node` addon).
Status: **plumbing PoC.** It proves the wiring — Rust → codegen → build-from-source
on each app build → load on both platforms — not a perf win yet. The one capability
(`sha1_hex`) is single-shot in-memory, and the local-sync probe found hashing isn't
the hot path; a measured payload is the next step. `core_version` is a smoke
entrypoint. Mobile is the consumed path; the server napi crate builds and
round-trips but is not wired into the server yet.
## Layout
```
crates/
immich_core pure logic, no binding deps. capabilities = cargo features (hashing).
immich_core_dart cdylib/staticlib + cbindgen header for dart:ffi (mobile)
immich_core_napi cdylib (.node) via napi-rs (server, unwired)
immich_native_core/ the Flutter package mobile depends on. build hook + ffigen @Native bindings.
smoke/ host dart + node roundtrip scripts (no device)
```
Bindings are separate crates (Cargo can't gate `crate-type` by feature).
## How the native lib is built (Flutter native assets — no prebuilt, no CI)
`immich_native_core/hook/build.dart` (`native_toolchain_rust`) compiles
`crates/immich_core_dart` **from source on every app build** via rustup and bundles
it as a Flutter *code asset*. The Dart side uses ffigen `@Native` externals bound to
that asset — no `DynamicLibrary`, no prebuilt artifacts, no fetch/publish/separate-repo.
Native assets is on by default on Flutter stable (3.38+), so a stock `flutter build`
runs the hook. Each builder needs **rustup** (the hook auto-installs the pinned
toolchain + targets from `crates/immich_core_dart/rust-toolchain.toml`).
## Dev commands (mise)
```
mise run build cargo build --workspace
mise run test cargo test --workspace (host Rust tests, incl. FFI-boundary)
mise run lint clippy -D warnings (fmt: mise run fmt)
mise run codegen regen cbindgen header + ffigen @Native bindings — commit the result
mise run test:flutter HOST FFI roundtrip through the real build hook (no device)
mise run smoke Rust tests + host dart:ffi + host napi roundtrips
```
## Add a capability (end to end)
1. add the logic to `crates/immich_core` (behind a cargo feature if it pulls a dep).
2. expose a C entry in `crates/immich_core_dart/src/lib.rs``#[no_mangle] pub extern "C"`,
wrap the body in `guard(...)` (panic at the boundary → null, never unwind into the host),
validate pointers, return Rust-owned memory the caller frees via `immich_core_free_string`.
3. `mise run codegen` — regenerates the committed cbindgen header + ffigen `@Native` bindings.
4. add an ergonomic wrapper + null-check in `immich_native_core/lib/immich_native_core.dart`.
5. (optional) mirror it in `crates/immich_core_napi/src/lib.rs` for the server.
6. `mise run test:flutter` (host) + add a case to `immich_native_core/test/`, and to
`mobile/integration_test/native_core_test.dart` to exercise it on a device.
## Consume from immich/mobile
`immich_native_core: { path: ../native/immich_native_core }` in `mobile/pubspec.yaml`,
then `dart pub get`. No app-level Gradle/Podfile edits — the hook builds + bundles the
lib. Builders need rustup. See the package README for the iOS App-Extension caveat.
`/native/` is codeowned by @santoshakil + @mertalev. License: reuses the immich
repo-root AGPL-3.0 (no separate license file).
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "immich_core"
version.workspace = true
edition.workspace = true
license.workspace = true
[features]
default = ["hashing", "image"]
hashing = ["dep:sha1", "dep:memmap2"]
image = [] # pure pixel math, no deps
[dependencies]
sha1 = { workspace = true, optional = true }
memmap2 = { workspace = true, optional = true }
[lints]
workspace = true
+75
View File
@@ -0,0 +1,75 @@
//! SHA-1 hashing. SHA-1 is immich's asset-identity checksum (server contract):
//! the algorithm is fixed. The win is in HOW it's computed — `sha1_file` mmaps the
//! file and feeds the OS-paged bytes straight to a hardware-accelerated digest, so
//! the whole file never lands in the caller's heap and there's no read+copy hop.
use sha1::{Digest, Sha1};
use std::fmt::Write;
use std::fs::File;
use std::io;
use std::path::Path;
/// Lowercase-hex SHA-1 of a byte slice.
pub fn sha1_hex(bytes: &[u8]) -> String {
let digest = Sha1::digest(bytes);
let mut out = String::with_capacity(40);
for b in digest {
let _ = write!(out, "{b:02x}");
}
out
}
/// Lowercase-hex SHA-1 of the file at `path`, read via mmap. The OS pages the file
/// in on demand, so memory stays bounded regardless of file size — no full read
/// into a buffer, no copy.
pub fn sha1_file(path: impl AsRef<Path>) -> io::Result<String> {
let file = File::open(path)?;
if file.metadata()?.len() == 0 {
return Ok(sha1_hex(&[]));
}
// SAFETY: the file is opened read-only and the mapping is read as immutable
// bytes for the duration of the hash. immich assets are not mutated in place;
// a concurrent truncation could SIGBUS, which is the documented mmap trade-off.
let mmap = unsafe { memmap2::Mmap::map(&file)? };
Ok(sha1_hex(&mmap))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha1_known_vector() {
// FIPS-180 worked example.
assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
}
#[test]
fn sha1_empty() {
assert_eq!(sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
}
#[test]
fn sha1_file_matches_in_memory() {
let dir = std::env::temp_dir();
let path = dir.join(format!("immich_core_sha1_file_{}.bin", std::process::id()));
let data: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
std::fs::write(&path, &data).unwrap();
assert_eq!(sha1_file(&path).unwrap(), sha1_hex(&data));
std::fs::remove_file(&path).ok();
}
#[test]
fn sha1_file_empty() {
let path =
std::env::temp_dir().join(format!("immich_core_empty_{}.bin", std::process::id()));
std::fs::write(&path, b"").unwrap();
assert_eq!(sha1_file(&path).unwrap(), sha1_hex(b""));
std::fs::remove_file(&path).ok();
}
#[test]
fn sha1_file_missing_errors() {
assert!(sha1_file("/no/such/immich_core/file").is_err());
}
}
+173
View File
@@ -0,0 +1,173 @@
//! EXIF-orientation rotation of RGBA8888 pixel buffers, ported from the Android
//! native_image.c (immich PR #29337). Lives here so the perf-critical pixel math
//! exists once, tested, callable from any platform's decode pipeline (Android RAW
//! today; the algorithm is platform-agnostic). The platform side keeps the bitmap
//! lock + output allocation and calls this to fill the destination buffer.
// EXIF orientation values (androidx ExifInterface.ORIENTATION_*).
const FLIP_HORIZONTAL: i32 = 2;
const ROTATE_180: i32 = 3;
const FLIP_VERTICAL: i32 = 4;
const TRANSPOSE: i32 = 5;
const ROTATE_90: i32 = 6;
const TRANSVERSE: i32 = 7;
const ROTATE_270: i32 = 8;
// 32x32 u32 tile = 4KB, L1-resident so a 90/270 transpose's scattered writes stay hot.
const TILE: usize = 32;
/// Whether the orientation swaps width and height (the 90/270 + transpose family).
pub fn swaps_dims(orientation: i32) -> bool {
matches!(orientation, ROTATE_90 | ROTATE_270 | TRANSPOSE | TRANSVERSE)
}
// (base, step_x, step_y): src pixel (sx,sy) maps to dst pixel index
// base + sx*step_x + sy*step_y for a destination of width `dw`. Mirrors
// native_image.c affine_for byte-for-byte. i64 so the math stays correct on 32-bit.
fn affine_for(o: i32, sw: i64, sh: i64, dw: i64) -> (i64, i64, i64) {
match o {
ROTATE_90 => (sh - 1, dw, -1),
ROTATE_270 => ((sw - 1) * dw, -dw, 1),
ROTATE_180 => ((sh - 1) * dw + (sw - 1), -1, -dw),
FLIP_HORIZONTAL => (sw - 1, -1, dw),
FLIP_VERTICAL => ((sh - 1) * dw, 1, -dw),
TRANSPOSE => (0, dw, 1),
TRANSVERSE => ((sw - 1) * dw + (sh - 1), -dw, -1),
_ => (0, 1, dw),
}
}
/// Rotate `src` (RGBA8888, `sh` rows of `src_stride` bytes, `sw` pixels per row) into
/// `dst` (densely packed, `dw*dh*4` bytes) for the given EXIF orientation, where
/// (dw,dh) swap for the 90/270/transpose family. Returns `false` without touching
/// out-of-range memory if the sizes are inconsistent, so the caller can fall back.
/// Indexing is bounds-checked: a bad input fails safe (panic caught at the FFI
/// boundary / false here), never an out-of-bounds write like the raw C.
pub fn rotate_rgba8888(
src: &[u8],
src_stride: usize,
sw: usize,
sh: usize,
orientation: i32,
dst: &mut [u8],
) -> bool {
if sw == 0 || sh == 0 || src_stride < sw * 4 {
return false;
}
let dw = if swaps_dims(orientation) { sh } else { sw };
let dh = if swaps_dims(orientation) { sw } else { sh };
if src.len() < src_stride * sh || dst.len() < dw * dh * 4 {
return false;
}
let (base, step_x, step_y) = affine_for(orientation, sw as i64, sh as i64, dw as i64);
for ty in (0..sh).step_by(TILE) {
let y_end = (ty + TILE).min(sh);
for tx in (0..sw).step_by(TILE) {
let x_end = (tx + TILE).min(sw);
for sy in ty..y_end {
let row = sy * src_stride;
let mut idx = base + sy as i64 * step_y + tx as i64 * step_x;
for sx in tx..x_end {
let s = row + sx * 4;
let d = idx as usize * 4;
dst[d..d + 4].copy_from_slice(&src[s..s + 4]);
idx += step_x;
}
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
// Independent textbook EXIF transform: src(sx,sy) -> dst(dx,dy). Verifies the
// affine port against orientation *semantics*, not against itself.
fn ref_dst_xy(o: i32, sx: usize, sy: usize, sw: usize, sh: usize) -> (usize, usize) {
match o {
FLIP_HORIZONTAL => (sw - 1 - sx, sy),
ROTATE_180 => (sw - 1 - sx, sh - 1 - sy),
FLIP_VERTICAL => (sx, sh - 1 - sy),
TRANSPOSE => (sy, sx),
ROTATE_90 => (sh - 1 - sy, sx),
TRANSVERSE => (sh - 1 - sy, sw - 1 - sx),
ROTATE_270 => (sy, sw - 1 - sx),
_ => (sx, sy),
}
}
fn pixel(i: usize) -> [u8; 4] {
[
(i & 0xff) as u8,
((i >> 8) & 0xff) as u8,
((i >> 16) & 0xff) as u8,
0xff,
]
}
fn check(o: i32, sw: usize, sh: usize) {
let mut src = vec![0u8; sw * sh * 4];
for sy in 0..sh {
for sx in 0..sw {
let i = sy * sw + sx;
src[i * 4..i * 4 + 4].copy_from_slice(&pixel(i));
}
}
let (dw, dh) = if swaps_dims(o) { (sh, sw) } else { (sw, sh) };
let mut dst = vec![0u8; dw * dh * 4];
assert!(rotate_rgba8888(&src, sw * 4, sw, sh, o, &mut dst));
for sy in 0..sh {
for sx in 0..sw {
let (dx, dy) = ref_dst_xy(o, sx, sy, sw, sh);
let di = dy * dw + dx;
let si = sy * sw + sx;
assert_eq!(&dst[di * 4..di * 4 + 4], &pixel(si), "o={o} src({sx},{sy})");
}
}
}
#[test]
fn all_orientations_match_exif_reference() {
for o in [1, 2, 3, 4, 5, 6, 7, 8] {
check(o, 4, 3);
check(o, 1, 5);
check(o, 5, 1);
check(o, 40, 33); // spans multiple tiles
}
}
#[test]
fn identity_for_normal_orientation() {
let src: Vec<u8> = (0..24u8).collect(); // 2x3 RGBA
let mut dst = vec![0u8; 24];
assert!(rotate_rgba8888(&src, 8, 2, 3, 1, &mut dst));
assert_eq!(src, dst);
}
#[test]
fn respects_src_stride_padding() {
let (sw, sh, stride) = (2usize, 2usize, 12usize); // 4 bytes row padding
let mut src = vec![0u8; stride * sh];
for sy in 0..sh {
for sx in 0..sw {
let i = sy * sw + sx;
src[sy * stride + sx * 4..sy * stride + sx * 4 + 4].copy_from_slice(&pixel(i));
}
}
let mut dst = vec![0u8; sw * sh * 4];
assert!(rotate_rgba8888(&src, stride, sw, sh, ROTATE_180, &mut dst));
for i in 0..4 {
assert_eq!(&dst[i * 4..i * 4 + 4], &pixel(3 - i)); // 180: i -> N-1-i
}
}
#[test]
fn rejects_bad_sizes() {
let src = vec![0u8; 16];
let mut small = vec![0u8; 4];
assert!(!rotate_rgba8888(&src, 8, 2, 2, ROTATE_90, &mut small)); // dst too small
assert!(!rotate_rgba8888(&src, 4, 2, 2, 1, &mut small)); // stride < sw*4
}
}
+26
View File
@@ -0,0 +1,26 @@
//! immich_native_core — shared Rust core for the immich server (napi) and mobile (dart:ffi).
//!
//! Pure logic only: no binding or platform deps live here. Each binding crate
//! (`immich_core_dart`, `immich_core_napi`) is a thin wrapper. Capabilities are
//! cargo features (`hashing`, `image`, ...) so every binding opts into the same set.
#[cfg(feature = "hashing")]
pub mod hashing;
#[cfg(feature = "image")]
pub mod image;
/// Version of the native core. Smoke-test entrypoint exercised by every binding.
pub fn core_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_is_present() {
assert!(!core_version().is_empty());
}
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "immich_core_dart"
version.workspace = true
edition.workspace = true
license.workspace = true
# native_toolchain_rust requires cdylib (the bundled lib) + staticlib (iOS). It
# derives the artifact name from [package].name, so no [lib] name override here.
[lib]
crate-type = ["cdylib", "staticlib"]
# hashing (SHA-1 asset identity) is the reason this lib exists — always on, so the
# cbindgen header + ffigen bindings always match the exported symbols.
[dependencies]
immich_core = { path = "../immich_core", default-features = false, features = ["hashing", "image"] }
[build-dependencies]
cbindgen = { workspace = true }
[lints]
workspace = true
+19
View File
@@ -0,0 +1,19 @@
use std::path::Path;
fn main() {
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=cbindgen.toml");
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let out = Path::new(&crate_dir).join("include").join("immich_core.h");
std::fs::create_dir_all(out.parent().unwrap()).ok();
// Hard-fail, not a warning: the CI drift gate diffs this header, so a silent
// codegen failure would let a stale header sail through green.
match cbindgen::generate(&crate_dir) {
Ok(bindings) => {
bindings.write_to_file(&out);
}
Err(e) => panic!("cbindgen failed: {e}"),
}
}
@@ -0,0 +1,3 @@
language = "C"
pragma_once = true
autogen_warning = "// Generated by cbindgen — do not edit."
@@ -0,0 +1,60 @@
#pragma once
// Generated by cbindgen — do not edit.
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
/**
* Native core version as a NUL-terminated UTF-8 string.
* Free the result with [`immich_core_free_string`].
*/
char *immich_core_version(void);
/**
* SHA-1 (lowercase hex) of `len` bytes at `ptr`. Returns NULL on a null pointer.
* Free the result with [`immich_core_free_string`].
*
* # Safety
* `ptr` must be valid for reads of `len` bytes.
*/
char *immich_core_sha1_hex(const unsigned char *ptr, uintptr_t len);
/**
* SHA-1 (lowercase hex) of the file at `path` (NUL-terminated UTF-8), read via
* mmap no Dart-side read or copy. Returns NULL on a null path, non-UTF-8 path,
* or any IO error. Free the result with [`immich_core_free_string`].
*
* # Safety
* `path` must be a valid NUL-terminated C string, or null.
*/
char *immich_core_sha1_file(const char *path);
/**
* Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
* `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
* swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
* inconsistent sizes so the caller can fall back. The platform side owns the
* bitmap lock + the dst allocation; this only fills dst.
*
* # Safety
* `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
*/
bool immich_core_rotate_rgba8888(const uint8_t *src,
uintptr_t src_len,
uintptr_t src_stride,
uint32_t width,
uint32_t height,
int32_t orientation,
uint8_t *dst,
uintptr_t dst_len);
/**
* Release a string returned by this library.
*
* # Safety
* `ptr` must be a pointer previously returned by this library, or null.
*/
void immich_core_free_string(char *ptr);
@@ -0,0 +1,18 @@
# The build hook (native_toolchain_rust) drives cargo via rustup and auto-installs
# this toolchain + targets. Pin a version (never bare stable/beta) for reproducible
# builds. Keep the channel in sync with mise.toml's rust pin.
[toolchain]
channel = "1.92.0"
targets = [
# Android
"armv7-linux-androideabi",
"aarch64-linux-android",
"x86_64-linux-android",
# iOS (device + simulator)
"aarch64-apple-ios",
"aarch64-apple-ios-sim",
"x86_64-apple-ios",
# host (local test / macOS)
"aarch64-apple-darwin",
"x86_64-apple-darwin",
]
+197
View File
@@ -0,0 +1,197 @@
//! dart:ffi binding for immich_core (mobile).
//!
//! Returns heap-allocated C strings the caller must release with
//! `immich_core_free_string`. cbindgen emits `include/immich_core.h` at build time.
#![deny(clippy::unwrap_used, clippy::expect_used)]
use std::ffi::{c_char, CStr, CString};
use std::os::raw::c_uchar;
use std::ptr;
/// Native core version as a NUL-terminated UTF-8 string.
/// Free the result with [`immich_core_free_string`].
#[no_mangle]
pub extern "C" fn immich_core_version() -> *mut c_char {
guard(ptr::null_mut(), || {
into_c_string(immich_core::core_version().to_owned())
})
}
/// SHA-1 (lowercase hex) of `len` bytes at `ptr`. Returns NULL on a null pointer.
/// Free the result with [`immich_core_free_string`].
///
/// # Safety
/// `ptr` must be valid for reads of `len` bytes.
#[no_mangle]
pub unsafe extern "C" fn immich_core_sha1_hex(ptr: *const c_uchar, len: usize) -> *mut c_char {
if ptr.is_null() {
return ptr::null_mut();
}
// SAFETY: caller guarantees `ptr` is valid for reads of `len` bytes (see # Safety).
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
guard(ptr::null_mut(), || {
into_c_string(immich_core::hashing::sha1_hex(bytes))
})
}
/// SHA-1 (lowercase hex) of the file at `path` (NUL-terminated UTF-8), read via
/// mmap — no Dart-side read or copy. Returns NULL on a null path, non-UTF-8 path,
/// or any IO error. Free the result with [`immich_core_free_string`].
///
/// # Safety
/// `path` must be a valid NUL-terminated C string, or null.
#[no_mangle]
pub unsafe extern "C" fn immich_core_sha1_file(path: *const c_char) -> *mut c_char {
if path.is_null() {
return ptr::null_mut();
}
// SAFETY: caller guarantees `path` is a valid NUL-terminated C string (see # Safety).
let cpath = unsafe { CStr::from_ptr(path) };
guard(ptr::null_mut(), || match cpath.to_str() {
Ok(s) => match immich_core::hashing::sha1_file(s) {
Ok(hex) => into_c_string(hex),
Err(_) => ptr::null_mut(),
},
Err(_) => ptr::null_mut(),
})
}
/// Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
/// `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
/// swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
/// inconsistent sizes so the caller can fall back. The platform side owns the
/// bitmap lock + the dst allocation; this only fills dst.
///
/// # Safety
/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
#[no_mangle]
pub unsafe extern "C" fn immich_core_rotate_rgba8888(
src: *const u8,
src_len: usize,
src_stride: usize,
width: u32,
height: u32,
orientation: i32,
dst: *mut u8,
dst_len: usize,
) -> bool {
if src.is_null() || dst.is_null() {
return false;
}
// SAFETY: caller guarantees `src` is valid for reads of `src_len` bytes (see # Safety).
let src_slice = unsafe { std::slice::from_raw_parts(src, src_len) };
// SAFETY: caller guarantees `dst` is valid for writes of `dst_len` bytes (see # Safety).
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, dst_len) };
// AssertUnwindSafe: the closure writes through `&mut dst_slice`, which isn't
// UnwindSafe, but a panic mid-rotate only leaves dst partially written — not a
// broken invariant — and we return false so the caller discards the buffer.
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
immich_core::image::rotate_rgba8888(
src_slice,
src_stride,
width as usize,
height as usize,
orientation,
dst_slice,
)
}))
.unwrap_or(false)
}
/// Release a string returned by this library.
///
/// # Safety
/// `ptr` must be a pointer previously returned by this library, or null.
#[no_mangle]
pub unsafe extern "C" fn immich_core_free_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
guard((), || {
// SAFETY: `ptr` came from this library's `CString::into_raw` (see # Safety).
let s = unsafe { CString::from_raw(ptr) };
drop(s);
});
}
/// Run `f` at the FFI boundary, turning a panic into `sentinel` rather than
/// unwinding across `extern "C"` into the host. Guards panics only — a bad `len`
/// or a double/foreign free is caller-contract UB that stays the caller's
/// `# Safety` obligation, not something this can catch.
fn guard<T>(sentinel: T, f: impl FnOnce() -> T + std::panic::UnwindSafe) -> T {
std::panic::catch_unwind(f).unwrap_or(sentinel)
}
fn into_c_string(s: String) -> *mut c_char {
match CString::new(s) {
Ok(c) => c.into_raw(),
Err(_) => ptr::null_mut(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use std::ffi::CStr;
#[test]
fn version_roundtrips_and_frees() {
let p = immich_core_version();
assert!(!p.is_null());
// SAFETY: `p` is a non-null NUL-terminated string from this library.
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
assert!(!s.is_empty());
// SAFETY: `p` was returned by this library and is freed exactly once.
unsafe { immich_core_free_string(p) };
}
#[test]
fn sha1_null_ptr_returns_null() {
// SAFETY: a null ptr is the documented null-returning case.
let p = unsafe { immich_core_sha1_hex(ptr::null(), 0) };
assert!(p.is_null());
}
#[test]
fn sha1_known_vector_roundtrips_and_frees() {
let input = b"abc";
// SAFETY: `input` is valid for reads of `input.len()` bytes.
let p = unsafe { immich_core_sha1_hex(input.as_ptr(), input.len()) };
assert!(!p.is_null());
// SAFETY: `p` is a non-null NUL-terminated string from this library.
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
assert_eq!(s, "a9993e364706816aba3e25717850c26c9cd0d89d");
// SAFETY: `p` was returned by this library and is freed exactly once.
unsafe { immich_core_free_string(p) };
}
#[test]
fn free_null_is_noop() {
// SAFETY: free_string explicitly accepts null.
unsafe { immich_core_free_string(ptr::null_mut()) };
}
#[test]
fn sha1_file_roundtrips_and_frees() {
let path = std::env::temp_dir().join(format!("immich_core_ffi_{}.bin", std::process::id()));
std::fs::write(&path, b"abc").unwrap();
let c = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
// SAFETY: `c` is a valid NUL-terminated path string.
let p = unsafe { immich_core_sha1_file(c.as_ptr()) };
assert!(!p.is_null());
// SAFETY: `p` is a non-null string from this library.
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
assert_eq!(s, "a9993e364706816aba3e25717850c26c9cd0d89d");
// SAFETY: `p` was returned by this library, freed once.
unsafe { immich_core_free_string(p) };
std::fs::remove_file(&path).ok();
}
#[test]
fn sha1_file_null_returns_null() {
// SAFETY: a null path is the documented null-returning case.
let p = unsafe { immich_core_sha1_file(ptr::null()) };
assert!(p.is_null());
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "immich_core_napi"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
immich_core = { path = "../immich_core", default-features = false, features = ["hashing"] }
napi = { workspace = true, features = ["napi4", "dyn-symbols"] }
napi-derive = { workspace = true }
[build-dependencies]
napi-build = { workspace = true }
[lints]
workspace = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}
+24
View File
@@ -0,0 +1,24 @@
//! napi-rs binding for immich_core (node server).
//!
//! Built as a cdylib loaded as a `.node` addon — the same shape as the server's
//! existing native deps (sharp, bcrypt).
use napi_derive::napi;
/// Native core version. JS: `core.coreVersion()`.
#[napi]
pub fn core_version() -> String {
immich_core::core_version().to_owned()
}
/// SHA-1 (lowercase hex) of a buffer. JS: `core.sha1Hex(Buffer.from(...))`.
#[napi]
pub fn sha1_hex(bytes: napi::bindgen_prelude::Buffer) -> String {
immich_core::hashing::sha1_hex(bytes.as_ref())
}
/// SHA-1 (lowercase hex) of a file, read via mmap. JS: `core.sha1File(path)`.
#[napi]
pub fn sha1_file(path: String) -> napi::Result<String> {
immich_core::hashing::sha1_file(&path).map_err(|e| napi::Error::from_reason(e.to_string()))
}
+33
View File
@@ -0,0 +1,33 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/
+48
View File
@@ -0,0 +1,48 @@
# immich_native_core (Flutter package)
dart:ffi bindings to the `immich_native_core` Rust core. The native code is **built
from source on every app build** via a Dart build hook (Flutter native assets) — no
prebuilt binaries, no `DynamicLibrary`, no platform plugin glue.
## Use it from immich/mobile
```yaml
# mobile/pubspec.yaml
dependencies:
immich_native_core:
path: ../native/immich_native_core
```
`dart pub get`, then call it:
```dart
import 'package:immich_native_core/immich_native_core.dart';
final version = coreVersion();
final hex = sha1Hex(bytes); // hash large inputs off the main isolate (worker_manager)
```
No app-level Gradle/Podfile edits. `hook/build.dart` compiles the Rust crate and
Flutter bundles it as a code asset; the `@Native` bindings resolve against it.
**Requirement:** every machine that builds the app needs [rustup](https://rustup.rs)
— the hook auto-installs the pinned toolchain + targets from the crate's
`rust-toolchain.toml`.
## Layout
- `hook/build.dart` — builds `../crates/immich_core_dart` via `native_toolchain_rust`.
- `lib/immich_native_core.dart` — barrel, the public API.
- `lib/src/{core,hashing,image}.dart` — thin wrappers, one file per Rust module.
- `lib/src/ffi/bindings.g.dart` — ffigen `@Native` output (committed; do not edit).
- `ffigen.yaml` — ffi-native mode; asset-id must match the hook's `assetName`.
- `test/` — host FFI roundtrip (`flutter test`); device runs via `mobile/integration_test`.
## ⚠ iOS App Extensions
Code assets are bundled into the app's **Runner** target. immich ships a Share
Extension and a Widget Extension — if the core is ever called from one of those,
verify the symbols resolve there (same family as the embed-into-Runner-only gotcha).
Not an issue while only the main app calls it.
The Rust workspace, the codegen/build/test commands, and the "add a function" loop
live in [`../README.md`](../README.md).
@@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+20
View File
@@ -0,0 +1,20 @@
# Regenerate: `mise run codegen` (cbindgen header -> ffigen @Native bindings).
# ffi-native mode emits top-level @Native externals + a library @DefaultAsset
# pointing at the code asset hook/build.dart produces — no DynamicLibrary loader.
# asset-id MUST equal the generated file's package URI (and the hook's assetName).
name: ImmichNativeCoreBindings
ffi-native:
asset-id: 'package:immich_native_core/src/ffi/bindings.g.dart'
description: 'FFI bindings to immich_native_core — generated, do not edit.'
output: 'lib/src/ffi/bindings.g.dart'
headers:
entry-points:
- '../crates/immich_core_dart/include/immich_core.h'
include-directives:
- '**/immich_core.h'
functions:
include:
- 'immich_core_.*'
comments:
style: any
length: full
+14
View File
@@ -0,0 +1,14 @@
import 'package:hooks/hooks.dart';
import 'package:native_toolchain_rust/native_toolchain_rust.dart';
// Builds crates/immich_core_dart from source on every app build and bundles it as
// a code asset. assetName must match the ffigen output (its package URI is the
// @Native DefaultAsset id). The crate is a sibling, so point cratePath at it.
void main(List<String> args) async {
await build(args, (input, output) async {
await RustBuilder(
assetName: 'src/ffi/bindings.g.dart',
cratePath: '../crates/immich_core_dart',
).run(input: input, output: output);
});
}
@@ -0,0 +1,8 @@
/// dart:ffi bindings to the immich_native_core Rust core (built from source via
/// Dart build hooks). Public API only implementation lives in `src/`, organised
/// to mirror the Rust crate's modules (core / hashing / image).
library;
export 'src/core.dart';
export 'src/hashing.dart';
export 'src/image.dart';
@@ -0,0 +1,5 @@
import 'ffi/bindings.g.dart' as bindings;
import 'ffi/ffi.dart';
/// Version baked into the native core. Cheap fine on the main isolate.
String coreVersion() => readAndFree(bindings.immich_core_version(), 'core_version');
@@ -0,0 +1,75 @@
// AUTO GENERATED FILE, DO NOT EDIT.
//
// Generated by `package:ffigen`.
// ignore_for_file: type=lint, unused_import
@ffi.DefaultAsset('package:immich_native_core/src/ffi/bindings.g.dart')
library;
import 'dart:ffi' as ffi;
/// Native core version as a NUL-terminated UTF-8 string.
/// Free the result with [`immich_core_free_string`].
@ffi.Native<ffi.Pointer<ffi.Char> Function()>()
external ffi.Pointer<ffi.Char> immich_core_version();
/// SHA-1 (lowercase hex) of `len` bytes at `ptr`. Returns NULL on a null pointer.
/// Free the result with [`immich_core_free_string`].
///
/// # Safety
/// `ptr` must be valid for reads of `len` bytes.
@ffi.Native<
ffi.Pointer<ffi.Char> Function(ffi.Pointer<ffi.UnsignedChar>, ffi.UintPtr)
>()
external ffi.Pointer<ffi.Char> immich_core_sha1_hex(
ffi.Pointer<ffi.UnsignedChar> ptr,
int len,
);
/// SHA-1 (lowercase hex) of the file at `path` (NUL-terminated UTF-8), read via
/// mmap no Dart-side read or copy. Returns NULL on a null path, non-UTF-8 path,
/// or any IO error. Free the result with [`immich_core_free_string`].
///
/// # Safety
/// `path` must be a valid NUL-terminated C string, or null.
@ffi.Native<ffi.Pointer<ffi.Char> Function(ffi.Pointer<ffi.Char>)>()
external ffi.Pointer<ffi.Char> immich_core_sha1_file(
ffi.Pointer<ffi.Char> path,
);
/// Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
/// `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
/// swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
/// inconsistent sizes so the caller can fall back. The platform side owns the
/// bitmap lock + the dst allocation; this only fills dst.
///
/// # Safety
/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
@ffi.Native<
ffi.Bool Function(
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
ffi.UintPtr,
ffi.Uint32,
ffi.Uint32,
ffi.Int32,
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
)
>()
external bool immich_core_rotate_rgba8888(
ffi.Pointer<ffi.Uint8> src,
int src_len,
int src_stride,
int width,
int height,
int orientation,
ffi.Pointer<ffi.Uint8> dst,
int dst_len,
);
/// Release a string returned by this library.
///
/// # Safety
/// `ptr` must be a pointer previously returned by this library, or null.
@ffi.Native<ffi.Void Function(ffi.Pointer<ffi.Char>)>()
external void immich_core_free_string(ffi.Pointer<ffi.Char> ptr);
@@ -0,0 +1,19 @@
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'bindings.g.dart' as bindings;
/// Read a C string the core returned into a Dart string and free it. A null
/// return means the native call failed (panic caught at the boundary, or error),
/// so we throw rather than hand back a silent empty value.
String readAndFree(Pointer<Char> ptr, String op) {
if (ptr == nullptr) {
throw StateError('immich_native_core: $op returned null');
}
try {
return ptr.cast<Utf8>().toDartString();
} finally {
bindings.immich_core_free_string(ptr);
}
}
@@ -0,0 +1,34 @@
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'ffi/bindings.g.dart' as bindings;
import 'ffi/ffi.dart';
/// Lowercase-hex SHA-1 of [bytes]. Reads every byte natively and blocks the
/// calling thread, so hash large inputs off the main isolate.
String sha1Hex(Uint8List bytes) {
// allocate at least 1 byte malloc(0) may return null (allocator-defined),
// which package:ffi would reject. The native side still reads only [len] bytes.
final len = bytes.length;
final buf = malloc<Uint8>(len == 0 ? 1 : len);
try {
if (len > 0) buf.asTypedList(len).setAll(0, bytes);
return readAndFree(bindings.immich_core_sha1_hex(buf.cast(), len), 'sha1_hex');
} finally {
malloc.free(buf);
}
}
/// Lowercase-hex SHA-1 of the file at [path], hashed natively via mmap the file
/// is never read into the Dart heap. Blocks the calling thread, so hash large
/// files off the main isolate. Throws if the file is missing/unreadable.
String sha1File(String path) {
final cpath = path.toNativeUtf8();
try {
return readAndFree(bindings.immich_core_sha1_file(cpath.cast()), 'sha1_file');
} finally {
malloc.free(cpath);
}
}
@@ -0,0 +1,42 @@
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'ffi/bindings.g.dart' as bindings;
/// True if [orientation] (EXIF) swaps width and height (the 90/270/transpose family).
bool orientationSwapsDims(int orientation) =>
orientation == 5 || orientation == 6 || orientation == 7 || orientation == 8;
/// Rotate an RGBA8888 image to the given EXIF [orientation], returning a freshly
/// packed buffer (dims swap for 90/270/transpose). [srcStride] is bytes per source
/// row (>= width*4). Returns null if the native rotate declines (bad sizes).
///
/// The production caller is the platform decode pipeline (it has the locked native
/// bitmap); this Dart entry mirrors that path and is what the host tests exercise.
Uint8List? rotateRgba8888(Uint8List src, int srcStride, int width, int height, int orientation) {
final dw = orientationSwapsDims(orientation) ? height : width;
final dh = orientationSwapsDims(orientation) ? width : height;
final dstLen = dw * dh * 4;
final srcPtr = malloc<Uint8>(src.isEmpty ? 1 : src.length);
final dstPtr = malloc<Uint8>(dstLen == 0 ? 1 : dstLen);
try {
if (src.isNotEmpty) srcPtr.asTypedList(src.length).setAll(0, src);
final ok = bindings.immich_core_rotate_rgba8888(
srcPtr.cast(),
src.length,
srcStride,
width,
height,
orientation,
dstPtr.cast(),
dstLen,
);
if (!ok) return null;
return Uint8List.fromList(dstPtr.asTypedList(dstLen));
} finally {
malloc.free(srcPtr);
malloc.free(dstPtr);
}
}
+26
View File
@@ -0,0 +1,26 @@
name: immich_native_core
description: "dart:ffi bindings to the immich_native_core Rust core, built from source via Dart build hooks."
version: 0.1.0
homepage: https://github.com/immich-app/immich
publish_to: none
environment:
sdk: '>=3.11.0 <4.0.0'
flutter: '>=3.3.0'
# Not a platform plugin: the native lib is built + bundled by hook/build.dart as a
# code asset (Flutter native assets), so there is no ffiPlugin / android / ios dir.
dependencies:
flutter:
sdk: flutter
ffi: ^2.2.0
# build-hook deps — run at build time to compile the Rust crate (need rustup).
hooks: ^2.0.2
native_toolchain_rust: ^1.0.4
dev_dependencies:
ffigen: 20.1.1 # pinned exact — a caret bump can re-emit bindings
crypto: ^3.0.7 # pure-Dart SHA-1 baseline for the perf bench only
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
@@ -0,0 +1,53 @@
// Host FFI roundtrip `flutter test` builds the hook for the host platform and
// resolves the @Native symbols, no device needed. (Device runs: example/integration_test.)
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_native_core/immich_native_core.dart';
void main() {
test('coreVersion returns a non-empty version', () {
expect(coreVersion(), isNotEmpty);
});
test('sha1Hex matches the FIPS-180 vector for "abc"', () {
expect(
sha1Hex(Uint8List.fromList(utf8.encode('abc'))),
'a9993e364706816aba3e25717850c26c9cd0d89d',
);
});
test('sha1Hex of empty input', () {
expect(sha1Hex(Uint8List(0)), 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
});
test('sha1File matches the in-memory hash (mmap path)', () {
final tmp = Directory.systemTemp.createTempSync('native_core');
final path = '${tmp.path}/abc.bin';
File(path).writeAsBytesSync(utf8.encode('abc'));
expect(sha1File(path), 'a9993e364706816aba3e25717850c26c9cd0d89d');
tmp.deleteSync(recursive: true);
});
test('sha1File throws on a missing file', () {
expect(() => sha1File('/no/such/immich_native_core/file'), throwsStateError);
});
test('rotateRgba8888: 180 reverses pixels, 90 swaps dims', () {
// 2x1 image: pixel0 = red, pixel1 = green (RGBA).
final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]);
final r180 = rotateRgba8888(src, 8, 2, 1, 3)!; // ROTATE_180
expect(r180, [0, 255, 0, 255, 255, 0, 0, 255]); // green, red
final r90 = rotateRgba8888(src, 8, 2, 1, 6)!; // ROTATE_90 -> 1x2
expect(r90.length, 8); // dims swapped to 1x2, still 2 pixels
});
test('rotateRgba8888 returns null on an undersized result expectation', () {
// width*height*4 mismatch is guarded natively; a 0x0 image yields empty.
final empty = rotateRgba8888(Uint8List(0), 0, 0, 0, 1);
expect(empty, anyOf(isNull, isEmpty));
});
}
@@ -0,0 +1,82 @@
// SHA-1 perf bench run explicitly: `flutter test test/sha1_bench.dart`.
// (Not named *_test.dart so it stays out of the default `flutter test` run.)
//
// Three ways to hash a file, to isolate where the win is:
// A) sha1File(path) Rust: open + mmap + HW-SHA, no Dart read
// B) File.read + sha1Hex(bytes) Dart reads into heap, Rust HW-SHA the bytes
// C) File.read + crypto.sha1(bytes) Dart reads into heap, pure-Dart SHA-1 (naive)
// A vs B = mmap/zero-copy win; B vs C = HW-SHA vs pure-Dart; A vs C = total vs naive.
//
// IMPORTANT C (pure-Dart) is NOT immich's real baseline. immich already hashes
// assets natively + hardware-accelerated on BOTH platforms (Android Kotlin
// MessageDigest SHA-1, iOS Swift CryptoKit Insecure.SHA1), streamed over a read
// buffer, via pigeon. So the real-world comparison is A vs ~B (Rust mmap vs a
// buffered native read with HW-SHA), i.e. roughly the A/B gap (~1.3x), NOT A/C.
// ignore_for_file: avoid_print
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto;
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_native_core/immich_native_core.dart';
void main() {
test('sha1 throughput: mmap(Rust) vs read+Rust vs read+pure-Dart', () {
final tmp = Directory.systemTemp.createTempSync('sha1_bench');
final sizesMb = [1, 16, 64, 256];
double msMin(int iters, void Function() f) {
var best = double.infinity;
for (var i = 0; i < iters; i++) {
final sw = Stopwatch()..start();
f();
sw.stop();
final ms = sw.elapsedMicroseconds / 1000.0;
if (ms < best) best = ms;
}
return best;
}
String mbps(int mb, double ms) => (mb / (ms / 1000.0)).toStringAsFixed(0);
print('');
print('size │ A mmap(Rust) │ B read+Rust │ C read+pureDart │ A vs C');
print('─────┼─────────────────┼─────────────────┼──────────────────┼───────');
for (final mb in sizesMb) {
final path = '${tmp.path}/f_$mb.bin';
final chunk = Uint8List(1 << 20); // 1 MiB pattern
for (var i = 0; i < chunk.length; i++) {
chunk[i] = (i * 31 + 7) & 0xff;
}
final sink = File(path).openSync(mode: FileMode.write);
for (var i = 0; i < mb; i++) {
sink.writeFromSync(chunk);
}
sink.closeSync();
final bytes = File(path).readAsBytesSync(); // for B/C; also baked into their totals below
final tRead = msMin(3, () => File(path).readAsBytesSync());
final tA = msMin(5, () => sha1File(path));
final tB = tRead + msMin(5, () => sha1Hex(bytes));
final tC = tRead + msMin(2, () => crypto.sha1.convert(bytes));
final hA = sha1File(path);
final hB = sha1Hex(bytes);
final hC = crypto.sha1.convert(bytes).toString();
expect(hA, hB);
expect(hA, hC);
final speedup = (tC / tA).toStringAsFixed(1);
String cell(double ms, int mb) =>
'${ms.toStringAsFixed(1)}ms ${mbps(mb, ms).padLeft(5)}MB/s';
print(
'${mb.toString().padLeft(3)}M │ ${cell(tA, mb)}${cell(tB, mb)}${cell(tC, mb).padRight(16)}${speedup}x',
);
}
print('(A/B/C identical SHA-1; read time included in B/C totals.)');
print('(NOTE: immich already hashes natively+HW on both platforms — real');
print(' baseline ~= B, not C. Rust mmap edge over it is the A/B gap (~1.3x).)');
tmp.deleteSync(recursive: true);
}, timeout: const Timeout(Duration(minutes: 10)));
}
+68
View File
@@ -0,0 +1,68 @@
[tools]
rust = "1.92.0" # keep in sync with rust-toolchain.toml (the build hook uses rustup)
[tasks.build]
description = "Build all native core crates (host)"
run = "cargo build --workspace"
[tasks.test]
description = "Run native core Rust tests"
run = "cargo test --workspace"
[tasks.fmt]
description = "Format all crates"
run = "cargo fmt --all"
[tasks.lint]
description = "Clippy (warnings = errors)"
run = "cargo clippy --workspace --all-targets -- -D warnings"
# Regen the committed cbindgen header + ffigen @Native bindings.
[tasks."codegen:ffigen"]
alias = "codegen"
description = "Generate the C header (cbindgen) + Dart @Native bindings (ffigen)"
sources = [
"crates/immich_core_dart/src/lib.rs",
"crates/immich_core_dart/cbindgen.toml",
"immich_native_core/ffigen.yaml",
]
outputs = [
"crates/immich_core_dart/include/immich_core.h",
"immich_native_core/lib/immich_native_core_bindings_generated.dart",
]
run = [
"cargo build -p immich_core_dart",
"cd immich_native_core && dart run ffigen --config ffigen.yaml && dart format lib/immich_native_core_bindings_generated.dart",
]
# Host FFI roundtrip through the real build hook — no device. Builds the Rust crate
# via rustup + resolves the @Native code asset.
[tasks."test:flutter"]
description = "Host FFI roundtrip via the build hook (flutter test)"
dir = "immich_native_core"
run = "flutter test"
[tasks."build:dart"]
description = "Build the dart:ffi cdylib directly (host, raw cargo)"
run = "cargo build -p immich_core_dart"
[tasks."build:napi"]
description = "Build the node addon + stage a .node for require() (server, unwired)"
run = [
"cargo build -p immich_core_napi --release",
"cp target/release/libimmich_core_napi.dylib smoke/immich_core_napi.node 2>/dev/null || cp target/release/libimmich_core_napi.so smoke/immich_core_napi.node",
]
[tasks."smoke:dart"]
description = "Host dart:ffi ABI roundtrip (raw DynamicLibrary on the cdylib)"
depends = ["build:dart"]
run = "dart run smoke/dart_smoke.dart target/debug/libimmich_core_dart.dylib"
[tasks."smoke:node"]
description = "Host napi roundtrip"
depends = ["build:napi"]
run = "node smoke/node_smoke.mjs"
[tasks.smoke]
description = "Rust tests + host dart:ffi + host napi roundtrips"
depends = ["test", "smoke:dart", "smoke:node"]
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Cross-build the napi addon for Linux server (x86_64 + aarch64) via zigbuild
# (no Docker) and stage as .node under dist/server/<target>/.
# In CI you'd build these natively per-arch instead; this is local convenience.
set -euo pipefail
cd "$(dirname "$0")/.."
CRATE=immich_core_napi
for t in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
rustup target add "$t" >/dev/null 2>&1 || true
cargo zigbuild -p "$CRATE" --target "$t" --release
mkdir -p "dist/server/$t"
cp "target/$t/release/lib${CRATE}.so" "dist/server/$t/immich_core_napi.node"
done
echo "linux -> dist/server/*/immich_core_napi.node"
+31
View File
@@ -0,0 +1,31 @@
// Mobile-side roundtrip: open the dart:ffi cdylib and call into the shared core.
// Standalone script (no package:ffi dep) reads the returned C string by hand.
//
// dart run smoke/dart_smoke.dart target/debug/libimmich_core_dart.dylib
import 'dart:ffi';
typedef _VersionNative = Pointer<Uint8> Function();
typedef _FreeNative = Void Function(Pointer<Uint8>);
typedef _FreeDart = void Function(Pointer<Uint8>);
String _readCString(Pointer<Uint8> p) {
final bytes = <int>[];
for (var i = 0; p[i] != 0; i++) {
bytes.add(p[i]);
}
return String.fromCharCodes(bytes);
}
void main(List<String> args) {
final libPath = args.isNotEmpty ? args.first : 'target/debug/libimmich_core_dart.dylib';
final lib = DynamicLibrary.open(libPath);
final version = lib.lookupFunction<_VersionNative, _VersionNative>('immich_core_version');
final free = lib.lookupFunction<_FreeNative, _FreeDart>('immich_core_free_string');
final ptr = version();
print('DART core_version = ${_readCString(ptr)}');
free(ptr);
print('DART roundtrip OK');
}
+17
View File
@@ -0,0 +1,17 @@
// Server-side roundtrip: load the napi addon and call into the shared core.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const core = require('./immich_core_napi.node');
const version = core.coreVersion();
console.log(`NAPI core_version = ${version}`);
const hash = core.sha1Hex(Buffer.from('abc'));
console.log(`NAPI sha1Hex("abc") = ${hash}`);
if (hash !== 'a9993e364706816aba3e25717850c26c9cd0d89d') {
console.error('NAPI sha1 mismatch');
process.exit(1);
}
console.log('NAPI roundtrip OK');
+1 -1
View File
@@ -16206,7 +16206,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "3.0.0",
"version": "3.0.0-rc.4",
"contact": {}
},
"tags": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "3.0.0",
"version": "3.0.0-rc.4",
"description": "Monorepo for Immich",
"type": "module",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "3.0.0",
"version": "3.0.0-rc.4",
"description": "Command Line Interface (CLI) for Immich",
"repository": {
"type": "git",
+2 -6
View File
@@ -150,7 +150,7 @@ const methods = wrapper<Manifest>({
changes: { asset: { visibility: config.visibility as AssetVisibility } },
}),
webhook: ({ config, data, functions, type, trigger }) => {
webhook: ({ config, data, functions }) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
@@ -161,11 +161,7 @@ const methods = wrapper<Manifest>({
functions.httpRequest(config.url, {
method: config.method ?? 'POST',
body: JSON.stringify({
type,
trigger,
data,
}),
body: JSON.stringify(data.asset),
headers,
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "3.0.0",
"version": "3.0.0-rc.4",
"description": "Auto-generated TypeScript SDK for the Immich API",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 3.0.0
* 3.0.0-rc.4
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "3.0.0",
"version": "3.0.0-rc.4",
"description": "",
"author": "",
"private": true,

Some files were not shown because too many files have changed in this diff Show More