mirror of
https://github.com/immich-app/immich.git
synced 2026-06-22 06:42:27 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46b44ba577 |
@@ -15,14 +15,14 @@ When using "Deduplicate All" or viewing suggestions, Immich automatically presel
|
||||
|
||||
### Synchronizing metadata
|
||||
|
||||
When resolving duplicates, metadata from trashed assets is automatically synchronized to the kept asset. This synchronization only happens when **exactly one** asset is kept and at least one asset is trashed. When more than one asset is kept, metadata is not merged — the assets keep their own metadata and are simply removed from the duplicate group. The following metadata is synchronized:
|
||||
When resolving duplicates, metadata from trashed assets is automatically synchronized to the kept assets. The following metadata is synchronized:
|
||||
|
||||
| Name | Description |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Album | The kept asset will be added to _every_ album that the other assets in the group belong to. |
|
||||
| Favorite | If any of the assets in the group have been added to favorites, the kept asset will also be added to favorites. |
|
||||
| Rating | If one or more assets in the duplicate group have a rating, the highest rating is selected and synchronized to the kept asset. |
|
||||
| Description | Descriptions from each asset are combined together and synchronized to the kept asset. |
|
||||
| Visibility | The most restrictive visibility is applied to the kept asset. |
|
||||
| Location | Latitude and longitude are copied if all assets with geolocation data in the group share the same coordinates. |
|
||||
| Tag | Tags from all assets in the group are merged and applied to the kept asset. |
|
||||
| Name | Description |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Album | The kept assets will be added to _every_ album that the other assets in the group belong to. |
|
||||
| Favorite | If any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. |
|
||||
| Rating | If one or more assets in the duplicate group have a rating, the highest rating is selected and synchronized to the kept assets. |
|
||||
| Description | Descriptions from each asset are combined together and synchronized to all the kept assets. |
|
||||
| Visibility | The most restrictive visibility is applied to the kept assets. |
|
||||
| Location | Latitude and longitude are copied if all assets with geolocation data in the group share the same coordinates. |
|
||||
| Tag | Tags from all assets in the group are merged and applied to every kept asset. |
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||
|
||||
[[tools.wrangler]]
|
||||
version = "4.100.0"
|
||||
version = "4.98.0"
|
||||
backend = "npm:wrangler"
|
||||
|
||||
[tools.wrangler.options]
|
||||
|
||||
+1
-1
@@ -28,4 +28,4 @@ run = "prettier --write ."
|
||||
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
||||
|
||||
[tools]
|
||||
wrangler = "4.100.0"
|
||||
wrangler = "4.98.0"
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v3.0.0-rc.2",
|
||||
"url": "https://docs.v3.0.0-rc.2.archive.immich.app"
|
||||
"label": "v3.0.0-rc.1",
|
||||
"url": "https://docs.v3.0.0-rc.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.7.5",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "3.0.0-rc.2",
|
||||
"version": "3.0.0-rc.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -192,14 +192,11 @@
|
||||
"maintenance_delete_backup": "Выдаліць рэзервовую копію",
|
||||
"maintenance_delete_backup_description": "Гэты файл будзе беззваротна выдалены.",
|
||||
"maintenance_delete_error": "Не атрымалася выдаліць рэзервовую копію.",
|
||||
"maintenance_integrity_check": "Праверыць",
|
||||
"maintenance_integrity_check_all": "Праверыць усе",
|
||||
"maintenance_integrity_checksum_mismatch": "Несупадзенне кантрольнай сумы",
|
||||
"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_restore_backup": "Аднавіць рэзервовую копію",
|
||||
|
||||
@@ -189,23 +189,18 @@
|
||||
"machine_learning_smart_search_enabled": "Включване на Интелигентно Търсене",
|
||||
"machine_learning_smart_search_enabled_description": "Ако е деактивирано, изображенията няма да бъдат кодирани за Интелигентно Търсене.",
|
||||
"machine_learning_url_description": "URL на сървъра за машинно обучение. Ако са предоставени повече от един URL, всеки сървър ще бъде опитан един по един, докато един отговори успешно, в реда от първия до последния. Сървъри, които не отговорят, ще бъдат временно игнорирани, докато не се върнат онлайн.",
|
||||
"maintenance_backup_management": "Управление на архивирането",
|
||||
"maintenance_delete_backup": "Изтриване на архив",
|
||||
"maintenance_delete_backup_description": "Този файл ще бъде безвъзвратно изтрит.",
|
||||
"maintenance_delete_error": "Неуспешно изтриване на архив.",
|
||||
"maintenance_integrity_check": "Проверка",
|
||||
"maintenance_integrity_check_all": "Провери всички",
|
||||
"maintenance_integrity_checksum_mismatch": "Несъответствие на контролната сума",
|
||||
"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": "Възстановяване на архив",
|
||||
|
||||
@@ -189,23 +189,18 @@
|
||||
"machine_learning_smart_search_enabled": "Povolit chytré vyhledávání",
|
||||
"machine_learning_smart_search_enabled_description": "Pokud je vypnuto, obrázky nebudou kódovány pro inteligentní vyhledávání.",
|
||||
"machine_learning_url_description": "URL serveru strojového učení. Pokud je zadáno více URL adres, budou jednotlivé servery zkoušeny postupně, dokud jeden z nich neodpoví úspěšně, a to v pořadí od prvního k poslednímu. Servery, které neodpoví, budou dočasně ignorovány, dokud nebudou opět online.",
|
||||
"maintenance_backup_management": "Správa záloh",
|
||||
"maintenance_delete_backup": "Smazat zálohu",
|
||||
"maintenance_delete_backup_description": "Tento soubor bude trvale smazán.",
|
||||
"maintenance_delete_error": "Nepodařilo se smazat zálohu.",
|
||||
"maintenance_integrity_check": "Zkontrolovat",
|
||||
"maintenance_integrity_check_all": "Zkontrolovat vše",
|
||||
"maintenance_integrity_checksum_mismatch": "Neshoda kontrolního součtu",
|
||||
"maintenance_integrity_checksum_mismatch_description": "Soubory, u nichž se kontrolní součet na disku neshoduje s kontrolním součtem, který Immich uložil do své databáze.",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Kontrola shod kontrolních součtů",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Obnovit hlášení o neshodách kontrolních součtů",
|
||||
"maintenance_integrity_missing_file": "Chybějící soubory",
|
||||
"maintenance_integrity_missing_file_description": "Soubory, které Immich sleduje ve své databázi, ale které neexistují v souborovém systému.",
|
||||
"maintenance_integrity_missing_file_job": "Kontrola chybějících souborů",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Obnovit hlášení o chybějících souborech",
|
||||
"maintenance_integrity_report": "Hlášení o integritě",
|
||||
"maintenance_integrity_untracked_file": "Nesledované soubory",
|
||||
"maintenance_integrity_untracked_file_description": "Soubory v adresářích služby Immich, o nichž nemá Immich žádný záznam.",
|
||||
"maintenance_integrity_untracked_file_job": "Kontrola nesledovaných souborů",
|
||||
"maintenance_integrity_untracked_file_refresh_job": "Obnovit hlášení o nesledovaných souborech",
|
||||
"maintenance_restore_backup": "Obnovit zálohu",
|
||||
|
||||
+3
-8
@@ -63,7 +63,7 @@
|
||||
"backup_onboarding_footer": "Weitere Informationen zum Sichern von Immich findest du in der <link>Dokumentation</link>.",
|
||||
"backup_onboarding_parts_title": "Eine 3-2-1-Sicherung umfasst:",
|
||||
"backup_onboarding_title": "Sicherungen",
|
||||
"backup_settings": "Einstellungen zur Datenbanksicherung",
|
||||
"backup_settings": "Einstellungen für Datenbanksicherung",
|
||||
"backup_settings_description": "Einstellungen zur regelmäßigen Sicherung der Datenbank. Hinweis: Diese Jobs werden nicht überwacht und du wirst nicht über Fehler informiert.",
|
||||
"cleared_jobs": "Folgende Aufgaben zurückgesetzt: {job}",
|
||||
"config_set_by_file": "Ist derzeit in einer Konfigurationsdatei festgelegt",
|
||||
@@ -189,24 +189,19 @@
|
||||
"machine_learning_smart_search_enabled": "Intelligente Suche aktivieren",
|
||||
"machine_learning_smart_search_enabled_description": "Ist diese Option deaktiviert, werden die Bilder nicht für die intelligente Suche verwendet.",
|
||||
"machine_learning_url_description": "Die URL des Servers für maschinelles Lernen. Wenn mehr als eine URL angegeben wird, wird jeder Server einzeln ausprobiert, bis einer erfolgreich antwortet, und zwar in der Reihenfolge vom ersten bis zum letzten. Server die nicht antworten werden temporär ignoriert, bis sie wieder verfügbar sind.",
|
||||
"maintenance_backup_management": "Backup Management",
|
||||
"maintenance_delete_backup": "Backup löschen",
|
||||
"maintenance_delete_backup_description": "Diese Datei wird irreversibel gelöscht.",
|
||||
"maintenance_delete_error": "Die Löschung der Sicherungskopie ist fehlgeschlagen.",
|
||||
"maintenance_integrity_check": "Überprüfe",
|
||||
"maintenance_integrity_check_all": "Überprüfe alle",
|
||||
"maintenance_integrity_checksum_mismatch": "Prüfsummenfehler",
|
||||
"maintenance_integrity_checksum_mismatch_description": "Dateien, deren Prüfsumme auf dem Datenträger nicht mit der übereinstimmt, die Immich in seiner Datenbank gespeichert hat.",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Auf Prüfsummenfehler überprüfen",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Auf Prüfsummen-Nichtübereinstimmungen prüfen",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Aktualisieren Sie Berichte über Prüfsummenkonflikte",
|
||||
"maintenance_integrity_missing_file": "Fehlende Dateien",
|
||||
"maintenance_integrity_missing_file_description": "Dateien, die Immich in seiner Datenbank erfasst hat, die aber im Dateisystem nicht vorhanden sind.",
|
||||
"maintenance_integrity_missing_file_job": "Auf fehlende Dateien prüfen",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Berichte über fehlende Dateien aktualisieren",
|
||||
"maintenance_integrity_report": "Integritätsbericht",
|
||||
"maintenance_integrity_untracked_file": "Nicht getrackte Dateien",
|
||||
"maintenance_integrity_untracked_file_description": "Dateien in Immichs Verzeichnissen, über die Immich keine Aufzeichnungen hat.",
|
||||
"maintenance_integrity_untracked_file_job": "Auf nicht verfolgte Dateien prüfen",
|
||||
"maintenance_integrity_untracked_file_job": "Überprüfen Sie ungetrackte Dateien",
|
||||
"maintenance_integrity_untracked_file_refresh_job": "Berichte über nicht getrackte Dateien aktualisieren",
|
||||
"maintenance_restore_backup": "Sicherungskopie wiederherstellen",
|
||||
"maintenance_restore_backup_description": "Immich wird zurückgesetzt und von der ausgewählten Sicherungskopie wiederhergestellt. Ein Backup wird erstellt, bevor es weitergeht.",
|
||||
|
||||
+1
-2
@@ -1548,7 +1548,7 @@
|
||||
"map_location_picker_page_use_location": "Use this location",
|
||||
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
|
||||
"map_location_service_disabled_title": "Location Service disabled",
|
||||
"map_marker_for_images": "Map marker for images taken in {city}, {country}",
|
||||
"map_marker_for_image": "Map marker for image taken in {city}, {country}",
|
||||
"map_marker_with_image": "Map marker with image",
|
||||
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
|
||||
"map_no_location_permission_title": "Location Permission denied",
|
||||
@@ -2443,7 +2443,6 @@
|
||||
"upload": "Upload",
|
||||
"upload_concurrency": "Upload concurrency",
|
||||
"upload_day_count": "{date}: {count, plural, one {# upload} other {# uploads}}",
|
||||
"upload_deferred_edit_pair": "Waiting for the original photo, will retry automatically",
|
||||
"upload_details": "Upload Details",
|
||||
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
||||
"upload_dialog_title": "Upload Asset",
|
||||
|
||||
+2
-10
@@ -79,7 +79,6 @@
|
||||
"cron_expression_description": "Agordu la intervalon de analizado pere de la formato de cron. Por pli da informoj, legu ekzemple <link>Crontab Guru</link>",
|
||||
"cron_expression_presets": "Antaŭagordoj pri la cron-esprimo",
|
||||
"disable_login": "Malebligi ensalutadon",
|
||||
"download_csv": "Elŝuti .csv-dosieron",
|
||||
"duplicate_detection_job_description": "Komenci permaŝin-lernadon por trovi similajn bildojn. Uzas 'inteligentan serĉadon'",
|
||||
"exclusion_pattern_description": "Per skemo de ekskludo, vi povas ignori dosierojn kaj dosierujojn dum analizado de la biblioteko. Tio estas utila se vi havas ekz. RAW-dosierojn, kiujn vi ne volas importi.",
|
||||
"export_config_as_json_description": "Elŝuti la aktualan sistem-agordaĵaron kiel JSON-dosieron",
|
||||
@@ -189,16 +188,9 @@
|
||||
"machine_learning_smart_search_enabled": "Ŝalti inteligentan serĉadon",
|
||||
"machine_learning_smart_search_enabled_description": "Se malŝaltita, tiam bildoj ne estos kodigitaj por inteligenta serĉado.",
|
||||
"machine_learning_url_description": "La URL-o de la maŝin-lerna servilo. Se vi donas pli ol unu URL-o, la sistemo provos ĉiun servilon unu post la alia ĝis kiam unu sukcese respondas, de la unua ĝis la lasta. Serviloj, kiuj ne respondas, estos dumtempe ignoritaj.",
|
||||
"maintenance_backup_management": "Administrado de savkopioj",
|
||||
"maintenance_delete_backup": "Forigi savkopion",
|
||||
"maintenance_delete_backup_description": "La dosiero estos por ĉiam forigita.",
|
||||
"maintenance_delete_error": "Malsukcesis forigi savkopion.",
|
||||
"maintenance_integrity_check": "Kontroli",
|
||||
"maintenance_integrity_check_all": "Kontroli ĉiujn",
|
||||
"maintenance_integrity_checksum_mismatch": "Nekongruaj kontrolsumoj",
|
||||
"maintenance_integrity_checksum_mismatch_description": "Dosieroj pri kiuj la kontrolsumo stokita sur disko ne kongruas kun tiu en la datumbazo de Immich.",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Kontroli pri nekongruaj kontrolsumoj",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Refreŝigi la raporton pri nekongruaj kontrolsumoj",
|
||||
"maintenance_restore_backup": "Restaŭri savkopion",
|
||||
"maintenance_restore_backup_description": "Immich estos forigita kaj reinstalita de la elektita savkopio. Nova savkopio estos kreita antaŭe.",
|
||||
"maintenance_restore_backup_different_version": "Tiu ĉi savkopio estis kreita per alia versio de Immich!",
|
||||
@@ -1672,14 +1664,14 @@
|
||||
"no_explore_results_message": "Alŝutu pli da fotoj por esplori vian kolekton.",
|
||||
"no_favorites_message": "Aldoni al Preferataĵoj por rapide retrovi viajn plej bonajn bildojn kaj videojn",
|
||||
"no_libraries_message": "Krei eksteran biblitekon por vidi viajn fotojn kaj videojn",
|
||||
"no_local_assets_found": "Neniu loka elemento trovita kun tiu kontrolsumo",
|
||||
"no_local_assets_found": "Neniuj lokaj elementoj trovitaj kun tiu kontrolsumo",
|
||||
"no_location_set": "Neniu loko agordita",
|
||||
"no_locked_photos_message": "Fotoj kaj videoj en la ŝlosita dosierujo estas kaŝitaj, kaj ne montriĝos dum vi foliumas aŭ serĉas en via biblioteko.",
|
||||
"no_name": "Neniu nomo",
|
||||
"no_notifications": "Neniuj sciigoj",
|
||||
"no_people_found": "Neniuj kongruaj homoj trovitaj",
|
||||
"no_places": "Neniuj lokoj",
|
||||
"no_remote_assets_found": "Neniu fora elemento trovita kun tiu kontrolsumo",
|
||||
"no_remote_assets_found": "Neniuj foraj elementoj trovitaj kun tiu kontrolsumo",
|
||||
"no_results": "Neniuj rezultoj",
|
||||
"no_results_description": "Provu sinonimon aŭ pli ĝeneralan ŝlosilvorton",
|
||||
"no_shared_albums_message": "Krei albumon por dividi fotojn kaj videojn kun konatoj",
|
||||
|
||||
+2
-2
@@ -1500,7 +1500,7 @@
|
||||
"logout_this_device_confirmation": "¿Estás seguro de que quieres cerrar sesión en este dispositivo?",
|
||||
"logs": "Registros",
|
||||
"longitude": "Longitud",
|
||||
"look": "Aspecto",
|
||||
"look": "Mirar",
|
||||
"loop_videos": "Vídeos en bucle",
|
||||
"loop_videos_description": "Habilite la reproducción automática de un video en el visor de detalles.",
|
||||
"main_branch_warning": "Está utilizando una versión de desarrollo; ¡le recomendamos encarecidamente que utilice una versión de lanzamiento!",
|
||||
@@ -2075,7 +2075,7 @@
|
||||
"search_places": "Buscar lugar",
|
||||
"search_rating": "Buscar por calificación...",
|
||||
"search_result_page_new_search_hint": "Nueva Búsqueda",
|
||||
"search_settings": "Buscar ajustes",
|
||||
"search_settings": "Ajustes de búsqueda",
|
||||
"search_state": "Buscar región/estado...",
|
||||
"search_suggestion_list_smart_search_hint_1": "La búsqueda inteligente está habilitada por defecto, para buscar metadatos utiliza esta sintaxis ",
|
||||
"search_suggestion_list_smart_search_hint_2": "m:tu-término-de-búsqueda",
|
||||
|
||||
+2
-458
@@ -79,7 +79,6 @@
|
||||
"cron_expression_description": "Ezarri eskaneatzeko tartea cron formatua erabiliz. Informazio gehiago lortzeko, jo mesedez <link>Crontab Guru</link> adibidera",
|
||||
"cron_expression_presets": "Cron adierazpenaren aurrezarpenak",
|
||||
"disable_login": "Desgaitu saio hastea",
|
||||
"download_csv": "CSVa deskargatu",
|
||||
"duplicate_detection_job_description": "Exekutatu ikasketa automatikoa aktiboetan antzeko irudiak detektatzeko. Bilaketa Adimendunean oinarritzen da",
|
||||
"exclusion_pattern_description": "Bazterketa patroiek fitxategiak eta karpetak alde batera uzteko aukera ematen dizute liburutegia eskaneatzean. Oso erabilgarria da inportatu nahi ez dituzun fitxategiak dituzten karpetak badituzu, adibidez RAW fitxategiak.",
|
||||
"export_config_as_json_description": "Deskargatu momentuko sistema konfigurazioa JSON fitxategi moduan",
|
||||
@@ -88,23 +87,12 @@
|
||||
"face_detection_description": "Hauteman aurpegiak elementuetan ikasketa automatikoa erabiliz. Bideoen kasuan, miniatura baino ez da kontuan hartuko. \"Freskatu\" aukerak elementu guztiak (bir-)prozesatzen ditu. \"Berrezarri\" aukerak horrez gain, uneko aurpegi-datu guztiak ezabatzen ditu. \"Falta direnak\" aukerak oraindik prozesatu ez diren elementuak jartzen ditu ilaran. Detektatutako aurpegiak Aurpegiaren Ezagutzarako ilaran jarriko dira Aurpegiaren Detekzioa amaitu ondoren, lehendik dauden edo berriak diren pertsonetan multzokatuz.",
|
||||
"facial_recognition_job_description": "Detektatutako aurpegiak pertsonetan multzokatu. Urrats hau Aurpegi Detekzioaren ondoren egikaritzen da. \"Berrezarri\" aukerak aurpegi guztiak (bir-)taldekatzen ditu. \"Faltan\" aukerak izendatu gabeko aurpegiak ilaran jartzen ditu.",
|
||||
"failed_job_command": "{command} komandoak hutsegin du {job} zereginerako",
|
||||
"force_delete_user_warning": "ADI: Honek erabiltzailea eta baliabide guztiak ezabatuko lituzke. Akzio hau ezin da atzera bota eta fitxategiak ezin izango dira berreskuratu.",
|
||||
"image_format": "Formatua",
|
||||
"image_format_description": "WebP ereduak JPEG baino fitxategi txikiagoak sortzen ditu, baina motelagoa da kodifikatzen.",
|
||||
"image_fullsize_description": "Tamaina osoko irudia metadaturik gabe, zoom egiterakoan erabiltzen da",
|
||||
"image_fullsize_enabled": "Tamaina osoko irudien sorrera ezarri",
|
||||
"image_fullsize_enabled_description": "Sortu tamaina osoko irudia web-erako egokiak ez diren formatuetan. \"Nahiago aurrebista txertatua\" gaituta dagoenean, aurrebista txertatuak zuzenean erabiltzen dira, bihurtu gabe. Ez die eragiten web-erako egokiak diren formatuei (JPEG, adibidez).",
|
||||
"image_fullsize_quality_description": "Tamaina osoko irudiaren kalitatea (1-100). Zenbat eta altuagoa, orduan eta hobea, baina fitxategi handiagoak sortzen ditu.",
|
||||
"image_fullsize_title": "Tamaina osoko irudien ezarpenak",
|
||||
"image_prefer_embedded_preview": "Nahiago aurrebista txertatua",
|
||||
"image_prefer_embedded_preview_setting_description": "Erabili RAW argazkien aurrebista txertatuak irudiak prozesatzeko abiapuntu gisa (eskuragarri daudenean). Honek kolore zehatzagoak eman ditzake irudi batzuetan, baina aurrebistaren kalitatea kameraren araberakoa da eta irudiak konpresio-artefaktu gehiago izan ditzake.",
|
||||
"image_prefer_wide_gamut": "Nahiago gamut zabala",
|
||||
"image_prefer_wide_gamut_setting_description": "Erabili Display P3 miniaturentzako (thumbnails). Honek hobeto mantentzen du kolore-espazio zabaleko irudien bizitasuna, baina irudiak ezberdin ikus daitezke arakatzaile bertsio zaharra duten gailu zaharkituetan. sRGB irudiak sRGB gisa mantentzen dira kolore-aldaketak saihesteko.",
|
||||
"image_preview_description": "Tamaina baxuko irudia metadaturik gabe, baliabide bakarra bistaratzerakoan eta ikasketa automatikoan erabiltzeko",
|
||||
"image_preview_quality_description": "Aurrebisten kalitatea (1-100). Zenbat eta altuagoa, orduan eta hobea, baina fitxategi handiagoak sortzen ditu eta aplikazioaren jarioa moteldu dezake. Balio baxu bat ezartzeak ikasketa automatikoaren kalitatea kaltetu dezake.",
|
||||
"image_preview_title": "Aurreikusiaen Konfigurazioa",
|
||||
"image_progressive": "Progresiboa",
|
||||
"image_progressive_description": "Kodetu JPEG irudiak progresiboki, pixkanaka kargatzen joan daitezen. Honek ez du eraginik WebP irudietan.",
|
||||
"image_quality": "Kalitatea",
|
||||
"image_resolution": "Erresoluzioa",
|
||||
"image_settings": "Argazkien Konfigurazioa",
|
||||
@@ -148,470 +136,26 @@
|
||||
"oauth_storage_label_claim": "Memoriaren etiketa eskaera",
|
||||
"oauth_storage_label_claim_description": "Erabiltzailearen memoria-etiketa automatikoki finkatzea, eskatutako balioan.",
|
||||
"oauth_storage_quota_claim": "Eskatutako memoriaren kuota",
|
||||
"template_email_preview": "Aurrebista",
|
||||
"transcoding_acceleration_vaapi": "VAAPI",
|
||||
"transcoding_threads": "Hariak",
|
||||
"transcoding_tone_mapping": "Tonoen mapeoa"
|
||||
"transcoding_acceleration_vaapi": "VAAPI"
|
||||
},
|
||||
"advanced": "Aurreratua",
|
||||
"advanced_settings_readonly_mode_title": "Irakurri-soilik modua",
|
||||
"advanced_settings_troubleshooting_title": "Arazoak detektatzea eta konpontzea",
|
||||
"album_info_card_backup_album_excluded": "BAZTERTUTAKOAK",
|
||||
"album_info_card_backup_album_included": "BARNEKOAK",
|
||||
"album_viewer_appbar_share_leave": "Albuma utzi",
|
||||
"album_viewer_appbar_share_to": "Albuma partekatu",
|
||||
"album_viewer_page_share_add_users": "Erabiltzaileak gehitu",
|
||||
"anti_clockwise": "Erloju-orratzen noranzkoaren aurka",
|
||||
"app_bar_signout_dialog_ok": "Bai",
|
||||
"app_bar_signout_dialog_title": "Saioa itxi",
|
||||
"apply_count": "Ezarri ({count, number})",
|
||||
"archive": "Artxibo",
|
||||
"archive_page_title": "Artxibo ({count})",
|
||||
"archived": "Artxibatua",
|
||||
"asset_hashing": "Hasha kalkulatzen…",
|
||||
"asset_list_group_by_sub_title": "Multzokatu",
|
||||
"asset_list_layout_settings_dynamic_layout_title": "Diseinu dinamikoa",
|
||||
"asset_list_layout_settings_group_automatically": "Automatikoa",
|
||||
"asset_list_layout_sub_title": "Antolaketa",
|
||||
"asset_list_settings_title": "Irudi lauki-sarea",
|
||||
"asset_skipped": "Alde batera utzita",
|
||||
"asset_uploaded": "Igota",
|
||||
"asset_uploading": "Igotzen…",
|
||||
"asset_viewer_settings_title": "Baliabide ikuslea",
|
||||
"assets": "Baliabideak",
|
||||
"assets_added_to_albums_count": "Gehituta {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}",
|
||||
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} ezin izan da albumetara gehitu",
|
||||
"assets_were_part_of_albums_count": "{count, plural, one {Asset was} other {Assets were}} dagoeneko albumean dago",
|
||||
"back": "Atzera",
|
||||
"backup": "Babes-kopia",
|
||||
"backup_album_selection_page_select_albums": "Albumak aukeratu",
|
||||
"backup_album_selection_page_selection_info": "Aukeraren informazioa",
|
||||
"backup_all": "Denak",
|
||||
"backup_background_service_current_upload_notification": "{filename} igotzen",
|
||||
"backup_background_service_error_title": "Akatsa babes-kopia egiterakoan",
|
||||
"backup_controller_page_albums": "Seguratsun-kopia albumak",
|
||||
"backup_controller_page_background_battery_info_ok": "Onartu",
|
||||
"backup_controller_page_background_battery_info_title": "Bateria optimizazioak",
|
||||
"backup_controller_page_backup": "Babes-kopia",
|
||||
"backup_controller_page_backup_selected": "Aukeratutakoa: ",
|
||||
"backup_controller_page_excluded": "Baztertutakoa: ",
|
||||
"backup_controller_page_failed": "Akatsak ({count})",
|
||||
"backup_controller_page_id": "ID: {id}",
|
||||
"backup_controller_page_info": "Segurtasun-kopiaren informazioa",
|
||||
"backup_controller_page_none_selected": "Aukerarik ez",
|
||||
"backup_controller_page_remainder": "Gainerakoak",
|
||||
"backup_controller_page_server_storage": "Zerbitzariko memoria",
|
||||
"backup_controller_page_start_backup": "Segurtasun-kopia hasi",
|
||||
"backup_info_card_assets": "baliabide",
|
||||
"backup_manual_cancelled": "Ezeztatuta",
|
||||
"backup_manual_success": "Arrakastatsua",
|
||||
"backup_manual_title": "Igoera egoera",
|
||||
"backup_options_page_title": "Babes-kopia ezarpenak",
|
||||
"backward": "Atzeruntz",
|
||||
"build": "Bertsioa",
|
||||
"cache_settings_clear_cache_button": "Cache-memoria garbitu",
|
||||
"cache_settings_duplicated_assets_clear_button": "GARBITU",
|
||||
"cache_settings_statistics_album": "Liburutegiaren miniaturak",
|
||||
"cache_settings_statistics_full": "Tamainu osoko irudiak",
|
||||
"cache_settings_statistics_thumbnail": "Miniatura",
|
||||
"cache_settings_statistics_title": "Cache-memoria erabilera",
|
||||
"cache_settings_tile_title": "Memoria lokala",
|
||||
"cache_settings_title": "Cache-memoria ezarpenak",
|
||||
"camera": "Kamera",
|
||||
"cancel": "Ezeztatu",
|
||||
"canceled": "Ezeztatua",
|
||||
"canceling": "Desgaitzen",
|
||||
"cast": "Transmititu",
|
||||
"change_password_form_confirm_password": "Pasahitza baieztu",
|
||||
"change_password_form_new_password": "Pasahitza berria",
|
||||
"check_corrupt_asset_backup_button": "Egiaztapena burutu",
|
||||
"city": "Hiria",
|
||||
"clear": "Garbitu",
|
||||
"client_cert_dialog_msg_confirm": "Ok",
|
||||
"client_cert_enter_password": "Pasahitza sartu",
|
||||
"client_cert_import": "Inportatu",
|
||||
"clockwise": "Erloju-orratzen noranzkoan",
|
||||
"close": "Itxi",
|
||||
"collapse": "Taldekatu",
|
||||
"color": "Kolorea",
|
||||
"completed": "Burututa",
|
||||
"confirm": "Onartu",
|
||||
"contain": "Egokitu",
|
||||
"context": "Kontextua",
|
||||
"continue": "Jarraitu",
|
||||
"control_bottom_app_bar_edit_location": "Kokapena eguneratu",
|
||||
"control_bottom_app_bar_share_link": "Esteka partekatu",
|
||||
"control_bottom_app_bar_share_to": "Partekatu",
|
||||
"country": "Herrialdea",
|
||||
"cover": "Portada",
|
||||
"covers": "Portada",
|
||||
"create": "Sortu",
|
||||
"create_album_page_untitled": "Izengabea",
|
||||
"create_new": "BERRIA SORTU",
|
||||
"create_shared_album_page_share_add_assets": "BALIABIDEAK GEHITU",
|
||||
"create_shared_album_page_share_select_photos": "Argazkiak aukeratu",
|
||||
"created": "Sortuta",
|
||||
"created_at": "Sortze-data",
|
||||
"crop": "Ebaki",
|
||||
"curated_object_page_title": "Objektuak",
|
||||
"dark": "Iluna",
|
||||
"day": "Eguna",
|
||||
"days": "Egunak",
|
||||
"delete": "Ezabatu",
|
||||
"delete_dialog_ok_force": "Hala ere ezabatu",
|
||||
"delete_dialog_title": "Behin betiko ezabatu",
|
||||
"delete_local_dialog_ok_force": "Hala ere ezabatu",
|
||||
"description": "Deskribapena",
|
||||
"description_input_hint_text": "Deskribapena ezarri…",
|
||||
"details": "Xehetasunak",
|
||||
"direction": "Norabidea",
|
||||
"disabled": "Desgaituta",
|
||||
"discord": "Discord",
|
||||
"discover": "Aurkitu",
|
||||
"documentation": "Dokumentazioa",
|
||||
"done": "Amaitu",
|
||||
"download": "Deskargatu",
|
||||
"download_canceled": "Deskarga ezeztatuta",
|
||||
"download_complete": "Deskarga burututa",
|
||||
"download_enqueue": "Deskarga ilaran",
|
||||
"download_error": "Akatsa deskargatzerakoan",
|
||||
"download_failed": "Akatsa deskargatzerakoan",
|
||||
"download_finished": "Deskarga burututa",
|
||||
"download_paused": "Deskarga geldituta",
|
||||
"download_settings": "Deskargak",
|
||||
"download_started": "Deskarga hasieratua",
|
||||
"download_sucess": "Deskarga arrakastatsua",
|
||||
"downloading": "Deskargatzen",
|
||||
"downloading_media": "Baliabideak deskargatzen",
|
||||
"duplicates": "Kopiak",
|
||||
"duration": "Iraupena",
|
||||
"edit": "Editatu",
|
||||
"edit_location_dialog_title": "Kokapena",
|
||||
"editor": "Editorea",
|
||||
"email": "E-mail",
|
||||
"enable": "Gaitu",
|
||||
"enabled": "Gaituta",
|
||||
"enqueued": "Ilaran gehituta",
|
||||
"error": "Akatsa",
|
||||
"error_saving_image": "Akatsa: {error}",
|
||||
"exif": "Exif",
|
||||
"exif_bottom_sheet_description": "Deskribapena ezarri…",
|
||||
"exif_bottom_sheet_details": "XEHETASUNAK",
|
||||
"exif_bottom_sheet_location": "KOKAPENA",
|
||||
"exif_bottom_sheet_people": "PERTSONAK",
|
||||
"exif_bottom_sheet_person_add_person": "Izena gehitu",
|
||||
"experimental_settings_title": "Esperimental",
|
||||
"expired": "Iraungita",
|
||||
"explore": "Bilatu",
|
||||
"explorer": "Bilatzailea",
|
||||
"export": "Esportatu",
|
||||
"extension": "Hedapena",
|
||||
"external": "Kanpokoa",
|
||||
"external_network": "Kanpoko sarea",
|
||||
"face_unassigned": "Ezarri gabea",
|
||||
"failed": "Akatsduna",
|
||||
"favorite": "Gogokoa",
|
||||
"favorites": "Gogokoenak",
|
||||
"features": "Ezaugarriak",
|
||||
"filename": "Fitxategia",
|
||||
"filetype": "Fitxategi mota",
|
||||
"filter": "Iragazkia",
|
||||
"first": "Lehenengo «Lehenik»",
|
||||
"folder": "Karpeta",
|
||||
"folders": "Karpetak",
|
||||
"forward": "Aurreruntz",
|
||||
"general": "General",
|
||||
"gps": "GPS",
|
||||
"gps_missing": "Ez dago GPS",
|
||||
"grant_permission": "Baimendu",
|
||||
"haptic_feedback_title": "Ukipen-feedbacka",
|
||||
"hashing": "Hasha sortzen",
|
||||
"header_settings_add_header_tip": "Goiburua gehitu",
|
||||
"header_settings_header_name_input": "Goiburu izena",
|
||||
"header_settings_header_value_input": "Goiburu balioa",
|
||||
"host": "Host",
|
||||
"hour": "Ordua",
|
||||
"hours": "Orduak",
|
||||
"id": "ID",
|
||||
"idle": "Jarduerarik gabe",
|
||||
"image": "Irudia",
|
||||
"image_saved_successfully": "Irudia gordeta",
|
||||
"image_viewer_page_state_provider_download_started": "Deskarga hasieratua",
|
||||
"image_viewer_page_state_provider_download_success": "Deskarga arrakastatsua",
|
||||
"info": "Informazioa",
|
||||
"jobs": "Atazak",
|
||||
"keep": "Mantendu",
|
||||
"language": "Hizkuntza",
|
||||
"last": "Azkena",
|
||||
"latitude": "Latitudea",
|
||||
"leave": "Bertan behera utzi",
|
||||
"level": "Maila",
|
||||
"library": "Liburutegia",
|
||||
"licenses": "Lizentziak",
|
||||
"light": "Argia",
|
||||
"like": "Gustoko",
|
||||
"list": "Zerrenda",
|
||||
"loading": "Kargatzen",
|
||||
"local": "Lokal",
|
||||
"lock": "Blokeatu",
|
||||
"login": "Saioa hasi",
|
||||
"login_form_back_button_text": "Atzera",
|
||||
"login_form_email_hint": "zure@emaila.com",
|
||||
"login_form_endpoint_hint": "http://zerbitzariaren-ip-a:portua",
|
||||
"login_form_password_hint": "pasahitza",
|
||||
"logs": "Erregistroak",
|
||||
"longitude": "Longitudea",
|
||||
"look": "Itxura",
|
||||
"main_menu": "Menu nagusia",
|
||||
"make": "Marka",
|
||||
"manage_geolocation": "Kudeatu kokapena",
|
||||
"map": "Mapa",
|
||||
"map_location_dialog_yes": "Bai",
|
||||
"matches": "Bat etorritakoak",
|
||||
"memories": "Gogorapenak",
|
||||
"memory": "Gogorapena",
|
||||
"menu": "Menua",
|
||||
"merge": "Batu",
|
||||
"minimize": "Txikitu",
|
||||
"minute": "Minutua",
|
||||
"minutes": "Minutuak",
|
||||
"missing": "Hutsegiteak",
|
||||
"model": "Modeloa",
|
||||
"month": "Hilabetea",
|
||||
"more": "Gehiago",
|
||||
"move": "Mugitu",
|
||||
"name": "Izena",
|
||||
"networking_settings": "Sarea",
|
||||
"never": "Inoiz ez",
|
||||
"next": "Hurrengoa",
|
||||
"no": "Ez",
|
||||
"notes": "Oharra",
|
||||
"notifications": "Jakinarazpenak",
|
||||
"oauth": "OAuth",
|
||||
"offline": "Lineaz kanpo",
|
||||
"offset": "Desbiderapena",
|
||||
"ok": "Bai",
|
||||
"onboarding": "Ezartzen",
|
||||
"online": "Linean",
|
||||
"open": "Zabalik",
|
||||
"options": "Ezarpenak",
|
||||
"or": "edo",
|
||||
"organize_into_albums": "Albumetan antolatu",
|
||||
"original": "Originala",
|
||||
"other": "Beste batzuk",
|
||||
"owned": "Berezkoak",
|
||||
"owner": "Jabea",
|
||||
"partner": "Kidea",
|
||||
"partners": "Kideak",
|
||||
"password": "Pasahitza",
|
||||
"path": "Bidea",
|
||||
"pattern": "Patroia",
|
||||
"pause": "Gelditu",
|
||||
"paused": "Geldituta",
|
||||
"pending": "Itxarotzen",
|
||||
"people": "Pertsonak",
|
||||
"permission": "Baimena",
|
||||
"permission_onboarding_back": "Atzera",
|
||||
"person": "Pertsona",
|
||||
"photos": "Argazkiak",
|
||||
"place": "Tokia",
|
||||
"places": "Tokiak",
|
||||
"play": "Erreproduzitu",
|
||||
"port": "Portua",
|
||||
"preferences_settings_title": "Ezarpenak",
|
||||
"preset": "Txantiloia",
|
||||
"preview": "Aurrebista",
|
||||
"previous": "Aurrekoa",
|
||||
"primary": "Nagusia",
|
||||
"privacy": "Pribatutasuna",
|
||||
"profile": "Profila",
|
||||
"profile_drawer_app_logs": "Erregistroak",
|
||||
"profile_drawer_github": "GitHub",
|
||||
"purchase_account_info": "Laguntzaile",
|
||||
"purchase_button_activate": "Aktibatu",
|
||||
"purchase_button_buy": "Erosi",
|
||||
"purchase_button_select": "Aukeratu",
|
||||
"purchase_individual_title": "Banakakoa",
|
||||
"purchase_server_title": "Zerbitzaria",
|
||||
"query_asset_id": "Aztertu aukeratutako ID-a",
|
||||
"readonly_mode_disabled": "Irakurri-bakarrik modua desgaituta",
|
||||
"readonly_mode_enabled": "Irakurri-bakarrik modua gaituta",
|
||||
"reassign": "Berrezarri",
|
||||
"recent": "Berria",
|
||||
"refresh": "Freskatu",
|
||||
"refreshed": "Freskatuta",
|
||||
"remote": "Urruneko zerbitzaria",
|
||||
"remove": "Kendu",
|
||||
"rename": "Izena aldatu",
|
||||
"repair": "Konpondu",
|
||||
"repository": "Errepositorioa",
|
||||
"rescan": "Berreskaneatu",
|
||||
"reset": "Berrasieratu",
|
||||
"restore": "Zaharberritu",
|
||||
"resume": "Jarraitu",
|
||||
"role": "Rola",
|
||||
"role_editor": "Editorea",
|
||||
"role_viewer": "Ikuslea",
|
||||
"running": "Martxan",
|
||||
"save": "Gorde",
|
||||
"scan_library": "Eskaneatu",
|
||||
"search": "Bilatu",
|
||||
"search_filter_date": "Data",
|
||||
"search_filter_location": "Kokapena",
|
||||
"search_for": "Bilatu",
|
||||
"search_no_people": "Pertsonarik ez",
|
||||
"search_options": "Bilaketa ezarpenak",
|
||||
"search_page_categories": "Kategoriak",
|
||||
"search_page_screenshots": "Pantaila-argazkiak",
|
||||
"search_page_selfies": "Selfiak",
|
||||
"search_page_things": "Gauzak eta Animaliak",
|
||||
"search_people": "Pertsonak bilatu",
|
||||
"search_places": "Tokiak bilatu",
|
||||
"search_settings": "Ezarpenak bilatu",
|
||||
"search_state": "Probintziaren arabera bilatu…",
|
||||
"search_suggestion_list_smart_search_hint_2": "m:zure-bilaketa",
|
||||
"search_tags": "Etiketak bilatu…",
|
||||
"search_timezone": "Ordu-eremua bilatu…",
|
||||
"search_type": "Bilaketa mota",
|
||||
"searching_locales": "Tokiak bilatzen…",
|
||||
"second": "Segundua",
|
||||
"select": "Aukeratu",
|
||||
"select_all": "Dena aukeratu",
|
||||
"select_face": "Aurpegia hautatu",
|
||||
"select_photos": "Argazkiak aukeratu",
|
||||
"selected": "Aukeratuta",
|
||||
"selected_gps_coordinates": "GPS Koordenadak Aukeratuta",
|
||||
"send_message": "Mezua bidali",
|
||||
"server_offline": "Zerbitzaria lineaz kanpo",
|
||||
"server_online": "Zerbitzaria linean",
|
||||
"server_stats": "Zerbitzariaren estatistikak",
|
||||
"server_version": "Zerbitzariaren bertsioa",
|
||||
"set": "Ezarri",
|
||||
"setting_image_viewer_title": "Irudiak",
|
||||
"setting_languages_apply": "Ezarri",
|
||||
"setting_notifications_notify_immediately": "berehala",
|
||||
"setting_notifications_notify_never": "inoiz ez",
|
||||
"setting_video_viewer_looping_title": "Errepikatu",
|
||||
"settings": "Ezarpenak",
|
||||
"settings_saved": "Ezarpenak gordeta",
|
||||
"share": "Partekatu",
|
||||
"share_dialog_preparing": "Prestatzen...",
|
||||
"shared": "Partekatuta",
|
||||
"shared_album_section_people_title": "PERTSONAK",
|
||||
"shared_by": "Honek partekatuta",
|
||||
"shared_link_info_chip_metadata": "EXIF",
|
||||
"shared_links": "Partekatutako estekak",
|
||||
"sharing": "Partekatzen",
|
||||
"show_albums": "Albumak erakutsi",
|
||||
"show_gallery": "Galeria erakutsi",
|
||||
"show_metadata": "Metadatuak erakutsi",
|
||||
"show_password": "Pasahitza erakutsi",
|
||||
"show_supporter_badge": "Kolaboratzaile entseina",
|
||||
"shuffle": "Nahasi",
|
||||
"sidebar": "Alboko panela",
|
||||
"sign_out": "Saioa itxi",
|
||||
"sign_up": "Erregistratu",
|
||||
"size": "Tamaina",
|
||||
"slideshow": "Diapositibak",
|
||||
"slideshow_settings": "Diapositiba ezarpenak",
|
||||
"sort_created": "Sortze data",
|
||||
"sort_modified": "Egunatze data",
|
||||
"sort_newest": "Argazkirik berriena",
|
||||
"sort_oldest": "Argazkirik zaharrena",
|
||||
"sort_title": "Izenburua",
|
||||
"source": "Iturburua",
|
||||
"stack": "Multzokatu",
|
||||
"stack_duplicates": "Kopiak multzokatu",
|
||||
"stacktrace": "Pila jarraipena",
|
||||
"start": "Hasi",
|
||||
"start_date": "Hasiera data",
|
||||
"state": "Estatua / Probintzia",
|
||||
"status": "Egoera",
|
||||
"storage": "Memoria edukiera",
|
||||
"storage_label": "Memoria etiketa",
|
||||
"submit": "Bidali",
|
||||
"success": "Arrakastatsua",
|
||||
"suggestions": "Iradokizunak",
|
||||
"support": "Babesa",
|
||||
"sync": "Sinkronizazioa",
|
||||
"tag": "Etiketa",
|
||||
"tag_assets": "Baliabideak etiketatu",
|
||||
"tag_people": "Pertsonak etiketatu",
|
||||
"tags": "Etiketak",
|
||||
"template": "Txantiloia",
|
||||
"theme": "Gaia",
|
||||
"theme_selection": "Gaien aukeraketa",
|
||||
"third_party_resources": "Bitartekoen baliabideak",
|
||||
"time_based_memories": "Denboran oinarritutako gogorapenak",
|
||||
"timeline": "Kronologia",
|
||||
"timezone": "Ordu-eremua",
|
||||
"to_archive": "Artxibatu",
|
||||
"to_change_password": "Pasahitza aldatu",
|
||||
"to_favorite": "Gogokotzat ezarri",
|
||||
"to_login": "Saioa hasi",
|
||||
"to_select": "aukeratzeko",
|
||||
"to_trash": "Baztertu",
|
||||
"toggle_settings": "Ezarpenak aldizkatu",
|
||||
"total": "Guztira",
|
||||
"total_usage": "Erabilpen osoa",
|
||||
"trash": "Zakarrontzia",
|
||||
"trash_all": "Denak ezabatu",
|
||||
"trash_delete_asset": "Baliabidea ezabatu/zakarrontzira eraman",
|
||||
"troubleshoot": "Arazoak konpontzea",
|
||||
"type": "Mota",
|
||||
"unarchive": "Desartxibatu",
|
||||
"undo": "Desegin",
|
||||
"unfavorite": "Gogokoetatik kendu",
|
||||
"unhide_person": "Pertsona erakutsi",
|
||||
"unknown": "Ezezaguna",
|
||||
"unknown_country": "Herrialde ezezaguna",
|
||||
"unknown_year": "Urte ezezaguna",
|
||||
"unlimited": "Mugagabea",
|
||||
"unlink_oauth": "OAuth deslotu",
|
||||
"unmute_memories": "Gogorapenen soinua gaitu",
|
||||
"unnamed_album": "Izengabeko Albuma",
|
||||
"unnamed_share": "Izengabeko baliabide partekatua",
|
||||
"unsaved_change": "Gordegabeko aldaketa",
|
||||
"unselect_all": "Aukeraketak garbitu",
|
||||
"unstack": "Multzotik kendu",
|
||||
"untagged": "Etiketagabea",
|
||||
"up_next": "Hurrengoa",
|
||||
"updated_at": "Eguneratze-data",
|
||||
"updated_password": "Pasahitza eguneratuta",
|
||||
"upload": "Igo",
|
||||
"upload_concurrency": "Igoera paraleloak",
|
||||
"upload_status_duplicates": "Kopiak",
|
||||
"upload_status_errors": "Akatsak",
|
||||
"upload_status_uploaded": "Igota",
|
||||
"uploading": "Igotzen",
|
||||
"url": "URL-a",
|
||||
"usage": "Erabilera",
|
||||
"user": "Erabiltzailea",
|
||||
"user_id": "Erabiltzaile ID-a",
|
||||
"user_purchase_settings": "Erosketa",
|
||||
"username": "Erabiltzaile izena",
|
||||
"users": "Erabiltzaileak",
|
||||
"utilities": "Tresnak",
|
||||
"validate": "Balioetsi",
|
||||
"variables": "Aldagaiak",
|
||||
"version": "Bertsioa",
|
||||
"version_history": "Bertsio-historia",
|
||||
"video": "Bideoa",
|
||||
"videos": "Bideoak",
|
||||
"view": "Bista",
|
||||
"view_album": "Albuma ikusi",
|
||||
"view_all": "Denak ikusi",
|
||||
"view_link": "Esteka ikusi",
|
||||
"view_links": "Estekak ikusi",
|
||||
"view_name": "Bista",
|
||||
"view_similar_photos": "Ikusi antzeko argazkiak",
|
||||
"view_stack": "Pila ikusi",
|
||||
"viewer_unstack": "Multzotik kendu",
|
||||
"waiting": "Itxarotzen",
|
||||
"warning": "Oharra",
|
||||
"week": "Astea",
|
||||
"welcome": "Ongi etorri",
|
||||
"year": "Urtea",
|
||||
"yes": "Bai",
|
||||
"zoom_image": "Irudia handitu"
|
||||
"view_similar_photos": "Ikusi antzeko argazkiak"
|
||||
}
|
||||
|
||||
+1
-14
@@ -189,23 +189,18 @@
|
||||
"machine_learning_smart_search_enabled": "Activar busca intelixente",
|
||||
"machine_learning_smart_search_enabled_description": "Se está desactivado, as imaxes non se codificarán para a busca intelixente.",
|
||||
"machine_learning_url_description": "A URL do servidor de aprendizaxe automática. Se se proporciona máis dunha URL, intentarase con cada servidor un por un ata que un responda correctamente, en orde do primeiro ao último. Os servidores que non respondan ignoraranse temporalmente ata que volvan estar en liña.",
|
||||
"maintenance_backup_management": "Xestión de copias de seguridade",
|
||||
"maintenance_delete_backup": "Eliminar copia de seguridade",
|
||||
"maintenance_delete_backup_description": "Este arquivo será borrado permanentemente.",
|
||||
"maintenance_delete_error": "Erro ao eliminar a copia de seguridade.",
|
||||
"maintenance_integrity_check": "Verificar",
|
||||
"maintenance_integrity_check_all": "Seleccionar todos",
|
||||
"maintenance_integrity_checksum_mismatch": "A suma de comprobación non coincide",
|
||||
"maintenance_integrity_checksum_mismatch_description": "Arquivos que teñen diferentes sumas de comprobación entre o disco e a base de datos de Immich.",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Comproba se hai discrepancias na suma de verificación",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Actualizar informes de discrepancias da suma de comprobación",
|
||||
"maintenance_integrity_missing_file": "Arquivos faltantes",
|
||||
"maintenance_integrity_missing_file_description": "Arquivos que Immich ten rexistrados na base de datos pero que non existen no sistema de arquivos.",
|
||||
"maintenance_integrity_missing_file_job": "Comprobe os arquivos que faltan",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Actualiza os informes de arquivos que faltan",
|
||||
"maintenance_integrity_report": "Informe de integridade",
|
||||
"maintenance_integrity_untracked_file": "Arquivos sen rastrexar",
|
||||
"maintenance_integrity_untracked_file_description": "Arquivos nos directorios de Immich dos que Immich non ten ningún rexistro.",
|
||||
"maintenance_integrity_untracked_file_job": "Comprobar se hai arquivos sen rastrexar",
|
||||
"maintenance_integrity_untracked_file_refresh_job": "Actualizar informes de arquivos sen rastrexar",
|
||||
"maintenance_restore_backup": "Recuperar copia de seguridade",
|
||||
@@ -1374,7 +1369,6 @@
|
||||
"individual_share": "Compartir individual",
|
||||
"individual_shares": "Comparticións individuais",
|
||||
"info": "Información",
|
||||
"integrity_checks": "Verificacións de integridade",
|
||||
"interval": {
|
||||
"day_at_onepm": "Todos os días ás 13:00",
|
||||
"hours": "Cada {hours, plural, one {hora} other {{hours, number} horas}}",
|
||||
@@ -1447,7 +1441,6 @@
|
||||
"linked_oauth_account": "Conta OAuth ligada",
|
||||
"list": "Lista",
|
||||
"live": "En directo",
|
||||
"load_more": "Cargar máis",
|
||||
"loading": "Cargando",
|
||||
"loading_search_results_failed": "Erro ao cargar os resultados da busca",
|
||||
"local": "Local",
|
||||
@@ -2106,7 +2099,6 @@
|
||||
"select_person": "Seleccionar persoa",
|
||||
"select_person_to_tag": "Seleccionar unha persoa para etiquetar",
|
||||
"select_photos": "Seleccionar fotos",
|
||||
"select_quality": "Seleccionar calidade",
|
||||
"select_trash_all": "Seleccionar mover todo ao lixo",
|
||||
"select_user_for_sharing_page_err_album": "Erro ao crear o álbum",
|
||||
"selected": "Seleccionado",
|
||||
@@ -2170,8 +2162,6 @@
|
||||
"share_assets_selected": "{count} seleccionados",
|
||||
"share_dialog_preparing": "Preparando...",
|
||||
"share_link": "Ligazón para Compartir",
|
||||
"share_original": "Utilizar orixinal (grande)",
|
||||
"share_preview": "Utilizar miniatura (pequena)",
|
||||
"shared": "Compartido",
|
||||
"shared_album_activities_input_disable": "O comentario está desactivado",
|
||||
"shared_album_activity_remove_content": "Quere eliminar esta actividade?",
|
||||
@@ -2273,7 +2263,6 @@
|
||||
"slideshow_repeat_description": "Volver ao principio ao rematar a presentación de diapositivas",
|
||||
"slideshow_settings": "Configuración da presentación",
|
||||
"smart_album": "Álbume intelixente",
|
||||
"some_assets_already_have_a_location_warning": "Algúns dos recursos seleccionados xa teñen unha localización",
|
||||
"sort_albums_by": "Ordenar álbums por...",
|
||||
"sort_created": "Data de creación",
|
||||
"sort_items": "Número de elementos",
|
||||
@@ -2394,13 +2383,11 @@
|
||||
"trash_page_title": "Lixo ({count})",
|
||||
"trashed_items_will_be_permanently_deleted_after": "Os elementos no lixo eliminaranse permanentemente despois de {days, plural, one {# día} other {# días}}.",
|
||||
"trigger": "Disparador",
|
||||
"trigger_asset_metadata_extraction": "Extracción de metadatos de activos",
|
||||
"trigger_asset_metadata_extraction_description": "Activado cando se extraen os metadatos EXIF dun recurso",
|
||||
"trigger_asset_uploaded": "Carga de activos",
|
||||
"trigger_asset_uploaded_description": "Actívase cando se carga un activo novo",
|
||||
"trigger_description": "Un evento que inicia o fluxo de traballo",
|
||||
"trigger_person_recognized": "Persoa recoñecida",
|
||||
"trigger_person_recognized_description": "Actívase cando se recoñece a unha persoa",
|
||||
"trigger_person_recognized_description": "Actívase cando se detecta a unha persoa",
|
||||
"trigger_type": "TIpo de disparador",
|
||||
"troubleshoot": "Solucionar problemas",
|
||||
"type": "Tipo",
|
||||
|
||||
@@ -196,16 +196,13 @@
|
||||
"maintenance_integrity_check": "Ellenőrizze",
|
||||
"maintenance_integrity_check_all": "Mind ellenőrzése",
|
||||
"maintenance_integrity_checksum_mismatch": "Ellenőrzőösszeg-eltérés",
|
||||
"maintenance_integrity_checksum_mismatch_description": "Azon fájlok, amelyek lemezen tárolt ellenőrzőösszege eltér az Immich adatbázisában szereplőtől.",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Az ellenőrzőösszegek eltéréseinek ellenőrzése",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Az ellenőrzőösszeg-eltérésről szóló jelentések frissítése",
|
||||
"maintenance_integrity_missing_file": "Hiányzó fájlok",
|
||||
"maintenance_integrity_missing_file_description": "Az Immich adatbázisában szereplő, de a fájlrendszerben nem létező fájlok.",
|
||||
"maintenance_integrity_missing_file_job": "Hiányzó fájlok ellenőrzése",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Hiányzó fájl riport frissítése",
|
||||
"maintenance_integrity_report": "Integritási jelentés",
|
||||
"maintenance_integrity_untracked_file": "Nem figyelt/követett fájlok",
|
||||
"maintenance_integrity_untracked_file_description": "Az Immich könyvtáraiban található, de a nyilvántartásában nem szereplő fájlok.",
|
||||
"maintenance_integrity_untracked_file_job": "Nem követett fájlok ellenőrzése",
|
||||
"maintenance_integrity_untracked_file_refresh_job": "Nem nyomon követett fájlok riportjának frissítése",
|
||||
"maintenance_restore_backup": "Biztonsági mentés visszaállítása",
|
||||
|
||||
@@ -189,23 +189,18 @@
|
||||
"machine_learning_smart_search_enabled": "Attiva ricerca intelligente",
|
||||
"machine_learning_smart_search_enabled_description": "Se disabilitato le immagini non saranno codificate per la ricerca intelligente.",
|
||||
"machine_learning_url_description": "URL del server machine learning. Se sono stati forniti più di un URL, verrà testato un server alla volta finché uno non risponderà, in ordine dal primo all'ultimo. I server che non rispondono saranno temporaneamente ignorati finché non torneranno online.",
|
||||
"maintenance_backup_management": "Gestione backup",
|
||||
"maintenance_delete_backup": "Elimina backup",
|
||||
"maintenance_delete_backup_description": "Questo file sarà eliminato in modo irreversibile.",
|
||||
"maintenance_delete_error": "Impossibile eliminare il backup.",
|
||||
"maintenance_integrity_check": "Verifica",
|
||||
"maintenance_integrity_check_all": "Verifica Tutto",
|
||||
"maintenance_integrity_checksum_mismatch": "I checksum non corrispondono",
|
||||
"maintenance_integrity_checksum_mismatch_description": "File il cui checksum sul disco non corrisponde al checksum memorizzato da Immich nel database.",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Verifica corrispondenza dei checksum",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Aggiorna report corrispondenze dei checksum",
|
||||
"maintenance_integrity_missing_file": "File mancanti",
|
||||
"maintenance_integrity_missing_file_description": "File che Immich ha registrato nel suo database ma che non esistono sul file system.",
|
||||
"maintenance_integrity_missing_file_job": "Verifica file mancanti",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Aggiorna report sui file mancanti",
|
||||
"maintenance_integrity_report": "Report di integrità",
|
||||
"maintenance_integrity_untracked_file": "File non tracciati",
|
||||
"maintenance_integrity_untracked_file_description": "File nelle directory di Immich di cui Immich non ha alcuna traccia.",
|
||||
"maintenance_integrity_untracked_file_job": "Verifica presenza file non tracciati",
|
||||
"maintenance_integrity_untracked_file_refresh_job": "Aggiorna il report dei file non tracciati",
|
||||
"maintenance_restore_backup": "Ripristina backup",
|
||||
|
||||
@@ -189,11 +189,9 @@
|
||||
"machine_learning_smart_search_enabled": "스마트 검색 활성화",
|
||||
"machine_learning_smart_search_enabled_description": "비활성화하면 스마트 검색을 위한 이미지 처리를 진행하지 않습니다.",
|
||||
"machine_learning_url_description": "기계 학습 서버의 URL을 설정합니다. 여러 개가 입력되면 첫 번째부터 한 번에 하나씩 순서대로 응답하는 서버를 찾을 때까지 요청을 시도합니다. 응답하지 않는 서버는 다시 사용 가능할 때까지 일시적으로 제외됩니다.",
|
||||
"maintenance_backup_management": "백업 관리",
|
||||
"maintenance_delete_backup": "백업 삭제",
|
||||
"maintenance_delete_backup_description": "이 파일이 영구적으로 삭제됩니다.",
|
||||
"maintenance_delete_error": "백업 삭제에 실패했습니다.",
|
||||
"maintenance_integrity_check": "체크",
|
||||
"maintenance_integrity_check_all": "전체선택",
|
||||
"maintenance_integrity_checksum_mismatch": "체크섬 불일치",
|
||||
"maintenance_integrity_checksum_mismatch_job": "파일 무결성 검사",
|
||||
|
||||
@@ -189,23 +189,18 @@
|
||||
"machine_learning_smart_search_enabled": "Aktiver smart søk",
|
||||
"machine_learning_smart_search_enabled_description": "Hvis deaktivert, vil bilder ikke bli enkodet for smart søk.",
|
||||
"machine_learning_url_description": "URL til maskinlærings-serveren. Hvis mer enn en URL er lagt inn, hver server vill bli forsøkt en om gangen frem til en svarer suksessfullt, i rekkefølge fra først til sist. Servere som ikke svarer vil midlertidig bli oversett frem til dem svarer igjen.",
|
||||
"maintenance_backup_management": "Administrasjon av sikkerhetskopier",
|
||||
"maintenance_delete_backup": "Slett sikkerhetskopi",
|
||||
"maintenance_delete_backup_description": "Denne filen vil bli permanent slettet.",
|
||||
"maintenance_delete_error": "Feilet ved sletting av sikkerhetskopi.",
|
||||
"maintenance_integrity_check": "Sjekk",
|
||||
"maintenance_integrity_check_all": "Velg alle",
|
||||
"maintenance_integrity_checksum_mismatch": "Sjekksum er feil",
|
||||
"maintenance_integrity_checksum_mismatch_description": "Filer med en sjekksum på disken som ikke stemmer overens med sjekksummen Immich har lagret i databasen sin.",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Sjekk for feilede sjekksummer",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Oppdater feilede sjekksum rapporter",
|
||||
"maintenance_integrity_missing_file": "Manglende filer",
|
||||
"maintenance_integrity_missing_file_description": "Filer som Immich har sporet i databasen sin, men som ikke eksisterer i filsystemet.",
|
||||
"maintenance_integrity_missing_file_job": "Sjekk etter manglende filer",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Oppdater rapporten for manglende filer",
|
||||
"maintenance_integrity_report": "Integritetsrapport",
|
||||
"maintenance_integrity_untracked_file": "Usporede filer",
|
||||
"maintenance_integrity_untracked_file_description": "Filer i Immich-mappene som ikke er registrert i databasen.",
|
||||
"maintenance_integrity_untracked_file_job": "Sjekk etter usporede filer",
|
||||
"maintenance_integrity_untracked_file_refresh_job": "Oppdater rapporten for usporede filer",
|
||||
"maintenance_restore_backup": "Gjenopprett Sikkerhetskopi",
|
||||
|
||||
+4
-9
@@ -56,7 +56,7 @@
|
||||
"backup_database": "Criar Cópia da Base de Dados",
|
||||
"backup_database_enable_description": "Ativar cópias da base de dados",
|
||||
"backup_keep_last_amount": "Quantidade de cópias anteriores a manter",
|
||||
"backup_onboarding_1_description": "Uma cópia remota na cloud ou noutra localização física.",
|
||||
"backup_onboarding_1_description": "Uma cópia remota na cloud ou outra localização física.",
|
||||
"backup_onboarding_2_description": "Cópias locais em dispositivos diferentes, incluindo os ficheiros principais e uma cópia de segurança local dos mesmos.",
|
||||
"backup_onboarding_3_description": "Cópias completas dos seus dados, incluindo os ficheiros originais. Inclui uma cópia remota e duas cópias locais.",
|
||||
"backup_onboarding_description": "É recomendada a <backblaze-link>estratégia de cópia de segurança 3-2-1</backblaze-link> para proteger os seus dados. Para uma solução de cópia de segurança completa, deve manter cópias das suas fotos e vídeos tal como da base de dados do Immich.",
|
||||
@@ -189,23 +189,18 @@
|
||||
"machine_learning_smart_search_enabled": "Ativar a Pesquisa Inteligente",
|
||||
"machine_learning_smart_search_enabled_description": "Se desativado, as imagens não serão codificadas para Pesquisa Inteligente.",
|
||||
"machine_learning_url_description": "A URL do servidor de aprendizagem de máquina. Se for fornecido mais do que um URL, cada servidor será testado, um a um, até um deles responder com sucesso, por ordem do primeiro ao último. Servidores que não responderem serão temporariamente ignorados até voltarem a estar online.",
|
||||
"maintenance_backup_management": "Gestão de cópias de segurança",
|
||||
"maintenance_delete_backup": "Eliminar Cópia de Segurança",
|
||||
"maintenance_delete_backup_description": "Este ficheiro irá ser apagado para sempre.",
|
||||
"maintenance_delete_error": "Ocorreu um erro ao eliminar a cópia de segurança.",
|
||||
"maintenance_integrity_check": "Verificar",
|
||||
"maintenance_integrity_check_all": "Verificar tudo",
|
||||
"maintenance_integrity_checksum_mismatch": "Checksum não corresponde",
|
||||
"maintenance_integrity_checksum_mismatch_description": "Ficheiros dos quais a verificação checksum no disco não condiz com a que o Immich tem armazenado na base de dados.",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Verificar se existem erros de checksum",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Atualizar relatórios de erros de checksum",
|
||||
"maintenance_integrity_missing_file": "Ficheiros em falta",
|
||||
"maintenance_integrity_missing_file_description": "Ficheiros dos quais o Immich está a monitorizar na sua base de dados mas que não existem no sistema de ficheiros.",
|
||||
"maintenance_integrity_missing_file_job": "Verificar se existem ficheiros em falta",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Atualizar relatórios de ficheiros em falta",
|
||||
"maintenance_integrity_report": "Relatório de integridade",
|
||||
"maintenance_integrity_untracked_file": "Ficheiros não monitorizados",
|
||||
"maintenance_integrity_untracked_file_description": "Ficheiros nos diretórios do Immich dos quais não existe qualquer registo.",
|
||||
"maintenance_integrity_untracked_file_job": "Verificar se existem ficheiros não monitorizados",
|
||||
"maintenance_integrity_untracked_file_refresh_job": "Atualizar relatórios de ficheiros não monitorizados",
|
||||
"maintenance_restore_backup": "Restaurar Cópia de Segurança",
|
||||
@@ -660,7 +655,7 @@
|
||||
"backup_background_service_complete_notification": "Cópia de conteúdos concluída",
|
||||
"backup_background_service_connection_failed_message": "Ocorreu um erro na ligação ao servidor. A tentar de novo…",
|
||||
"backup_background_service_current_upload_notification": "A enviar {filename}",
|
||||
"backup_background_service_default_notification": "A verificar a existência de novos ficheiros…",
|
||||
"backup_background_service_default_notification": "A verificar se há novos ficheiros…",
|
||||
"backup_background_service_error_title": "Erro na cópia de segurança",
|
||||
"backup_background_service_in_progress_notification": "A fazer cópia de segurança dos seus ficheiros…",
|
||||
"backup_background_service_upload_failure_notification": "Ocorreu um erro ao enviar {filename}",
|
||||
@@ -783,8 +778,8 @@
|
||||
"changed_visibility_successfully": "Visibilidade alterada com sucesso",
|
||||
"charging": "A carregar",
|
||||
"charging_requirement_mobile_backup": "Cópia de segurança de fundo necessita que o dispositivo esteja a carregar",
|
||||
"check_corrupt_asset_backup": "Verificar por cópias de segurança de ficheiros corrompidas",
|
||||
"check_corrupt_asset_backup_button": "Realizar verificação",
|
||||
"check_corrupt_asset_backup": "Verificar por backups corrompidos",
|
||||
"check_corrupt_asset_backup_button": "Verificar",
|
||||
"check_corrupt_asset_backup_description": "Execute esta verificação apenas numa rede Wi-Fi e quando a cópia de segurança de todos os ficheiros já estiver concluída. O processo pode demorar alguns minutos.",
|
||||
"check_logs": "Verificar registos",
|
||||
"checksum": "Teste de soma de dados",
|
||||
|
||||
@@ -189,23 +189,18 @@
|
||||
"machine_learning_smart_search_enabled": "Povoliť inteligentné vyhľadávanie",
|
||||
"machine_learning_smart_search_enabled_description": "Ak je vypnuté, obrázky nebudú spracované pre inteligentné vyhľadávanie.",
|
||||
"machine_learning_url_description": "URL adresa servera strojového učenia. Ak je zadaných viacero adries URL, každý server bude testovaný postupne, kým jeden z nich neodpovie úspešne, v poradí od prvého po posledný. Servery, ktoré neodpovedajú, budú dočasne ignorované, kým nebudú opäť online.",
|
||||
"maintenance_backup_management": "Spravovanie záloh",
|
||||
"maintenance_delete_backup": "Vymazať zálohu",
|
||||
"maintenance_delete_backup_description": "Tento súbor bude nezvratne vymazaný.",
|
||||
"maintenance_delete_error": "Nepodarilo sa vymazať zálohu.",
|
||||
"maintenance_integrity_check": "Skontrolovať",
|
||||
"maintenance_integrity_check_all": "Skontrolovať všetko",
|
||||
"maintenance_integrity_checksum_mismatch": "Nesúlad kontrolného súčtu",
|
||||
"maintenance_integrity_checksum_mismatch_description": "Súbory, ktorých kontrolný súčet na disku sa nezhoduje s kontrolným súčtom, ktorý Immich uložil vo svojej databáze.",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Skontrolovať, či sa kontrolné súčty zhodujú",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Obnoviť správy o nezhode kontrolného súčtu",
|
||||
"maintenance_integrity_missing_file": "Chýbajúce súbory",
|
||||
"maintenance_integrity_missing_file_description": "Súbory, ktoré Immich zaznamenal vo svojej databáze, ale ktoré v súborovom systéme neexistujú.",
|
||||
"maintenance_integrity_missing_file_job": "Skontrolovať chýbajúce súbory",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Obnoviť hlásenia o chýbajúcich súboroch",
|
||||
"maintenance_integrity_report": "Hlásenie o integrite",
|
||||
"maintenance_integrity_untracked_file": "Nesledované súbory",
|
||||
"maintenance_integrity_untracked_file_description": "Súbory v adresároch Immich, o ktorých Immich nemá žiadne záznamy.",
|
||||
"maintenance_integrity_untracked_file_job": "Skontrolovať nesledované súbory",
|
||||
"maintenance_integrity_untracked_file_refresh_job": "Obnoviť hlásenia o nesledovaných súboroch",
|
||||
"maintenance_restore_backup": "Obnoviť zálohu",
|
||||
|
||||
@@ -189,23 +189,18 @@
|
||||
"machine_learning_smart_search_enabled": "Omogoči pametno iskanje",
|
||||
"machine_learning_smart_search_enabled_description": "Če je onemogočeno, slike ne bodo kodirane za pametno iskanje.",
|
||||
"machine_learning_url_description": "URL strežnika za strojno učenje. Če je na voljo več kot en URL, bo vsak strežnik poskusen posamično, dokler se eden ne odzove uspešno, v vrstnem redu od prvega do zadnjega. Strežniki, ki se ne odzovejo, bodo začasno prezrti, dokler se spet ne vzpostavijo.",
|
||||
"maintenance_backup_management": "Upravljanje varnostnih kopij",
|
||||
"maintenance_delete_backup": "Izbriši varnostno kopijo",
|
||||
"maintenance_delete_backup_description": "Ta datoteka bo nepreklicno izbrisana.",
|
||||
"maintenance_delete_error": "Varnostne kopije ni bilo mogoče izbrisati.",
|
||||
"maintenance_integrity_check": "Preveri",
|
||||
"maintenance_integrity_check_all": "Označi vse",
|
||||
"maintenance_integrity_checksum_mismatch": "Neujemanje kontrolne vsote",
|
||||
"maintenance_integrity_checksum_mismatch_description": "Datoteke, katerih kontrolna vsota na disku se ne ujema s kontrolno vsoto, ki jo je Immich shranil v svojo zbirko podatkov.",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Preverite neujemanje kontrolnih vsot",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Osveži poročila o neujemanju kontrolnih vsot",
|
||||
"maintenance_integrity_missing_file": "Manjkajoče datoteke",
|
||||
"maintenance_integrity_missing_file_description": "Datoteke, ki jih je Immich sledil v svoji bazi podatkov, vendar ne obstajajo v datotečnem sistemu.",
|
||||
"maintenance_integrity_missing_file_job": "Preverite manjkajoče datoteke",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Osveži poročila o manjkajočih datotekah",
|
||||
"maintenance_integrity_report": "Poročilo o integriteti",
|
||||
"maintenance_integrity_untracked_file": "Nesledljive datoteke",
|
||||
"maintenance_integrity_untracked_file_description": "Datoteke v Immichovih imenikih, o katerih Immich nima nobenih zapisov.",
|
||||
"maintenance_integrity_untracked_file_job": "Preveri nesledljive datoteke",
|
||||
"maintenance_integrity_untracked_file_refresh_job": "Osveži poročila o nesledenih datotekah",
|
||||
"maintenance_restore_backup": "Obnovi varnostno kopijo",
|
||||
|
||||
+1
-36
@@ -79,7 +79,6 @@
|
||||
"cron_expression_description": "Sätt skanningsintervallet genom att använda cron-format. För mer information se <link>Crontab Guru</link>",
|
||||
"cron_expression_presets": "Cron-uttryck förinställningar",
|
||||
"disable_login": "Inaktivera inloggning",
|
||||
"download_csv": "Ladda ner CSV",
|
||||
"duplicate_detection_job_description": "Kör maskininlärning på objekt för att upptäcka liknande bilder. Bygger på Smart Search",
|
||||
"exclusion_pattern_description": "Exkluderingsmönster tillåter dig att ignorera filer och mappar när skanning görs av ditt album. Detta är användbart om du har mappar som innehåller filer som du inte vill importera, t.ex. RAW-filer.",
|
||||
"export_config_as_json_description": "Ladda ner den aktuella systemkonfigurationen som en JSON-fil",
|
||||
@@ -189,25 +188,9 @@
|
||||
"machine_learning_smart_search_enabled": "Aktivera smart sökning",
|
||||
"machine_learning_smart_search_enabled_description": "Om inaktiverat kommer bilder inte att kodas för smart sökning.",
|
||||
"machine_learning_url_description": "Maskininlärningsserverns URL. Om det är mer än en URL tillagd så kommer ett försök per URL att utföras tills någon av dom svarar, försöken görs i kronologisk ordning. Servrar som inte svarar kommer tillfälligt ignoreras tills de är nåbara igen.",
|
||||
"maintenance_backup_management": "Hantering av säkerhetskopior",
|
||||
"maintenance_delete_backup": "Ta bort säkerhetskopia",
|
||||
"maintenance_delete_backup_description": "Den här filen kommer att raderas oåterkalleligt.",
|
||||
"maintenance_delete_error": "Det gick inte att ta bort säkerhetskopian.",
|
||||
"maintenance_integrity_check": "Kontrollera",
|
||||
"maintenance_integrity_check_all": "Kontrollera alla",
|
||||
"maintenance_integrity_checksum_mismatch": "Kontrollsummeavvikelse",
|
||||
"maintenance_integrity_checksum_mismatch_description": "Filer vars kontrollsumma på disken inte stämmer överens med den kontrollsumma som Immich har lagrat i sin databas.",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Kolla efter kontrollsummor som inte matchar",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Uppdatera rapporter om kontrollsummor som inte matchar",
|
||||
"maintenance_integrity_missing_file": "Filer saknas",
|
||||
"maintenance_integrity_missing_file_description": "Filer som har registrerats i Immichs databas men som saknas i filsystemet.",
|
||||
"maintenance_integrity_missing_file_job": "Kolla efter saknade filer",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Uppdatera rapporter om saknade filer",
|
||||
"maintenance_integrity_report": "Integritetsrapport",
|
||||
"maintenance_integrity_untracked_file": "Ospårade filer",
|
||||
"maintenance_integrity_untracked_file_description": "Okända filer i Immichs kataloger.",
|
||||
"maintenance_integrity_untracked_file_job": "Kolla efter okända filer",
|
||||
"maintenance_integrity_untracked_file_refresh_job": "Uppdatera rapporter om okända filer",
|
||||
"maintenance_restore_backup": "Återställ säkerhetskopia",
|
||||
"maintenance_restore_backup_description": "Immich kommer att återställas från den valda säkerhetskopian. En ny säkerhetskopia kommer att skapas innan du fortsätter.",
|
||||
"maintenance_restore_backup_different_version": "Denna säkerhetskopia skapades med en annan version av Immich!",
|
||||
@@ -587,11 +570,10 @@
|
||||
"asset_added_to_album": "Lades till i album",
|
||||
"asset_adding_to_album": "Lägger till i album…",
|
||||
"asset_created": "Objekt skapad",
|
||||
"asset_day_count": "{date}: {count, plural, one {# objekt} other {# objekt}}",
|
||||
"asset_description_updated": "Objektbeskrivning har uppdaterats",
|
||||
"asset_filename_is_offline": "Objektet {filename} är offline",
|
||||
"asset_has_unassigned_faces": "Objektet har otilldelade ansikten",
|
||||
"asset_hashing": "Hashar…",
|
||||
"asset_hashing": "Hashning…",
|
||||
"asset_list_group_by_sub_title": "Gruppera på",
|
||||
"asset_list_layout_settings_dynamic_layout_title": "Dynamisk layout",
|
||||
"asset_list_layout_settings_group_automatically": "Automatiskt",
|
||||
@@ -717,7 +699,6 @@
|
||||
"backup_settings_subtitle": "Hantera uppladdningsinställningar",
|
||||
"backup_upload_details_page_more_details": "Tryck för mer detaljer",
|
||||
"backward": "Bakåt",
|
||||
"battery_optimization_backup_reliability": "Inaktivera batterioptimeringar för stabilare säkerhetskopiering i bakgrunden",
|
||||
"biometric_auth_enabled": "Biometrisk autentisering aktiverad",
|
||||
"biometric_locked_out": "Du är utelåst från biometrisk autentisering",
|
||||
"biometric_no_options": "Inga biometriska alternativ tillgängliga",
|
||||
@@ -932,8 +913,6 @@
|
||||
"deduplicate_all": "Deduplicera alla",
|
||||
"default_locale": "Standardspråk",
|
||||
"default_locale_description": "Formatera datum och siffror baserat på din webbläsares språkinställningar",
|
||||
"default_quality_subtitle": "Kvalitet som används vid delning. Håll ner delaknappen för att välja kvalitet varje gång.",
|
||||
"default_share_quality": "Standarddelningskvalitet",
|
||||
"delete": "Radera",
|
||||
"delete_action_confirmation_message": "Är du säker på att du vill ta bort det här objektet? Den här åtgärden flyttar objektet till serverns papperskorg och frågar om du vill ta bort den lokalt",
|
||||
"delete_action_prompt": "{count} raderade",
|
||||
@@ -1243,7 +1222,6 @@
|
||||
"failed": "Misslyckades",
|
||||
"failed_count": "Misslyckade: {count}",
|
||||
"failed_to_authenticate": "Misslyckades med autentisering",
|
||||
"failed_to_delete_file": "Misslyckades att radera fil",
|
||||
"failed_to_load_assets": "Det gick inte att läsa in objekten",
|
||||
"failed_to_load_folder": "Kunde inte ladda mappen",
|
||||
"favorite": "Favorit",
|
||||
@@ -1374,7 +1352,6 @@
|
||||
"individual_share": "Enskild delning",
|
||||
"individual_shares": "Individuella delningar",
|
||||
"info": "Information",
|
||||
"integrity_checks": "Integritetskontroller",
|
||||
"interval": {
|
||||
"day_at_onepm": "Alla dagar vid kl 13.00",
|
||||
"hours": "Vid varje {hours, plural, one {hour} other {{hours, number} hours}}",
|
||||
@@ -1422,7 +1399,6 @@
|
||||
"leave": "Lämna",
|
||||
"leave_album": "Lämna albumet",
|
||||
"lens_model": "Objektiv",
|
||||
"less": "Mindre",
|
||||
"let_others_respond": "Låt andra svara",
|
||||
"level": "Nivå",
|
||||
"library": "Bibliotek",
|
||||
@@ -1447,7 +1423,6 @@
|
||||
"linked_oauth_account": "Länkat OAuth konto",
|
||||
"list": "Lista",
|
||||
"live": "Live",
|
||||
"load_more": "Ladda mer",
|
||||
"loading": "Inläsning",
|
||||
"loading_search_results_failed": "Det gick inte att läsa in sökresultat",
|
||||
"local": "Lokalt",
|
||||
@@ -1714,7 +1689,6 @@
|
||||
"not_selected": "Ej vald",
|
||||
"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_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",
|
||||
@@ -2106,7 +2080,6 @@
|
||||
"select_person": "Välj person",
|
||||
"select_person_to_tag": "Välj en person att tagga",
|
||||
"select_photos": "Välj foton",
|
||||
"select_quality": "Välj kvalitet",
|
||||
"select_trash_all": "Släng alla",
|
||||
"select_user_for_sharing_page_err_album": "Kunde inte skapa nytt album",
|
||||
"selected": "Valda",
|
||||
@@ -2170,8 +2143,6 @@
|
||||
"share_assets_selected": "{count} valda",
|
||||
"share_dialog_preparing": "Förbereder...",
|
||||
"share_link": "Dela Länk",
|
||||
"share_original": "Använd original (stor)",
|
||||
"share_preview": "Använd miniatyrbild (liten)",
|
||||
"shared": "Delad",
|
||||
"shared_album_activities_input_disable": "Kommentar är inaktiverad",
|
||||
"shared_album_activity_remove_content": "Vill du ta bort den här aktiviteten?",
|
||||
@@ -2273,7 +2244,6 @@
|
||||
"slideshow_repeat_description": "Gå tillbaka till början när bildspelet slutar",
|
||||
"slideshow_settings": "Bildspelsinställningar",
|
||||
"smart_album": "Smart album",
|
||||
"some_assets_already_have_a_location_warning": "Vissa av de valda objekten har redan platsdata",
|
||||
"sort_albums_by": "Sortera album efter...",
|
||||
"sort_created": "Skapat datum",
|
||||
"sort_items": "Antal artiklar",
|
||||
@@ -2394,8 +2364,6 @@
|
||||
"trash_page_title": "Papperskorg ({count})",
|
||||
"trashed_items_will_be_permanently_deleted_after": "Objekt i papperskorgen raderas permanent efter {days, plural, one {# dag} other {# dagar}}.",
|
||||
"trigger": "Utlösare",
|
||||
"trigger_asset_metadata_extraction": "Hämtning av objektmetadata",
|
||||
"trigger_asset_metadata_extraction_description": "Aktiveras när EXIF-metadata hämtas från ett objekt",
|
||||
"trigger_asset_uploaded": "Objekt uppladdning",
|
||||
"trigger_asset_uploaded_description": "Utlöses när ett nytt objekt laddas upp",
|
||||
"trigger_description": "Ett evenemang som sätter igång arbetsflödet",
|
||||
@@ -2442,7 +2410,6 @@
|
||||
"updated_password": "Lösenordet har uppdaterats",
|
||||
"upload": "Ladda upp",
|
||||
"upload_concurrency": "Uppladdning samtidighet",
|
||||
"upload_day_count": "{date}: {count, plural, one {# uppladdning} other {# uppladdningar}}",
|
||||
"upload_details": "Uppladdningsdetaljer",
|
||||
"upload_dialog_info": "Vill du säkerhetskopiera de valda objekten till servern?",
|
||||
"upload_dialog_title": "Ladda Upp Objekt",
|
||||
@@ -2458,8 +2425,6 @@
|
||||
"upload_to_immich": "Ladda upp till Immich ({count})",
|
||||
"uploading": "Laddar upp",
|
||||
"uploading_media": "Uppladdning av media",
|
||||
"uploads": "Uppladdningar",
|
||||
"uploads_count": "{count, plural, one {# uppladdning} other {# uppladdningar}}",
|
||||
"url": "URL",
|
||||
"usage": "Användning",
|
||||
"use_biometric": "Använd biometri",
|
||||
|
||||
+2
-6
@@ -79,7 +79,6 @@
|
||||
"cron_expression_description": "ตั้งช่วงเวลาในการสแกนโดยใช้รูปแบบ cron สำหรับข้อมูลเพิ่มเติมกรุณาอิง <link>Crontab Guru</link>",
|
||||
"cron_expression_presets": "พรีเซ็ตรูปแบบ cron",
|
||||
"disable_login": "ปิดการล็อกอิน",
|
||||
"download_csv": "ดาวน์โหลด CSV",
|
||||
"duplicate_detection_job_description": "ใช้ machine learning กับสี่อเพื่อตรวจจับรูปภาพที่คล้ายกัน โดยใช้การค้นหาอัจฉริยะ",
|
||||
"exclusion_pattern_description": "ข้อยกเว้นสามารถละเว้นไฟล์และโฟลเดอร์ขณะสแกนคลังภาพของคุณ มีประโยชน์เมื่อโฟลเดอร์มีไฟล์ที่ไม่อยากนำเข้า เช่นไฟล์ RAW",
|
||||
"export_config_as_json_description": "ดาวน์โหลดการตั้งค่าระบบปัจจุบันไปยังไฟล์ในรูปแบบ JSON",
|
||||
@@ -189,12 +188,9 @@
|
||||
"machine_learning_smart_search_enabled": "เปิดใช้งานการค้นหาอัจฉริยะ",
|
||||
"machine_learning_smart_search_enabled_description": "หากปิดใช้งาน ภาพจะไม่ถูกใช้สําหรับการค้นหาอัจฉริยะ",
|
||||
"machine_learning_url_description": "URL ของเซิร์ฟเวอร์ machine learning กรณีมี URL มากกว่าหนึ่ง URL จะทำการทดลองส่งข้อมูลเรียงไปทีละอันตามลำดับจนกว่าจะพบ URL ที่ตอบสนอง และจะเลิกส่งข้อมูลชั่วคราวในส่วนของ URL ที่ไม่ตอบสนอง",
|
||||
"maintenance_backup_management": "การจัดการสำรองข้อมูล",
|
||||
"maintenance_delete_backup": "ลบการสำรองข้อมูล",
|
||||
"maintenance_delete_backup_description": "ไฟล์นี้จะถูกลบและไม่สามารถย้อนกลับได้",
|
||||
"maintenance_delete_error": "ไม่สามารถลบการสำรองข้อมูล",
|
||||
"maintenance_integrity_check": "ตรวจสอบ",
|
||||
"maintenance_integrity_check_all": "ตรวจสอบทั้งหมด",
|
||||
"maintenance_restore_backup": "กู้คืนการสำรองข้อมูล",
|
||||
"maintenance_restore_backup_description": "Immich จะถูกล้าง และกู้คืนข้อมูลจากการสำรองข้อมูลที่เลือก ระบบจะสร้างการสำรองข้อมูลก่อนดำเนินการต่อ",
|
||||
"maintenance_restore_backup_different_version": "การสำรองข้อมูลนี้ถูกสร้างด้วย Immich เวอร์ชันต่างกัน!",
|
||||
@@ -642,7 +638,7 @@
|
||||
"backup_controller_page_background_battery_info_title": "ประสิทธิภาพแบตเตอรี่",
|
||||
"backup_controller_page_background_charging": "ขณะชาร์จอย่างเดียว",
|
||||
"backup_controller_page_background_configure_error": "ไม่สามารถติดตั้งบริการเบื้องหลัง",
|
||||
"backup_controller_page_background_delay": "ล่าช้าการสำรองทรัพยากรใหม่: {duration}",
|
||||
"backup_controller_page_background_delay": "ล่าช้าการลำรองทรัพยากรใหม่: {duration}",
|
||||
"backup_controller_page_background_description": "เปิดบริการเบื้องหลังเพื่อที่จะสำรองทรัพยากรใหม่โดยที่ไม่จำเป็นต้องเปิดแอป",
|
||||
"backup_controller_page_background_is_off": "การสำรองข้อมูลอัตโนมัติปิดอยู่",
|
||||
"backup_controller_page_background_is_on": "การสำรองข้อมูลอัตโนมัติเปิดอยู่",
|
||||
@@ -2146,7 +2142,7 @@
|
||||
"sort_modified": "จัดเรียงตามวันที่แก้ไข",
|
||||
"sort_newest": "รูปภาพใหม่ล่าสุด",
|
||||
"sort_oldest": "จัดเรียงตามเก่าสุด",
|
||||
"sort_people_by_similarity": "จัดเรียงผู้คนตามความคล้ายคลึง",
|
||||
"sort_people_by_similarity": "จุดเรียงบุคคลตามความคล้ายคลึง",
|
||||
"sort_recent": "จัดเรียงใหม่ล่าสุด",
|
||||
"sort_title": "ไตเติ้ล",
|
||||
"source": "แหล่ง",
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@
|
||||
"exclusion_pattern_description": "Kütüphaneyi tararken dosya ve klasörleri görmezden gelmek için dışlama desenlerini kullanabilirsiniz. RAW dosyaları gibi bazı dosya ve klasörleri içe aktarmak istemediğinizde bu seçeneği kullanabilirsiniz.",
|
||||
"export_config_as_json_description": "Geçerli sistem yapılandırmasını JSON dosyası olarak indir",
|
||||
"external_libraries_page_description": "Yönetici harici kütüphane sayfası",
|
||||
"face_detection": "Yüz tespiti",
|
||||
"face_detection": "Yüz tarama",
|
||||
"face_detection_description": "Makine öğrenimi kullanarak varlıklardaki yüzleri tespit et. Videolar için sadece küçük resim (thumbnail) dikkate alınır. 'Yenile' tüm varlıkları yeniden işler. 'Sıfırla', mevcut tüm yüz verilerini temizleyerek işlemi yeniden başlatır. 'Eksik' henüz işlenmemiş varlıkları sıraya alır. Tespit edilen yüzler, Yüz Tanıma işlemi tamamlandıktan sonra mevcut ya da yeni kişilere gruplanmak üzere Yüz Tanıma için sıraya alınacaktır.",
|
||||
"facial_recognition_job_description": "Algılanan yüzleri kişilere grupla. Bu adım, Yüz Tespit işlemi tamamlandıktan sonra çalışır. \"Sıfırla\", tüm yüzleri yeniden gruplandırır. \"Eksik\" ise henüz bir kişiye atanmamış yüzleri sıraya alır.",
|
||||
"failed_job_command": "{job} görevi için {command} komutu başarısız",
|
||||
|
||||
+2
-45
@@ -51,7 +51,7 @@
|
||||
"authentication_settings": "驗證設定",
|
||||
"authentication_settings_description": "管理密碼、OAuth 同其他驗證設定",
|
||||
"authentication_settings_disable_all": "你確定你要停用所有登入嘅方式嗎?你會完全冇辦法登入。",
|
||||
"authentication_settings_reenable": "如果你要重新啟用嘅話,請使用<link>伺服器指令</link>。",
|
||||
"authentication_settings_reenable": "如果你要重新啟用嘅話,請使用<link>嘅伺服器指令</link>。",
|
||||
"background_task_job": "背景操作",
|
||||
"backup_database": "建立資料庫備份",
|
||||
"backup_database_enable_description": "啟用資料庫備份",
|
||||
@@ -60,7 +60,7 @@
|
||||
"backup_onboarding_2_description": "儲存係唔同裝置嘅本地副本。包含主要嘅檔案同埋喺本機嘅備份。",
|
||||
"backup_onboarding_3_description": "資料包含原始文件,總共備份嘅次數。呢個包括1份異地嘅備份同埋2份本機副本。",
|
||||
"backup_onboarding_description": "我哋建議你使用<backblaze-link>嘅3-2-1備份</backblaze-link>嚟保障你嘅資料。為咗可以全方位地備份你嘅資料,你應該保留已經上傳嘅相/影片嘅副本,同埋Immich嘅資料庫,。",
|
||||
"backup_onboarding_footer": "有關其他Immich備份嘅資料,請參考<link>說明文件</link>。",
|
||||
"backup_onboarding_footer": "有關其他Immich備份嘅資料,請參考<link></link>。",
|
||||
"backup_onboarding_parts_title": "一個3-2-1備份包括:",
|
||||
"backup_onboarding_title": "備份",
|
||||
"backup_settings": "資料庫備份嘅設定",
|
||||
@@ -261,49 +261,6 @@
|
||||
"nightly_tasks_sync_quota_usage_setting": "同步限額使用情況",
|
||||
"nightly_tasks_sync_quota_usage_setting_description": "根據目前嘅使用量更新使用者嘅儲存限額",
|
||||
"no_paths_added": "冇新增咗嘅路徑",
|
||||
"no_pattern_added": "未有排除規則",
|
||||
"note_apply_storage_label_previous_assets": "提示:如果您想將儲存標籤套用到上傳咗嘅項目,請執行",
|
||||
"note_cannot_be_changed_later": "注意:呢個設定之後改唔到!",
|
||||
"notification_email_from_address": "寄件地址",
|
||||
"notification_email_from_address_description": "寄件者電郵,好似:\"Immich Photo Server <noreply@example.com>\"。請確保係您有權限寄送到郵件嘅地址。",
|
||||
"notification_email_host_description": "電子郵件伺服器主機(例如:smtp.immich.app)",
|
||||
"notification_email_ignore_certificate_errors": "忽略唔啱嘅憑證",
|
||||
"notification_email_ignore_certificate_errors_description": "忽略 TLS 錯嘅憑證驗證(唔建議)",
|
||||
"notification_email_password_description": "用嚟驗證電郵伺服器嘅密碼",
|
||||
"notification_email_port_description": "電郵伺服器嘅連接埠(例如 25、465 或 587)",
|
||||
"notification_email_secure": "SMTPS",
|
||||
"notification_email_secure_description": "用 SMTPS(基於 TLS 嘅 SMTP)",
|
||||
"notification_email_sent_test_email_button": "傳送測試電郵,跟住儲存",
|
||||
"notification_email_setting_description": "寄送電郵通知嘅設定",
|
||||
"notification_email_test_email": "傳送測試電郵",
|
||||
"notification_email_test_email_failed": "傳送唔到測試電郵,請檢查您嘅設定",
|
||||
"notification_email_test_email_sent": "測試電郵已傳送咗到 {email}。請檢查您嘅收件匣。",
|
||||
"notification_email_username_description": "用嚟驗證電郵伺服器嘅使用者名稱",
|
||||
"notification_enable_email_notifications": "用電郵通知",
|
||||
"notification_settings": "通知設定",
|
||||
"notification_settings_description": "管理通知設定,包括電郵設定",
|
||||
"oauth_allow_insecure_requests": "畀用唔可靠嘅請求",
|
||||
"oauth_allow_insecure_requests_description": "警告:呢個選項會停用 OAuth 嘅 TLS 憑證驗證請求,可能會令您暴露於 MITM 攻擊。",
|
||||
"oauth_auto_launch": "自動啟動",
|
||||
"oauth_auto_launch_description": "進入登入頁面時,自動啟動 OAuth 登入流程",
|
||||
"oauth_auto_register": "自動註冊",
|
||||
"oauth_auto_register_description": "使用 OAuth 登入之後自動註冊新使用者",
|
||||
"oauth_button_text": "按鈕文字",
|
||||
"oauth_client_secret_description": "機密用戶端嘅必填項目;如果公開用戶端唔支援 PKCE (代碼交換的驗證金鑰),必須填寫。",
|
||||
"oauth_enable_description": "用 OAuth 登入",
|
||||
"oauth_end_session_url_description": "用戶登出時將佢導向到呢個URL。",
|
||||
"oauth_mobile_redirect_uri": "流動裝置重新導向 URI",
|
||||
"oauth_mobile_redirect_uri_override": "覆寫流動裝置重新導向 URI",
|
||||
"oauth_mobile_redirect_uri_override_description": "當 OAuth 提供者唔俾用流動裝置 URI(例如 ''{callback}'')嘅時候啟用",
|
||||
"oauth_prompt_description": "Prompt 參數(例如 select_account、login、consent)",
|
||||
"oauth_role_claim": "角色宣告",
|
||||
"oauth_role_claim_description": "根據呢個宣告嘅存在,自動授權管理員權限。呢啲宣告值可以係 'user' 或 'admin'。",
|
||||
"oauth_settings": "OAuth",
|
||||
"oauth_settings_description": "管理 OAuth 登入設定",
|
||||
"oauth_settings_more_details": "如果想了解呢個功能嘅詳細資訊,請參閱 <link>說明文件</link>。",
|
||||
"oauth_storage_label_claim": "儲存標籤宣告",
|
||||
"oauth_storage_label_claim_description": "自動將使用者嘅儲存標籤設定為呢個宣告值。",
|
||||
"oauth_storage_quota_claim": "儲存限額宣告",
|
||||
"queue_details": "隊列資訊",
|
||||
"queues": "任務隊列",
|
||||
"queues_page_description": "管理員任務隊列頁面",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "3.0.0rc2"
|
||||
version = "3.0.0rc1"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
Generated
+1
-1
@@ -974,7 +974,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "3.0.0rc2"
|
||||
version = "3.0.0rc1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -83,12 +83,17 @@ version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
|
||||
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
@@ -112,17 +117,12 @@ version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
|
||||
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
|
||||
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
@@ -188,6 +188,30 @@ url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288
|
||||
version = "21.0.2"
|
||||
backend = "core:java"
|
||||
|
||||
[tools.java."platforms.linux-arm64"]
|
||||
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
|
||||
|
||||
[tools.java."platforms.linux-x64"]
|
||||
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
|
||||
|
||||
[tools.java."platforms.macos-arm64"]
|
||||
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
|
||||
|
||||
[tools.java."platforms.macos-x64"]
|
||||
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
|
||||
|
||||
[tools.java."platforms.windows-x64"]
|
||||
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
|
||||
|
||||
[[tools.java]]
|
||||
version = "21.0.2"
|
||||
backend = "core:java"
|
||||
|
||||
[tools.java.options]
|
||||
shorthand_vendor = "openjdk"
|
||||
|
||||
@@ -280,37 +304,37 @@ checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c70773
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
|
||||
|
||||
[[tools.pnpm]]
|
||||
version = "11.6.0"
|
||||
version = "11.5.2"
|
||||
backend = "aqua:pnpm/pnpm"
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64"]
|
||||
checksum = "sha256:2fec653ff6dadab340d1c3d2214688a7451cc471f39710839440b293ca7c53b0"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64.tar.gz"
|
||||
checksum = "sha256:7fef0c74081135d777754fccf25272f698e504b26ba0568504846c0cea402f8f"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-linux-arm64.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:56a78c08cf22adf29e7dacb6f7f100139731693863d20fb94a7883463a62169e"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64-musl.tar.gz"
|
||||
checksum = "sha256:843beed7bca760276d29f8950ca219600995d345dbc93fad8150b3e5f83b74d4"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-linux-arm64-musl.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64"]
|
||||
checksum = "sha256:74d64c1646385fb21691f32f0ab6aca1a9f5c829ba54d3cda3a24838a228e68c"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64.tar.gz"
|
||||
checksum = "sha256:2033a702618c8576dc6bb0f6adb3a67ab506031351ddd59ca50d1bcaf5d13dc5"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-linux-x64.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:7a0c463a09d912fba6b7d9eca0a946bc228ea50f3015a05c09a29e7e85a932d7"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64-musl.tar.gz"
|
||||
checksum = "sha256:0b794b23461c7475f7ffc29c4945692838b51ddadd857a2ace8edf0018798305"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-linux-x64-musl.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.macos-arm64"]
|
||||
checksum = "sha256:87c901635a14481fb30566a3749041134ffd4317bc6fe866c345b69fdf9b6b85"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-darwin-arm64.tar.gz"
|
||||
checksum = "sha256:54993dae26bea0f3c1b0e15f9427f6f6a86827d56f32d1d1554d8cda59a62399"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-darwin-arm64.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.windows-x64"]
|
||||
checksum = "sha256:91c753435542b04859c689304fae0dd64eba6b40937cfa426a48485b712e4e9c"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-win32-x64.zip"
|
||||
checksum = "sha256:b3ddff2c2bf87d3996fadf074bac58cd2259f718a17912a04ae930e3775b30e9"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-win32-x64.zip"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
|
||||
@@ -16,7 +16,7 @@ config_roots = [
|
||||
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
pnpm = "11.6.0"
|
||||
pnpm = "11.5.2"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
"npm:oazapfts" = "7.5.0"
|
||||
|
||||
+12
-231
@@ -207,18 +207,6 @@ enum class PlatformAssetPlaybackStyle(val raw: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class EditState(val raw: Int) {
|
||||
NOT_EDITED(0),
|
||||
EDITED(1),
|
||||
UNKNOWN(2);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): EditState? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class PlatformAsset (
|
||||
val id: String,
|
||||
@@ -234,9 +222,7 @@ data class PlatformAsset (
|
||||
val adjustmentTime: Long? = null,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
val playbackStyle: PlatformAssetPlaybackStyle,
|
||||
val burstId: String? = null,
|
||||
val isBurstRepresentative: Boolean
|
||||
val playbackStyle: PlatformAssetPlaybackStyle
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
@@ -255,9 +241,7 @@ data class PlatformAsset (
|
||||
val latitude = pigeonVar_list[11] as Double?
|
||||
val longitude = pigeonVar_list[12] as Double?
|
||||
val playbackStyle = pigeonVar_list[13] as PlatformAssetPlaybackStyle
|
||||
val burstId = pigeonVar_list[14] as String?
|
||||
val isBurstRepresentative = pigeonVar_list[15] as Boolean
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationMs, orientation, isFavorite, adjustmentTime, latitude, longitude, playbackStyle, burstId, isBurstRepresentative)
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationMs, orientation, isFavorite, adjustmentTime, latitude, longitude, playbackStyle)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
@@ -276,8 +260,6 @@ data class PlatformAsset (
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
burstId,
|
||||
isBurstRepresentative,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
@@ -288,7 +270,7 @@ data class PlatformAsset (
|
||||
return true
|
||||
}
|
||||
val other = other as PlatformAsset
|
||||
return MessagesPigeonUtils.deepEquals(this.id, other.id) && MessagesPigeonUtils.deepEquals(this.name, other.name) && MessagesPigeonUtils.deepEquals(this.type, other.type) && MessagesPigeonUtils.deepEquals(this.createdAt, other.createdAt) && MessagesPigeonUtils.deepEquals(this.updatedAt, other.updatedAt) && MessagesPigeonUtils.deepEquals(this.width, other.width) && MessagesPigeonUtils.deepEquals(this.height, other.height) && MessagesPigeonUtils.deepEquals(this.durationMs, other.durationMs) && MessagesPigeonUtils.deepEquals(this.orientation, other.orientation) && MessagesPigeonUtils.deepEquals(this.isFavorite, other.isFavorite) && MessagesPigeonUtils.deepEquals(this.adjustmentTime, other.adjustmentTime) && MessagesPigeonUtils.deepEquals(this.latitude, other.latitude) && MessagesPigeonUtils.deepEquals(this.longitude, other.longitude) && MessagesPigeonUtils.deepEquals(this.playbackStyle, other.playbackStyle) && MessagesPigeonUtils.deepEquals(this.burstId, other.burstId) && MessagesPigeonUtils.deepEquals(this.isBurstRepresentative, other.isBurstRepresentative)
|
||||
return MessagesPigeonUtils.deepEquals(this.id, other.id) && MessagesPigeonUtils.deepEquals(this.name, other.name) && MessagesPigeonUtils.deepEquals(this.type, other.type) && MessagesPigeonUtils.deepEquals(this.createdAt, other.createdAt) && MessagesPigeonUtils.deepEquals(this.updatedAt, other.updatedAt) && MessagesPigeonUtils.deepEquals(this.width, other.width) && MessagesPigeonUtils.deepEquals(this.height, other.height) && MessagesPigeonUtils.deepEquals(this.durationMs, other.durationMs) && MessagesPigeonUtils.deepEquals(this.orientation, other.orientation) && MessagesPigeonUtils.deepEquals(this.isFavorite, other.isFavorite) && MessagesPigeonUtils.deepEquals(this.adjustmentTime, other.adjustmentTime) && MessagesPigeonUtils.deepEquals(this.latitude, other.latitude) && MessagesPigeonUtils.deepEquals(this.longitude, other.longitude) && MessagesPigeonUtils.deepEquals(this.playbackStyle, other.playbackStyle)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
@@ -307,8 +289,6 @@ data class PlatformAsset (
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.latitude)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.longitude)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.playbackStyle)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.burstId)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.isBurstRepresentative)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -492,82 +472,6 @@ data class CloudIdResult (
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class BaseResource (
|
||||
val path: String,
|
||||
val sha1: String
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): BaseResource {
|
||||
val path = pigeonVar_list[0] as String
|
||||
val sha1 = pigeonVar_list[1] as String
|
||||
return BaseResource(path, sha1)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
path,
|
||||
sha1,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as BaseResource
|
||||
return MessagesPigeonUtils.deepEquals(this.path, other.path) && MessagesPigeonUtils.deepEquals(this.sha1, other.sha1)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.path)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.sha1)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class BaseLivePhoto (
|
||||
val still: BaseResource,
|
||||
val video: BaseResource? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): BaseLivePhoto {
|
||||
val still = pigeonVar_list[0] as BaseResource
|
||||
val video = pigeonVar_list[1] as BaseResource?
|
||||
return BaseLivePhoto(still, video)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
still,
|
||||
video,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as BaseLivePhoto
|
||||
return MessagesPigeonUtils.deepEquals(this.still, other.still) && MessagesPigeonUtils.deepEquals(this.video, other.video)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.still)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.video)
|
||||
return result
|
||||
}
|
||||
}
|
||||
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
@@ -577,45 +481,30 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
EditState.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PlatformAsset.fromList(it)
|
||||
}
|
||||
}
|
||||
132.toByte() -> {
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PlatformAlbum.fromList(it)
|
||||
}
|
||||
}
|
||||
133.toByte() -> {
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SyncDelta.fromList(it)
|
||||
}
|
||||
}
|
||||
134.toByte() -> {
|
||||
133.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
HashResult.fromList(it)
|
||||
}
|
||||
}
|
||||
135.toByte() -> {
|
||||
134.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
CloudIdResult.fromList(it)
|
||||
}
|
||||
}
|
||||
136.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
BaseResource.fromList(it)
|
||||
}
|
||||
}
|
||||
137.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
BaseLivePhoto.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
@@ -625,36 +514,24 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is EditState -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is PlatformAsset -> {
|
||||
stream.write(131)
|
||||
stream.write(130)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is PlatformAlbum -> {
|
||||
stream.write(132)
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SyncDelta -> {
|
||||
stream.write(133)
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is HashResult -> {
|
||||
stream.write(134)
|
||||
stream.write(133)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is CloudIdResult -> {
|
||||
stream.write(135)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is BaseResource -> {
|
||||
stream.write(136)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is BaseLivePhoto -> {
|
||||
stream.write(137)
|
||||
stream.write(134)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
@@ -679,18 +556,6 @@ interface NativeSyncApi {
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit)
|
||||
/**
|
||||
* Streams the bytes immich treats as the asset's canonical content — the same
|
||||
* resource [hashAssets] hashes (`PHAsset.getResource()`, the `.isCurrent`
|
||||
* rendition). Used to upload iOS burst members: they're invisible to
|
||||
* photo_manager, so this is the only way to read their file, and streaming
|
||||
* the same resource the hash measured keeps the server checksum aligned with
|
||||
* the local one (else the asset shows cloud-only). iOS-only; android returns null.
|
||||
*/
|
||||
fun getCurrentResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit)
|
||||
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit)
|
||||
fun getBaseLivePhoto(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseLivePhoto?>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by NativeSyncApi. */
|
||||
@@ -953,90 +818,6 @@ interface NativeSyncApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val allowNetworkAccessArg = args[1] as Boolean
|
||||
api.getBaseResource(assetIdArg, allowNetworkAccessArg) { result: Result<BaseResource?> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCurrentResource$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val allowNetworkAccessArg = args[1] as Boolean
|
||||
api.getCurrentResource(assetIdArg, allowNetworkAccessArg) { result: Result<BaseResource?> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val allowNetworkAccessArg = args[1] as Boolean
|
||||
api.getEditState(assetIdArg, allowNetworkAccessArg) { result: Result<EditState> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val allowNetworkAccessArg = args[1] as Boolean
|
||||
api.getBaseLivePhoto(assetIdArg, allowNetworkAccessArg) { result: Result<BaseLivePhoto?> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,8 +204,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
0L,
|
||||
isFavorite,
|
||||
playbackStyle = playbackStyle,
|
||||
// Android has no burstIdentifier equivalent in MediaStore — bursts are iOS-only.
|
||||
isBurstRepresentative = false,
|
||||
)
|
||||
yield(AssetResult.ValidAsset(asset, bucketId))
|
||||
}
|
||||
@@ -511,25 +509,4 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// Android has no Photos-style edit original to stack; iOS-only.
|
||||
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit) {
|
||||
completeWhenActive(callback, Result.success(null))
|
||||
}
|
||||
|
||||
// iOS-only; burst members are an iOS concept. Android resolves every asset via
|
||||
// MediaStore already, so there's no hidden-member byte fetch to provide.
|
||||
fun getCurrentResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit) {
|
||||
completeWhenActive(callback, Result.success(null))
|
||||
}
|
||||
|
||||
// iOS-only; Android assets never carry a Photos-style edit.
|
||||
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit) {
|
||||
completeWhenActive(callback, Result.success(EditState.NOT_EDITED))
|
||||
}
|
||||
|
||||
// iOS-only; Android assets never carry a Photos-style live edit.
|
||||
fun getBaseLivePhoto(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseLivePhoto?>) -> Unit) {
|
||||
completeWhenActive(callback, Result.success(null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3050,
|
||||
"android.injected.version.name" => "3.0.0-rc.2",
|
||||
"android.injected.version.code" => 3049,
|
||||
"android.injected.version.name" => "3.0.0-rc.1",
|
||||
}
|
||||
)
|
||||
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" => 3050,
|
||||
"android.injected.version.name" => "3.0.0-rc.2",
|
||||
"android.injected.version.code" => 3049,
|
||||
"android.injected.version.name" => "3.0.0-rc.1",
|
||||
}
|
||||
)
|
||||
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')
|
||||
|
||||
-3693
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -11,7 +11,6 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
467DA6EAF83F3481F8BD94AB /* Pods_ShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8AB817AA297EDEC88B23F3F6 /* Pods_ShareExtension.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
@@ -20,9 +19,9 @@
|
||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
|
||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
|
||||
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
|
||||
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
|
||||
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
|
||||
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
|
||||
D3BED739C0BC29BB32E18EB2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC499FBCE6B29B2DAFED7130 /* Pods_Runner.framework */; };
|
||||
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */; };
|
||||
@@ -40,6 +39,7 @@
|
||||
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
|
||||
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
|
||||
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -94,7 +94,6 @@
|
||||
6D160F04A389B9FFBC557803 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
8AB817AA297EDEC88B23F3F6 /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
937632897A02DE9C249F20A6 /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -110,9 +109,9 @@
|
||||
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
|
||||
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
|
||||
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
|
||||
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
|
||||
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = "<group>"; };
|
||||
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = "<group>"; };
|
||||
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
|
||||
C4A6A71F33CE37B3C913115C /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
CC499FBCE6B29B2DAFED7130 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -131,6 +130,7 @@
|
||||
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
|
||||
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
|
||||
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -153,11 +153,15 @@
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Core;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Sync;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -179,6 +183,8 @@
|
||||
};
|
||||
FEE084F22EC172080045228E /* Schemas */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Schemas;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -358,6 +364,9 @@
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
@@ -464,7 +473,7 @@
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
|
||||
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
|
||||
);
|
||||
@@ -519,14 +528,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
@@ -556,14 +561,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
@@ -757,7 +758,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
NEW_SETTING = "";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -776,7 +777,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 240;
|
||||
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -785,7 +786,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.0.0;
|
||||
MARKETING_VERSION = 1.121.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile;
|
||||
PRODUCT_NAME = "Immich-Profile";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -846,7 +847,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
NEW_SETTING = "";
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -900,7 +901,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
NEW_SETTING = "";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -921,7 +922,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 240;
|
||||
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -930,7 +931,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.0.0;
|
||||
MARKETING_VERSION = 1.121.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug;
|
||||
PRODUCT_NAME = "Immich-Debug";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -950,7 +951,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 240;
|
||||
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -959,7 +960,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.0.0;
|
||||
MARKETING_VERSION = 1.121.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
|
||||
PRODUCT_NAME = Immich;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1261,7 +1262,7 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
@@ -1306,7 +1307,17 @@
|
||||
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
|
||||
productName = StructuredFieldValues;
|
||||
};
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
|
||||
+202
-203
@@ -1,206 +1,205 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>app.alextran.immich.background.refreshUpload</string>
|
||||
<string>app.alextran.immich.background.processingUpload</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true />
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${PRODUCT_NAME}</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>ShareHandler</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.file-url</string>
|
||||
<string>public.image</string>
|
||||
<string>public.text</string>
|
||||
<string>public.movie</string>
|
||||
<string>public.url</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>ar</string>
|
||||
<string>ca</string>
|
||||
<string>cs</string>
|
||||
<string>da</string>
|
||||
<string>de</string>
|
||||
<string>es</string>
|
||||
<string>fi</string>
|
||||
<string>fr</string>
|
||||
<string>he</string>
|
||||
<string>hi</string>
|
||||
<string>hu</string>
|
||||
<string>it</string>
|
||||
<string>ja</string>
|
||||
<string>ko</string>
|
||||
<string>lv</string>
|
||||
<string>mn</string>
|
||||
<string>nb</string>
|
||||
<string>nl</string>
|
||||
<string>pl</string>
|
||||
<string>pt</string>
|
||||
<string>ro</string>
|
||||
<string>ru</string>
|
||||
<string>sk</string>
|
||||
<string>sl</string>
|
||||
<string>sr</string>
|
||||
<string>sv</string>
|
||||
<string>th</string>
|
||||
<string>uk</string>
|
||||
<string>vi</string>
|
||||
<string>zh</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>immich_mobile</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.0.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Share Extension</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Deep Link</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>immich</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>4</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false />
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true />
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<string>No</string>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true />
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true />
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>We need to use FaceID to allow access to your locked folder</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>We need local network permission to connect to the local server using IP address and
|
||||
allow the casting feature to work</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>We need to access the microphone to let you take beautiful video using this app</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false />
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>FlutterSceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true />
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false />
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true />
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true />
|
||||
</dict>
|
||||
</plist>
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>app.alextran.immich.background.refreshUpload</string>
|
||||
<string>app.alextran.immich.background.processingUpload</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${PRODUCT_NAME}</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>ShareHandler</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.file-url</string>
|
||||
<string>public.image</string>
|
||||
<string>public.text</string>
|
||||
<string>public.movie</string>
|
||||
<string>public.url</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>ar</string>
|
||||
<string>ca</string>
|
||||
<string>cs</string>
|
||||
<string>da</string>
|
||||
<string>de</string>
|
||||
<string>es</string>
|
||||
<string>fi</string>
|
||||
<string>fr</string>
|
||||
<string>he</string>
|
||||
<string>hi</string>
|
||||
<string>hu</string>
|
||||
<string>it</string>
|
||||
<string>ja</string>
|
||||
<string>ko</string>
|
||||
<string>lv</string>
|
||||
<string>mn</string>
|
||||
<string>nb</string>
|
||||
<string>nl</string>
|
||||
<string>pl</string>
|
||||
<string>pt</string>
|
||||
<string>ro</string>
|
||||
<string>ru</string>
|
||||
<string>sk</string>
|
||||
<string>sl</string>
|
||||
<string>sr</string>
|
||||
<string>sv</string>
|
||||
<string>th</string>
|
||||
<string>uk</string>
|
||||
<string>vi</string>
|
||||
<string>zh</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>immich_mobile</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.0.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Share Extension</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Deep Link</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>immich</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>240</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<string>No</string>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>We need to use FaceID to allow access to your locked folder</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>We need local network permission to connect to the local server using IP address and allow the casting feature to work</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>We need to access the microphone to let you take beautiful video using this app</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>FlutterSceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Generated
+11
-214
@@ -183,12 +183,6 @@ enum PlatformAssetPlaybackStyle: Int {
|
||||
case videoLooping = 5
|
||||
}
|
||||
|
||||
enum EditState: Int {
|
||||
case notEdited = 0
|
||||
case edited = 1
|
||||
case unknown = 2
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct PlatformAsset: Hashable {
|
||||
var id: String
|
||||
@@ -205,8 +199,6 @@ struct PlatformAsset: Hashable {
|
||||
var latitude: Double? = nil
|
||||
var longitude: Double? = nil
|
||||
var playbackStyle: PlatformAssetPlaybackStyle
|
||||
var burstId: String? = nil
|
||||
var isBurstRepresentative: Bool
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
@@ -225,8 +217,6 @@ struct PlatformAsset: Hashable {
|
||||
let latitude: Double? = nilOrValue(pigeonVar_list[11])
|
||||
let longitude: Double? = nilOrValue(pigeonVar_list[12])
|
||||
let playbackStyle = pigeonVar_list[13] as! PlatformAssetPlaybackStyle
|
||||
let burstId: String? = nilOrValue(pigeonVar_list[14])
|
||||
let isBurstRepresentative = pigeonVar_list[15] as! Bool
|
||||
|
||||
return PlatformAsset(
|
||||
id: id,
|
||||
@@ -242,9 +232,7 @@ struct PlatformAsset: Hashable {
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
burstId: burstId,
|
||||
isBurstRepresentative: isBurstRepresentative
|
||||
playbackStyle: playbackStyle
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
@@ -263,15 +251,13 @@ struct PlatformAsset: Hashable {
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
burstId,
|
||||
isBurstRepresentative,
|
||||
]
|
||||
}
|
||||
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
||||
if Swift.type(of: lhs) != Swift.type(of: rhs) {
|
||||
return false
|
||||
}
|
||||
return deepEqualsMessages(lhs.id, rhs.id) && deepEqualsMessages(lhs.name, rhs.name) && deepEqualsMessages(lhs.type, rhs.type) && deepEqualsMessages(lhs.createdAt, rhs.createdAt) && deepEqualsMessages(lhs.updatedAt, rhs.updatedAt) && deepEqualsMessages(lhs.width, rhs.width) && deepEqualsMessages(lhs.height, rhs.height) && deepEqualsMessages(lhs.durationMs, rhs.durationMs) && deepEqualsMessages(lhs.orientation, rhs.orientation) && deepEqualsMessages(lhs.isFavorite, rhs.isFavorite) && deepEqualsMessages(lhs.adjustmentTime, rhs.adjustmentTime) && deepEqualsMessages(lhs.latitude, rhs.latitude) && deepEqualsMessages(lhs.longitude, rhs.longitude) && deepEqualsMessages(lhs.playbackStyle, rhs.playbackStyle) && deepEqualsMessages(lhs.burstId, rhs.burstId) && deepEqualsMessages(lhs.isBurstRepresentative, rhs.isBurstRepresentative)
|
||||
return deepEqualsMessages(lhs.id, rhs.id) && deepEqualsMessages(lhs.name, rhs.name) && deepEqualsMessages(lhs.type, rhs.type) && deepEqualsMessages(lhs.createdAt, rhs.createdAt) && deepEqualsMessages(lhs.updatedAt, rhs.updatedAt) && deepEqualsMessages(lhs.width, rhs.width) && deepEqualsMessages(lhs.height, rhs.height) && deepEqualsMessages(lhs.durationMs, rhs.durationMs) && deepEqualsMessages(lhs.orientation, rhs.orientation) && deepEqualsMessages(lhs.isFavorite, rhs.isFavorite) && deepEqualsMessages(lhs.adjustmentTime, rhs.adjustmentTime) && deepEqualsMessages(lhs.latitude, rhs.latitude) && deepEqualsMessages(lhs.longitude, rhs.longitude) && deepEqualsMessages(lhs.playbackStyle, rhs.playbackStyle)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
@@ -290,8 +276,6 @@ struct PlatformAsset: Hashable {
|
||||
deepHashMessages(value: latitude, hasher: &hasher)
|
||||
deepHashMessages(value: longitude, hasher: &hasher)
|
||||
deepHashMessages(value: playbackStyle, hasher: &hasher)
|
||||
deepHashMessages(value: burstId, hasher: &hasher)
|
||||
deepHashMessages(value: isBurstRepresentative, hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,78 +458,6 @@ struct CloudIdResult: Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct BaseResource: Hashable {
|
||||
var path: String
|
||||
var sha1: String
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> BaseResource? {
|
||||
let path = pigeonVar_list[0] as! String
|
||||
let sha1 = pigeonVar_list[1] as! String
|
||||
|
||||
return BaseResource(
|
||||
path: path,
|
||||
sha1: sha1
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
path,
|
||||
sha1,
|
||||
]
|
||||
}
|
||||
static func == (lhs: BaseResource, rhs: BaseResource) -> Bool {
|
||||
if Swift.type(of: lhs) != Swift.type(of: rhs) {
|
||||
return false
|
||||
}
|
||||
return deepEqualsMessages(lhs.path, rhs.path) && deepEqualsMessages(lhs.sha1, rhs.sha1)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("BaseResource")
|
||||
deepHashMessages(value: path, hasher: &hasher)
|
||||
deepHashMessages(value: sha1, hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct BaseLivePhoto: Hashable {
|
||||
var still: BaseResource
|
||||
var video: BaseResource? = nil
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> BaseLivePhoto? {
|
||||
let still = pigeonVar_list[0] as! BaseResource
|
||||
let video: BaseResource? = nilOrValue(pigeonVar_list[1])
|
||||
|
||||
return BaseLivePhoto(
|
||||
still: still,
|
||||
video: video
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
still,
|
||||
video,
|
||||
]
|
||||
}
|
||||
static func == (lhs: BaseLivePhoto, rhs: BaseLivePhoto) -> Bool {
|
||||
if Swift.type(of: lhs) != Swift.type(of: rhs) {
|
||||
return false
|
||||
}
|
||||
return deepEqualsMessages(lhs.still, rhs.still) && deepEqualsMessages(lhs.video, rhs.video)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("BaseLivePhoto")
|
||||
deepHashMessages(value: still, hasher: &hasher)
|
||||
deepHashMessages(value: video, hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
@@ -556,25 +468,15 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
}
|
||||
return nil
|
||||
case 130:
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return EditState(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
case 131:
|
||||
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
||||
case 132:
|
||||
case 131:
|
||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||
case 133:
|
||||
case 132:
|
||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||
case 134:
|
||||
case 133:
|
||||
return HashResult.fromList(self.readValue() as! [Any?])
|
||||
case 135:
|
||||
case 134:
|
||||
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
||||
case 136:
|
||||
return BaseResource.fromList(self.readValue() as! [Any?])
|
||||
case 137:
|
||||
return BaseLivePhoto.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
@@ -586,29 +488,20 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
||||
if let value = value as? PlatformAssetPlaybackStyle {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? EditState {
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? PlatformAsset {
|
||||
super.writeByte(131)
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? PlatformAlbum {
|
||||
super.writeByte(132)
|
||||
super.writeByte(131)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? SyncDelta {
|
||||
super.writeByte(133)
|
||||
super.writeByte(132)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? HashResult {
|
||||
super.writeByte(134)
|
||||
super.writeByte(133)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? CloudIdResult {
|
||||
super.writeByte(135)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? BaseResource {
|
||||
super.writeByte(136)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? BaseLivePhoto {
|
||||
super.writeByte(137)
|
||||
super.writeByte(134)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
@@ -647,16 +540,6 @@ protocol NativeSyncApi {
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||
func getBaseResource(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseResource?, Error>) -> Void)
|
||||
/// Streams the bytes immich treats as the asset's canonical content — the same
|
||||
/// resource [hashAssets] hashes (`PHAsset.getResource()`, the `.isCurrent`
|
||||
/// rendition). Used to upload iOS burst members: they're invisible to
|
||||
/// photo_manager, so this is the only way to read their file, and streaming
|
||||
/// the same resource the hash measured keeps the server checksum aligned with
|
||||
/// the local one (else the asset shows cloud-only). iOS-only; android returns null.
|
||||
func getCurrentResource(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseResource?, Error>) -> Void)
|
||||
func getEditState(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<EditState, Error>) -> Void)
|
||||
func getBaseLivePhoto(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseLivePhoto?, Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
@@ -890,91 +773,5 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getBaseResourceChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getBaseResourceChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getBaseResource(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getBaseResourceChannel.setMessageHandler(nil)
|
||||
}
|
||||
/// Streams the bytes immich treats as the asset's canonical content — the same
|
||||
/// resource [hashAssets] hashes (`PHAsset.getResource()`, the `.isCurrent`
|
||||
/// rendition). Used to upload iOS burst members: they're invisible to
|
||||
/// photo_manager, so this is the only way to read their file, and streaming
|
||||
/// the same resource the hash measured keeps the server checksum aligned with
|
||||
/// the local one (else the asset shows cloud-only). iOS-only; android returns null.
|
||||
let getCurrentResourceChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCurrentResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCurrentResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getCurrentResourceChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getCurrentResource(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getCurrentResourceChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getEditStateChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getEditStateChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getEditState(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getEditStateChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getBaseLivePhotoChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getBaseLivePhotoChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getBaseLivePhoto(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getBaseLivePhotoChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Photos
|
||||
import CryptoKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct AssetWrapper: Hashable, Equatable {
|
||||
let asset: PlatformAsset
|
||||
@@ -116,7 +115,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
let options = PHFetchOptions()
|
||||
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
|
||||
options.includeHiddenAssets = false
|
||||
|
||||
|
||||
let assets = getAssetsFromAlbum(in: album, options: options)
|
||||
|
||||
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
|
||||
@@ -176,14 +175,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
deletedAssets.formUnion(details.deletedLocalIdentifiers)
|
||||
|
||||
if (updated.isEmpty) { continue }
|
||||
|
||||
|
||||
let options = PHFetchOptions()
|
||||
options.includeHiddenAssets = false
|
||||
options.includeAllBurstAssets = true
|
||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
|
||||
for i in 0..<result.count {
|
||||
let asset = result.object(at: i)
|
||||
|
||||
|
||||
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
|
||||
let predicate = PlatformAsset(
|
||||
id: asset.localIdentifier,
|
||||
@@ -192,28 +190,14 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
durationMs: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: .unknown,
|
||||
isBurstRepresentative: false
|
||||
playbackStyle: .unknown
|
||||
)
|
||||
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
|
||||
updatedAssets.insert(domainAsset)
|
||||
|
||||
// iOS reports only the representative in change details, so a delta sync
|
||||
// would otherwise miss the other frames. Pull the full burst group
|
||||
// explicitly and add each member (deduped by the wrapper's id).
|
||||
if let burstId = asset.burstIdentifier {
|
||||
let burstOptions = PHFetchOptions()
|
||||
burstOptions.includeHiddenAssets = false
|
||||
burstOptions.includeAllBurstAssets = true
|
||||
let members = PHAsset.fetchAssets(withBurstIdentifier: burstId, options: burstOptions)
|
||||
members.enumerateObjects { (member, _, _) in
|
||||
updatedAssets.insert(AssetWrapper(with: member.toPlatformAsset()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,12 +309,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
var missingAssetIds = Set(assetIds)
|
||||
var assets = [PHAsset]()
|
||||
assets.reserveCapacity(assetIds.count)
|
||||
// includeAllBurstAssets: a non-representative burst member is invisible to a
|
||||
// default fetch-by-id, so without this it'd be reported "not found" and never
|
||||
// hashed — leaving it out of the backup candidate set permanently.
|
||||
let hashFetchOptions = PHFetchOptions()
|
||||
hashFetchOptions.includeAllBurstAssets = true
|
||||
PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: hashFetchOptions).enumerateObjects { (asset, _, stop) in
|
||||
PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil).enumerateObjects { (asset, _, stop) in
|
||||
if Task.isCancelled {
|
||||
stop.pointee = true
|
||||
return
|
||||
@@ -466,11 +445,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
|
||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||
// Surface every burst member, not just the auto-picked representative. Set in
|
||||
// the shared fetch helper so album asset counts and the asset lists stay
|
||||
// consistent — LocalSyncService compares assetCount against the synced set, so
|
||||
// a count that excludes burst members while the list includes them wedges sync.
|
||||
options.includeAllBurstAssets = true
|
||||
// Ensure to actually getting all assets for the Recents album
|
||||
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
||||
return PHAsset.fetchAssets(with: options)
|
||||
@@ -502,334 +476,4 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
return mappings;
|
||||
}
|
||||
|
||||
// Streams the asset's current rendition — the same resource hashAssets hashes
|
||||
// (getResource(): the isCurrent rendition, the lone .photo otherwise). Used for
|
||||
// iOS burst members, which photo_manager can't resolve by id; streaming the same
|
||||
// bytes the hash measured keeps the server checksum aligned with the local one
|
||||
// (else the asset shows cloud-only). includeAllBurstAssets so a non-rep resolves.
|
||||
func getCurrentResource(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<BaseResource?, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let options = PHFetchOptions()
|
||||
options.includeAllBurstAssets = true
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: options).firstObject,
|
||||
let resource = asset.getResource()
|
||||
else {
|
||||
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||
}
|
||||
do {
|
||||
let result = try await self.streamBaseResource(
|
||||
resource: resource,
|
||||
localId: assetId,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
self.completeWhenActive(for: completion, with: .success(result))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getBaseResource(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<BaseResource?, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
do {
|
||||
guard let originals = try await Self.originalsForEditedAsset(assetId, allowNetworkAccess: allowNetworkAccess)
|
||||
else {
|
||||
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||
}
|
||||
let result = try await self.streamBaseResource(
|
||||
resource: originals.still,
|
||||
localId: assetId,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
self.completeWhenActive(for: completion, with: .success(result))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reads both readable originals of an edited live photo (still + paired video) so the
|
||||
// backup can upload the unedited pair and stack the edit onto it. Same edited-only gate
|
||||
// as getBaseResource. video is nil when the asset has no paired video left to recover
|
||||
// (e.g. the edit turned Live off); the still temp is removed if the video read fails.
|
||||
func getBaseLivePhoto(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<BaseLivePhoto?, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
do {
|
||||
guard let originals = try await Self.originalsForEditedAsset(assetId, allowNetworkAccess: allowNetworkAccess)
|
||||
else {
|
||||
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||
}
|
||||
let still = try await self.streamBaseResource(
|
||||
resource: originals.still,
|
||||
localId: assetId,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
var video: BaseResource? = nil
|
||||
if let videoRes = originals.video {
|
||||
do {
|
||||
video = try await self.streamBaseResource(
|
||||
resource: videoRes,
|
||||
localId: assetId,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(atPath: still.path)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
self.completeWhenActive(for: completion, with: .success(BaseLivePhoto(still: still, video: video)))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns whether the asset carries a live Photos edit without reading the photo
|
||||
// itself, only the small adjustment metadata. The revert probe relies on this to
|
||||
// tell "not edited" apart from "couldn't read" (offloaded to iCloud), so it never
|
||||
// mistakes an unreadable edit for a revert.
|
||||
func getEditState(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<EditState, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
|
||||
// Not in the library, so don't answer "not edited" (the caller acts on that).
|
||||
return self.completeWhenActive(for: completion, with: .success(.unknown))
|
||||
}
|
||||
let state = await Self.classifyEdit(
|
||||
resources: PHAssetResource.assetResources(for: asset),
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
self.completeWhenActive(for: completion, with: .success(state))
|
||||
}
|
||||
}
|
||||
|
||||
// adjustmentRenderTypes for a photo with no real edit: a plain capture, a
|
||||
// Photographic Style, or a reverted edit. A real edit changes this value.
|
||||
private static let kNoEditRenderTypes = 27648
|
||||
|
||||
// Idle deadline for the base-resource reads: cancel only after this long with no
|
||||
// data received, so a stalled iCloud fetch can't hang the backup forever but a
|
||||
// big original on a slow link keeps downloading as long as chunks flow.
|
||||
private static let kBaseReadTimeoutSeconds: Double = 120
|
||||
|
||||
private final class ResourceRequestRef {
|
||||
var id: PHAssetResourceDataRequestID?
|
||||
// Written from the resource callback queue, read from the deadline timer;
|
||||
// unsynchronized on purpose — the read below clamps, so the worst case is
|
||||
// the timer re-arming one extra round.
|
||||
var lastActivity = DispatchTime.now()
|
||||
}
|
||||
|
||||
// Re-arming watchdog: fires after `delay`, cancels if nothing arrived for a full
|
||||
// timeout window, otherwise re-arms for the remainder of the window.
|
||||
private static func armIdleDeadline(_ ref: ResourceRequestRef, after delay: Double = kBaseReadTimeoutSeconds) {
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
|
||||
guard let id = ref.id else { return }
|
||||
let nowNs = DispatchTime.now().uptimeNanoseconds
|
||||
let lastNs = ref.lastActivity.uptimeNanoseconds
|
||||
// lastActivity can race ahead of the captured now; treat that as activity.
|
||||
let idle = nowNs > lastNs ? Double(nowNs - lastNs) / 1_000_000_000 : 0
|
||||
if idle >= kBaseReadTimeoutSeconds {
|
||||
PHAssetResourceManager.default().cancelDataRequest(id)
|
||||
} else {
|
||||
armIdleDeadline(ref, after: kBaseReadTimeoutSeconds - idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared gate for the base readers: fetch the asset, classify the edit from its
|
||||
// adjustment metadata, and pick the original resources. nil = positively nothing
|
||||
// to recover (missing asset, not edited, or no readable original still). An
|
||||
// unreadable plist throws instead — that's "can't tell right now", and Dart
|
||||
// defers the asset rather than uploading the edit standalone for good.
|
||||
private static func originalsForEditedAsset(
|
||||
_ assetId: String,
|
||||
allowNetworkAccess: Bool
|
||||
) async throws -> (still: PHAssetResource, video: PHAssetResource?)? {
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
|
||||
return nil
|
||||
}
|
||||
let resources = PHAssetResource.assetResources(for: asset)
|
||||
let state = await classifyEdit(resources: resources, allowNetworkAccess: allowNetworkAccess)
|
||||
if state == .unknown {
|
||||
throw PigeonError(
|
||||
code: "unknownEditState",
|
||||
message: "Could not read adjustment metadata for \(assetId)",
|
||||
details: nil
|
||||
)
|
||||
}
|
||||
guard state == .edited, let still = originalStillResource(resources) else {
|
||||
return nil
|
||||
}
|
||||
return (still, originalPairedVideoResource(resources))
|
||||
}
|
||||
|
||||
// Works out the edit state from Adjustments.plist only (never reads the photo).
|
||||
// adjustmentRenderTypes is the signal: a real edit moves it off the baseline, while a
|
||||
// plain capture, a Photographic Style, and a reverted edit all sit at the baseline. The
|
||||
// editor id is NOT reliable: com.apple.camera authors both styles and some real edits
|
||||
// (e.g. changing the Photographic Style after capture), so we key off the render types
|
||||
// alone. Cleanup and object-removal write AdjustmentsSecondary.data, which we count as
|
||||
// edited. unknown = couldn't read the plist (offloaded, no network).
|
||||
private static func classifyEdit(resources: [PHAssetResource], allowNetworkAccess: Bool) async -> EditState {
|
||||
if resources.contains(where: { $0.originalFilename == "AdjustmentsSecondary.data" }) {
|
||||
return .edited
|
||||
}
|
||||
guard let adjRes = resources.first(where: { $0.originalFilename == "Adjustments.plist" }) else {
|
||||
return .notEdited
|
||||
}
|
||||
guard let buf = await collectResourceData(adjRes, allowNetworkAccess: allowNetworkAccess),
|
||||
let plist = try? PropertyListSerialization.propertyList(from: buf, options: [], format: nil) as? [String: Any]
|
||||
else {
|
||||
return .unknown
|
||||
}
|
||||
let renderTypes = (plist["adjustmentRenderTypes"] as? NSNumber)?.intValue
|
||||
let isUserEdit = renderTypes != nil && renderTypes != kNoEditRenderTypes
|
||||
return isUserEdit ? .edited : .notEdited
|
||||
}
|
||||
|
||||
// The unedited original still, told apart from the edited "current" render by isCurrent.
|
||||
// Prefer the non-current .photo; fall back to the .adjustmentBasePhoto flavor some
|
||||
// creation-API / third-party-editor layouts use for the unaltered source (their .photo
|
||||
// IS the edited render, so this must come before the bare .photo net); last, a lone
|
||||
// .photo for single-resource assets or a failed isCurrent read.
|
||||
private static func originalStillResource(_ resources: [PHAssetResource]) -> PHAssetResource? {
|
||||
return resources.first(where: { $0.type == .photo && !$0.isCurrent })
|
||||
?? resources.first(where: { $0.type == .adjustmentBasePhoto })
|
||||
?? resources.first(where: { $0.type == .photo })
|
||||
}
|
||||
|
||||
// The unedited original paired video, same isCurrent / adjustment-base ordering as the
|
||||
// still. nil when the asset carries no paired video (not live, or Live turned off).
|
||||
private static func originalPairedVideoResource(_ resources: [PHAssetResource]) -> PHAssetResource? {
|
||||
return resources.first(where: { $0.type == .pairedVideo && !$0.isCurrent })
|
||||
?? resources.first(where: { $0.type == .adjustmentBasePairedVideo })
|
||||
?? resources.first(where: { $0.type == .pairedVideo })
|
||||
}
|
||||
|
||||
private func streamBaseResource(
|
||||
resource: PHAssetResource,
|
||||
localId: String,
|
||||
allowNetworkAccess: Bool
|
||||
) async throws -> BaseResource {
|
||||
let safeId = localId.replacingOccurrences(of: "/", with: "_")
|
||||
let suffix = UTType(resource.uniformTypeIdentifier)?.preferredFilenameExtension ?? "bin"
|
||||
// Library/Caches, not tmp: the chain can span launches and clearCache wipes
|
||||
// tmp at the start of every upload run. Swept by clearEditBaseCache instead.
|
||||
let cacheRoot =
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let tempDir = cacheRoot.appendingPathComponent("immich_base", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
|
||||
let unique = UUID().uuidString.prefix(8)
|
||||
let tempUrl = tempDir.appendingPathComponent("\(safeId)_\(unique)_base.\(suffix)")
|
||||
|
||||
// Write the resource to disk and hash it chunk by chunk, so a big original (e.g.
|
||||
// ProRAW) never sits fully in memory on the upload thread.
|
||||
FileManager.default.createFile(atPath: tempUrl.path, contents: nil)
|
||||
guard let handle = try? FileHandle(forWritingTo: tempUrl) else {
|
||||
try? FileManager.default.removeItem(at: tempUrl)
|
||||
throw PigeonError(
|
||||
code: "baseResourceWriteFailed",
|
||||
message: "Failed to open temp file for base resource \(localId)",
|
||||
details: nil
|
||||
)
|
||||
}
|
||||
|
||||
var hasher = Insecure.SHA1()
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||
|
||||
// Deadline + cancellation so a stalled iCloud read can't hang the backup forever;
|
||||
// a write failure also cancels right away instead of draining the download for nothing.
|
||||
let requestRef = ResourceRequestRef()
|
||||
let succeeded = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
|
||||
var writeFailed = false
|
||||
requestRef.id = PHAssetResourceManager.default().requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { chunk in
|
||||
requestRef.lastActivity = DispatchTime.now()
|
||||
if writeFailed { return }
|
||||
do {
|
||||
try handle.write(contentsOf: chunk)
|
||||
hasher.update(data: chunk)
|
||||
} catch {
|
||||
writeFailed = true
|
||||
if let id = requestRef.id {
|
||||
PHAssetResourceManager.default().cancelDataRequest(id)
|
||||
}
|
||||
}
|
||||
},
|
||||
completionHandler: { error in
|
||||
requestRef.id = nil
|
||||
continuation.resume(returning: error == nil && !writeFailed)
|
||||
}
|
||||
)
|
||||
Self.armIdleDeadline(requestRef)
|
||||
}
|
||||
|
||||
try? handle.close()
|
||||
|
||||
guard succeeded else {
|
||||
try? FileManager.default.removeItem(at: tempUrl)
|
||||
throw PigeonError(
|
||||
code: "baseResourceReadFailed",
|
||||
message: "Failed to read base resource for \(localId)",
|
||||
details: nil
|
||||
)
|
||||
}
|
||||
|
||||
let sha1 = Data(hasher.finalize()).base64EncodedString()
|
||||
return BaseResource(path: tempUrl.path, sha1: sha1)
|
||||
}
|
||||
|
||||
private static func collectResourceData(
|
||||
_ resource: PHAssetResource,
|
||||
allowNetworkAccess: Bool
|
||||
) async -> Data? {
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||
var buffer = Data()
|
||||
let requestRef = ResourceRequestRef()
|
||||
return await withCheckedContinuation { (continuation: CheckedContinuation<Data?, Never>) in
|
||||
requestRef.id = PHAssetResourceManager.default().requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { data in
|
||||
requestRef.lastActivity = DispatchTime.now()
|
||||
buffer.append(data)
|
||||
},
|
||||
completionHandler: { error in
|
||||
requestRef.id = nil
|
||||
continuation.resume(returning: error == nil ? buffer : nil)
|
||||
}
|
||||
)
|
||||
armIdleDeadline(requestRef)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -27,9 +27,7 @@ extension PHAsset {
|
||||
adjustmentTime: adjustmentTimestamp,
|
||||
latitude: location?.coordinate.latitude,
|
||||
longitude: location?.coordinate.longitude,
|
||||
playbackStyle: platformPlaybackStyle,
|
||||
burstId: burstIdentifier,
|
||||
isBurstRepresentative: representsBurst
|
||||
playbackStyle: platformPlaybackStyle
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,22 +20,6 @@ const String kSecuredPinCode = "secured_pin_code";
|
||||
const String kManualUploadGroup = 'manual_upload_group';
|
||||
const String kBackupGroup = 'backup_group';
|
||||
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
|
||||
const String kBackupEditPairGroup = 'backup_edit_pair_group';
|
||||
|
||||
// Upload multipart 'visibility' value for motion videos (server AssetVisibility.Hidden)
|
||||
// so they never flash onto the timeline before their still links them.
|
||||
const String kHiddenVisibility = 'hidden';
|
||||
|
||||
// Server's 400 message when stackParentId points at a trashed/deleted asset
|
||||
// (asset-media.service.ts). Matching it clears the stale prior stamps so the
|
||||
// next backup cycle re-resolves instead of looping on the same dead id.
|
||||
const String kDeadStackParentError = 'Cannot stack onto a trashed or missing asset';
|
||||
|
||||
// Multipart fields that stack a burst frame under its representative without
|
||||
// letting it steal the cover (server keepPrimary, asset-media.service.ts).
|
||||
// Empty when there's no anchor yet (rep-less group → standalone upload).
|
||||
Map<String, String> burstStackFields(String? anchorRemoteId) =>
|
||||
anchorRemoteId != null ? {'stackParentId': anchorRemoteId, 'keepPrimary': 'true'} : const {};
|
||||
const String kDownloadGroupImage = 'group_image';
|
||||
const String kDownloadGroupVideo = 'group_video';
|
||||
const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||
|
||||
@@ -12,19 +12,6 @@ class LocalAsset extends BaseAsset {
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
// Remote id of this asset's previous upload; used to stack a new edit under it.
|
||||
final String? priorRemoteId;
|
||||
|
||||
// Local checksum at the last sync action; lets backup skip an already-handled
|
||||
// local whose current render hashes fresh (the iOS revert case).
|
||||
final String? syncedChecksum;
|
||||
|
||||
// iOS burst grouping. burstId = PHAsset.burstIdentifier (null for non-burst).
|
||||
// isBurstRepresentative = the auto-picked lead frame (timeline tile + stack
|
||||
// anchor).
|
||||
final String? burstId;
|
||||
final bool isBurstRepresentative;
|
||||
|
||||
const LocalAsset({
|
||||
required this.id,
|
||||
String? remoteId,
|
||||
@@ -45,10 +32,6 @@ class LocalAsset extends BaseAsset {
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required super.isEdited,
|
||||
this.priorRemoteId,
|
||||
this.syncedChecksum,
|
||||
this.burstId,
|
||||
this.isBurstRepresentative = false,
|
||||
}) : remoteAssetId = remoteId;
|
||||
|
||||
@override
|
||||
@@ -137,10 +120,6 @@ class LocalAsset extends BaseAsset {
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
bool? isEdited,
|
||||
String? priorRemoteId,
|
||||
String? syncedChecksum,
|
||||
String? burstId,
|
||||
bool? isBurstRepresentative,
|
||||
}) {
|
||||
return LocalAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -161,10 +140,6 @@ class LocalAsset extends BaseAsset {
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
||||
burstId: burstId ?? this.burstId,
|
||||
isBurstRepresentative: isBurstRepresentative ?? this.isBurstRepresentative,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,6 @@ class RemoteAsset extends BaseAsset {
|
||||
final DateTime? uploadedAt;
|
||||
final DateTime? deletedAt;
|
||||
|
||||
// The linked local's current checksum. Differs from [checksum] when the link
|
||||
// came via priorRemoteId (the local re-encoded on device, e.g. a revert); local
|
||||
// renders are cache-keyed by this so on-device changes aren't shown stale.
|
||||
final String? localChecksum;
|
||||
|
||||
const RemoteAsset({
|
||||
required this.id,
|
||||
String? localId,
|
||||
@@ -38,7 +33,6 @@ class RemoteAsset extends BaseAsset {
|
||||
this.stackId,
|
||||
required super.isEdited,
|
||||
this.deletedAt,
|
||||
this.localChecksum,
|
||||
}) : localAssetId = localId;
|
||||
|
||||
@override
|
||||
@@ -97,8 +91,7 @@ class RemoteAsset extends BaseAsset {
|
||||
visibility == other.visibility &&
|
||||
stackId == other.stackId &&
|
||||
uploadedAt == other.uploadedAt &&
|
||||
deletedAt == other.deletedAt &&
|
||||
localChecksum == other.localChecksum;
|
||||
deletedAt == other.deletedAt;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -111,8 +104,7 @@ class RemoteAsset extends BaseAsset {
|
||||
visibility.hashCode ^
|
||||
stackId.hashCode ^
|
||||
uploadedAt.hashCode ^
|
||||
deletedAt.hashCode ^
|
||||
localChecksum.hashCode;
|
||||
deletedAt.hashCode;
|
||||
|
||||
RemoteAsset copyWith({
|
||||
String? id,
|
||||
@@ -134,7 +126,6 @@ class RemoteAsset extends BaseAsset {
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
DateTime? deletedAt,
|
||||
String? localChecksum,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -156,7 +147,6 @@ class RemoteAsset extends BaseAsset {
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
localChecksum: localChecksum ?? this.localChecksum,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -184,7 +174,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
super.livePhotoVideoId,
|
||||
super.stackId,
|
||||
super.isEdited = false,
|
||||
super.localChecksum,
|
||||
this.exifInfo = const ExifInfo(),
|
||||
});
|
||||
|
||||
@@ -223,7 +212,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
String? localChecksum,
|
||||
ExifInfo? exifInfo,
|
||||
}) {
|
||||
return RemoteAssetExif(
|
||||
@@ -246,7 +234,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
localChecksum: localChecksum ?? this.localChecksum,
|
||||
exifInfo: exifInfo ?? this.exifInfo, // Use the new parameter
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
return _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id);
|
||||
}
|
||||
|
||||
await _ref
|
||||
return _ref
|
||||
?.read(foregroundUploadServiceProvider)
|
||||
.uploadCandidates(currentUser.id, _cancellationToken, useSequentialUpload: true);
|
||||
},
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Handles an edit that was reverted in Photos. The local was uploaded as an edit
|
||||
/// before but isn't edited now, so flip the stack primary back to the original (via
|
||||
/// prior_remote_id) and mark it handled so we don't re-upload the reverted render.
|
||||
/// Nothing is trashed; all the edits stay in the stack.
|
||||
class EditRevertService {
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final _log = Logger('EditRevertService');
|
||||
|
||||
EditRevertService({
|
||||
required this._nativeSyncApi,
|
||||
required this._stackRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._assetApiRepository,
|
||||
});
|
||||
|
||||
/// Returns the remote id the stack cover was flipped back to when the asset
|
||||
/// was a revert and was handled (caller skips the upload and can report that
|
||||
/// id); null to fall through to the normal upload path.
|
||||
Future<String?> tryHandleRevert(LocalAsset asset) async {
|
||||
if (asset.priorRemoteId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only "not edited" is a revert. `edited` is a fresh edit, so let the pair flow
|
||||
// take it. `unknown` means we couldn't read the adjustment (offloaded to iCloud,
|
||||
// network off); bail there too instead of mistaking an unreadable edit for a
|
||||
// revert and flipping the stack. Network off keeps this a cheap offline read.
|
||||
try {
|
||||
final editState = await _nativeSyncApi
|
||||
.getEditState(asset.id, allowNetworkAccess: false)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (editState != EditState.notEdited) {
|
||||
return null;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.warning("edit-state probe failed for ${asset.id}", error, stack);
|
||||
return null;
|
||||
}
|
||||
|
||||
// It's a revert. Styled photos hit this path because iOS re-encodes the revert to
|
||||
// fresh bytes, so it looks like a new backup candidate and reaches upload.
|
||||
// Non-styled reverts hash back to the base instead, aren't candidates, and get
|
||||
// flipped at hash time in HashService._reconcileReverts. Fresh bytes match nothing
|
||||
// remote, so flip by structure: prior_remote_id is the current primary (the latest
|
||||
// edit), flip it back to the base.
|
||||
final String stackId;
|
||||
final String baseId;
|
||||
try {
|
||||
final foundStack = await _stackRepository.findStackIdByRemoteId(asset.priorRemoteId!);
|
||||
if (foundStack == null) {
|
||||
return null;
|
||||
}
|
||||
final base = await _stackRepository.findStackBaseId(foundStack, excludeId: asset.priorRemoteId!);
|
||||
if (base == null) {
|
||||
return null;
|
||||
}
|
||||
stackId = foundStack;
|
||||
baseId = base;
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert stack lookup failed for ${asset.id}", error, stack);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await _assetApiRepository.setStackPrimary(stackId, baseId);
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert primary flip failed for ${asset.id}", error, stack);
|
||||
return null;
|
||||
}
|
||||
|
||||
// The server flip is what makes the revert handled. If the local writes fail,
|
||||
// falling through would upload the reverted render as a brand-new edit — the
|
||||
// opposite of the user's action — so log and let checkpoint sync heal local state.
|
||||
try {
|
||||
await _stackRepository.setPrimary(stackId, baseId);
|
||||
await _localAssetRepository.markSynced(asset.id, priorRemoteId: baseId, syncedChecksum: asset.checksum);
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert local reconcile failed for ${asset.id}", error, stack);
|
||||
}
|
||||
|
||||
return baseId;
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,8 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
const String _kHashCancelledCode = "HASH_CANCELLED";
|
||||
@@ -22,8 +20,6 @@ class HashService {
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final Completer<void>? _cancellation;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final _log = Logger('HashService');
|
||||
|
||||
HashService({
|
||||
@@ -32,8 +28,6 @@ class HashService {
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._nativeSyncApi,
|
||||
this._cancellation,
|
||||
required this._stackRepository,
|
||||
required this._assetApiRepository,
|
||||
int? batchSize,
|
||||
}) : _batchSize = batchSize ?? kBatchHashFileLimit {
|
||||
// Stop the in-flight native hash call promptly on cancellation; the loops
|
||||
@@ -72,17 +66,6 @@ class HashService {
|
||||
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
|
||||
}
|
||||
}
|
||||
|
||||
// Revert reconcile for non-styled photos: the reverted edit hashes back to the
|
||||
// original's exact bytes, which are already the stack base, so it's not a backup
|
||||
// candidate and never reaches upload. Flip the primary here. Styled photos
|
||||
// re-encode to fresh bytes and get flipped on the upload path instead
|
||||
// (EditRevertService.tryHandleRevert). Runs every cycle, not just when something
|
||||
// hashed: a flip that failed (offline at hash time) has no second hash to ride,
|
||||
// and the stack-driven target query is cheap and self-limiting.
|
||||
if (CurrentPlatform.isIOS && !isCancelled) {
|
||||
await _reconcileReverts();
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _kHashCancelledCode) {
|
||||
_log.warning("Hashing cancelled by platform");
|
||||
@@ -160,30 +143,4 @@ class HashService {
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reconcileReverts() async {
|
||||
final List<StackReconcileTarget> targets;
|
||||
try {
|
||||
targets = await _stackRepository.findRevertReconcileTargets();
|
||||
} catch (error, stack) {
|
||||
_log.warning("findRevertReconcileTargets failed", error, stack);
|
||||
return;
|
||||
}
|
||||
|
||||
for (final target in targets) {
|
||||
try {
|
||||
await _assetApiRepository.setStackPrimary(target.stackId, target.newPrimaryId);
|
||||
await _stackRepository.setPrimary(target.stackId, target.newPrimaryId);
|
||||
// Roll priorRemoteId forward to the matched member (now the primary) so a
|
||||
// later edit stacks onto THAT (the current render), not the old edit.
|
||||
await _localAssetRepository.markSynced(
|
||||
target.localAssetId,
|
||||
priorRemoteId: target.newPrimaryId,
|
||||
syncedChecksum: target.localAssetChecksum,
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert reconcile flip failed for stack ${target.stackId}", error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,8 +469,6 @@ extension PlatformToLocalAsset on PlatformAsset {
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
isEdited: false,
|
||||
burstId: burstId,
|
||||
isBurstRepresentative: isBurstRepresentative,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
if (assets.isNotEmpty && exifs.isNotEmpty) {
|
||||
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-batch', fromWebsocket: true);
|
||||
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-batch');
|
||||
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
|
||||
_logger.info('Successfully processed ${assets.length} assets in batch');
|
||||
}
|
||||
@@ -403,7 +403,7 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
if (assets.isNotEmpty && exifs.isNotEmpty) {
|
||||
await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch', fromWebsocket: true);
|
||||
await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch');
|
||||
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
|
||||
_logger.info('Successfully processed ${assets.length} assets in batch');
|
||||
}
|
||||
@@ -444,7 +444,7 @@ class SyncStreamService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit', fromWebsocket: true);
|
||||
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit');
|
||||
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
|
||||
|
||||
_logger.info(
|
||||
@@ -482,7 +482,7 @@ class SyncStreamService {
|
||||
.whereType<SyncAssetEditV1>()
|
||||
.toList();
|
||||
|
||||
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit', fromWebsocket: true);
|
||||
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit');
|
||||
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
|
||||
|
||||
_logger.info(
|
||||
|
||||
@@ -160,22 +160,6 @@ class BackgroundSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs a remote sync guaranteed to observe changes up to now. [syncRemote]
|
||||
/// joins an in-flight sync whose snapshot can pre-date a just-received change
|
||||
/// (e.g. a stack update) and miss it, so wait for any in-flight sync to finish
|
||||
/// first, then run a fresh one.
|
||||
Future<void> runFreshRemoteSync() async {
|
||||
final inflight = _syncTask;
|
||||
if (inflight != null) {
|
||||
try {
|
||||
await inflight.future;
|
||||
} catch (_) {
|
||||
// The in-flight sync's outcome doesn't matter; we only need a fresh one after it.
|
||||
}
|
||||
}
|
||||
await syncRemote();
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
|
||||
@@ -7,8 +7,6 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_burst_id ON local_asset_entity (burst_id)')
|
||||
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
const LocalAssetEntity();
|
||||
|
||||
@@ -30,21 +28,6 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
|
||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
||||
|
||||
// remote id of the previous upload (iOS edit-pair stacking)
|
||||
TextColumn get priorRemoteId => text().nullable()();
|
||||
|
||||
// local checksum at the last sync action. Lets the backup query skip a local
|
||||
// whose current hash matches nothing remote but is still "handled": the iOS
|
||||
// revert case, where the reverted render hashes fresh but is already reconciled.
|
||||
TextColumn get syncedChecksum => text().nullable()();
|
||||
|
||||
// iOS burst grouping. burstId = PHAsset.burstIdentifier (null for non-burst).
|
||||
// isBurstRepresentative = the auto-picked lead frame at detection; the rep is
|
||||
// the timeline tile and the stack anchor. Both re-sync on every delta, so a
|
||||
// Photos re-pick that moves the rep flag is reflected.
|
||||
TextColumn get burstId => text().nullable()();
|
||||
BoolColumn get isBurstRepresentative => boolean().withDefault(const Constant(false))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -69,9 +52,5 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
longitude: longitude,
|
||||
cloudId: iCloudId,
|
||||
isEdited: false,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
burstId: burstId,
|
||||
isBurstRepresentative: isBurstRepresentative,
|
||||
);
|
||||
}
|
||||
|
||||
+3
-304
@@ -26,10 +26,6 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
i0.Value<String?> priorRemoteId,
|
||||
i0.Value<String?> syncedChecksum,
|
||||
i0.Value<String?> burstId,
|
||||
i0.Value<bool> isBurstRepresentative,
|
||||
});
|
||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.LocalAssetEntityCompanion Function({
|
||||
@@ -49,10 +45,6 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
i0.Value<String?> priorRemoteId,
|
||||
i0.Value<String?> syncedChecksum,
|
||||
i0.Value<String?> burstId,
|
||||
i0.Value<bool> isBurstRepresentative,
|
||||
});
|
||||
|
||||
class $$LocalAssetEntityTableFilterComposer
|
||||
@@ -149,26 +141,6 @@ class $$LocalAssetEntityTableFilterComposer
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get priorRemoteId => $composableBuilder(
|
||||
column: $table.priorRemoteId,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get syncedChecksum => $composableBuilder(
|
||||
column: $table.syncedChecksum,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get burstId => $composableBuilder(
|
||||
column: $table.burstId,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<bool> get isBurstRepresentative => $composableBuilder(
|
||||
column: $table.isBurstRepresentative,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableOrderingComposer
|
||||
@@ -259,26 +231,6 @@ class $$LocalAssetEntityTableOrderingComposer
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get priorRemoteId => $composableBuilder(
|
||||
column: $table.priorRemoteId,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get syncedChecksum => $composableBuilder(
|
||||
column: $table.syncedChecksum,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get burstId => $composableBuilder(
|
||||
column: $table.burstId,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<bool> get isBurstRepresentative => $composableBuilder(
|
||||
column: $table.isBurstRepresentative,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableAnnotationComposer
|
||||
@@ -348,24 +300,6 @@ class $$LocalAssetEntityTableAnnotationComposer
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<String> get priorRemoteId => $composableBuilder(
|
||||
column: $table.priorRemoteId,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<String> get syncedChecksum => $composableBuilder(
|
||||
column: $table.syncedChecksum,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<String> get burstId =>
|
||||
$composableBuilder(column: $table.burstId, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<bool> get isBurstRepresentative => $composableBuilder(
|
||||
column: $table.isBurstRepresentative,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableTableManager
|
||||
@@ -425,10 +359,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
i0.Value<String?> burstId = const i0.Value.absent(),
|
||||
i0.Value<bool> isBurstRepresentative = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -446,10 +376,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
burstId: burstId,
|
||||
isBurstRepresentative: isBurstRepresentative,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -470,10 +396,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
i0.Value<String?> burstId = const i0.Value.absent(),
|
||||
i0.Value<bool> isBurstRepresentative = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion.insert(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -491,10 +413,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
burstId: burstId,
|
||||
isBurstRepresentative: isBurstRepresentative,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -719,54 +637,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
).withConverter<i2.AssetPlaybackStyle>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle,
|
||||
);
|
||||
static const i0.VerificationMeta _priorRemoteIdMeta =
|
||||
const i0.VerificationMeta('priorRemoteId');
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> priorRemoteId =
|
||||
i0.GeneratedColumn<String>(
|
||||
'prior_remote_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const i0.VerificationMeta _syncedChecksumMeta =
|
||||
const i0.VerificationMeta('syncedChecksum');
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> syncedChecksum =
|
||||
i0.GeneratedColumn<String>(
|
||||
'synced_checksum',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const i0.VerificationMeta _burstIdMeta = const i0.VerificationMeta(
|
||||
'burstId',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> burstId = i0.GeneratedColumn<String>(
|
||||
'burst_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const i0.VerificationMeta _isBurstRepresentativeMeta =
|
||||
const i0.VerificationMeta('isBurstRepresentative');
|
||||
@override
|
||||
late final i0.GeneratedColumn<bool> isBurstRepresentative =
|
||||
i0.GeneratedColumn<bool>(
|
||||
'is_burst_representative',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("is_burst_representative" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const i4.Constant(false),
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
name,
|
||||
@@ -785,10 +655,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
priorRemoteId,
|
||||
syncedChecksum,
|
||||
burstId,
|
||||
isBurstRepresentative,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -893,39 +759,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('prior_remote_id')) {
|
||||
context.handle(
|
||||
_priorRemoteIdMeta,
|
||||
priorRemoteId.isAcceptableOrUnknown(
|
||||
data['prior_remote_id']!,
|
||||
_priorRemoteIdMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('synced_checksum')) {
|
||||
context.handle(
|
||||
_syncedChecksumMeta,
|
||||
syncedChecksum.isAcceptableOrUnknown(
|
||||
data['synced_checksum']!,
|
||||
_syncedChecksumMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('burst_id')) {
|
||||
context.handle(
|
||||
_burstIdMeta,
|
||||
burstId.isAcceptableOrUnknown(data['burst_id']!, _burstIdMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('is_burst_representative')) {
|
||||
context.handle(
|
||||
_isBurstRepresentativeMeta,
|
||||
isBurstRepresentative.isAcceptableOrUnknown(
|
||||
data['is_burst_representative']!,
|
||||
_isBurstRepresentativeMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1006,22 +839,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
data['${effectivePrefix}playback_style'],
|
||||
)!,
|
||||
),
|
||||
priorRemoteId: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}prior_remote_id'],
|
||||
),
|
||||
syncedChecksum: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}synced_checksum'],
|
||||
),
|
||||
burstId: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}burst_id'],
|
||||
),
|
||||
isBurstRepresentative: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_burst_representative'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1060,10 +877,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final i2.AssetPlaybackStyle playbackStyle;
|
||||
final String? priorRemoteId;
|
||||
final String? syncedChecksum;
|
||||
final String? burstId;
|
||||
final bool isBurstRepresentative;
|
||||
const LocalAssetEntityData({
|
||||
required this.name,
|
||||
required this.type,
|
||||
@@ -1081,10 +894,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.playbackStyle,
|
||||
this.priorRemoteId,
|
||||
this.syncedChecksum,
|
||||
this.burstId,
|
||||
required this.isBurstRepresentative,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -1129,16 +938,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
|
||||
);
|
||||
}
|
||||
if (!nullToAbsent || priorRemoteId != null) {
|
||||
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId);
|
||||
}
|
||||
if (!nullToAbsent || syncedChecksum != null) {
|
||||
map['synced_checksum'] = i0.Variable<String>(syncedChecksum);
|
||||
}
|
||||
if (!nullToAbsent || burstId != null) {
|
||||
map['burst_id'] = i0.Variable<String>(burstId);
|
||||
}
|
||||
map['is_burst_representative'] = i0.Variable<bool>(isBurstRepresentative);
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1168,12 +967,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
|
||||
serializer.fromJson<int>(json['playbackStyle']),
|
||||
),
|
||||
priorRemoteId: serializer.fromJson<String?>(json['priorRemoteId']),
|
||||
syncedChecksum: serializer.fromJson<String?>(json['syncedChecksum']),
|
||||
burstId: serializer.fromJson<String?>(json['burstId']),
|
||||
isBurstRepresentative: serializer.fromJson<bool>(
|
||||
json['isBurstRepresentative'],
|
||||
),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -1200,10 +993,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
'playbackStyle': serializer.toJson<int>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
|
||||
),
|
||||
'priorRemoteId': serializer.toJson<String?>(priorRemoteId),
|
||||
'syncedChecksum': serializer.toJson<String?>(syncedChecksum),
|
||||
'burstId': serializer.toJson<String?>(burstId),
|
||||
'isBurstRepresentative': serializer.toJson<bool>(isBurstRepresentative),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1224,10 +1013,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i2.AssetPlaybackStyle? playbackStyle,
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
i0.Value<String?> burstId = const i0.Value.absent(),
|
||||
bool? isBurstRepresentative,
|
||||
}) => i1.LocalAssetEntityData(
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
@@ -1247,14 +1032,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
latitude: latitude.present ? latitude.value : this.latitude,
|
||||
longitude: longitude.present ? longitude.value : this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
priorRemoteId: priorRemoteId.present
|
||||
? priorRemoteId.value
|
||||
: this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum.present
|
||||
? syncedChecksum.value
|
||||
: this.syncedChecksum,
|
||||
burstId: burstId.present ? burstId.value : this.burstId,
|
||||
isBurstRepresentative: isBurstRepresentative ?? this.isBurstRepresentative,
|
||||
);
|
||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||
return LocalAssetEntityData(
|
||||
@@ -1284,16 +1061,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
playbackStyle: data.playbackStyle.present
|
||||
? data.playbackStyle.value
|
||||
: this.playbackStyle,
|
||||
priorRemoteId: data.priorRemoteId.present
|
||||
? data.priorRemoteId.value
|
||||
: this.priorRemoteId,
|
||||
syncedChecksum: data.syncedChecksum.present
|
||||
? data.syncedChecksum.value
|
||||
: this.syncedChecksum,
|
||||
burstId: data.burstId.present ? data.burstId.value : this.burstId,
|
||||
isBurstRepresentative: data.isBurstRepresentative.present
|
||||
? data.isBurstRepresentative.value
|
||||
: this.isBurstRepresentative,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1315,11 +1082,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle, ')
|
||||
..write('priorRemoteId: $priorRemoteId, ')
|
||||
..write('syncedChecksum: $syncedChecksum, ')
|
||||
..write('burstId: $burstId, ')
|
||||
..write('isBurstRepresentative: $isBurstRepresentative')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1342,10 +1105,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
priorRemoteId,
|
||||
syncedChecksum,
|
||||
burstId,
|
||||
isBurstRepresentative,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1366,11 +1125,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
other.adjustmentTime == this.adjustmentTime &&
|
||||
other.latitude == this.latitude &&
|
||||
other.longitude == this.longitude &&
|
||||
other.playbackStyle == this.playbackStyle &&
|
||||
other.priorRemoteId == this.priorRemoteId &&
|
||||
other.syncedChecksum == this.syncedChecksum &&
|
||||
other.burstId == this.burstId &&
|
||||
other.isBurstRepresentative == this.isBurstRepresentative);
|
||||
other.playbackStyle == this.playbackStyle);
|
||||
}
|
||||
|
||||
class LocalAssetEntityCompanion
|
||||
@@ -1391,10 +1146,6 @@ class LocalAssetEntityCompanion
|
||||
final i0.Value<double?> latitude;
|
||||
final i0.Value<double?> longitude;
|
||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
||||
final i0.Value<String?> priorRemoteId;
|
||||
final i0.Value<String?> syncedChecksum;
|
||||
final i0.Value<String?> burstId;
|
||||
final i0.Value<bool> isBurstRepresentative;
|
||||
const LocalAssetEntityCompanion({
|
||||
this.name = const i0.Value.absent(),
|
||||
this.type = const i0.Value.absent(),
|
||||
@@ -1412,10 +1163,6 @@ class LocalAssetEntityCompanion
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
this.priorRemoteId = const i0.Value.absent(),
|
||||
this.syncedChecksum = const i0.Value.absent(),
|
||||
this.burstId = const i0.Value.absent(),
|
||||
this.isBurstRepresentative = const i0.Value.absent(),
|
||||
});
|
||||
LocalAssetEntityCompanion.insert({
|
||||
required String name,
|
||||
@@ -1434,10 +1181,6 @@ class LocalAssetEntityCompanion
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
this.priorRemoteId = const i0.Value.absent(),
|
||||
this.syncedChecksum = const i0.Value.absent(),
|
||||
this.burstId = const i0.Value.absent(),
|
||||
this.isBurstRepresentative = const i0.Value.absent(),
|
||||
}) : name = i0.Value(name),
|
||||
type = i0.Value(type),
|
||||
id = i0.Value(id);
|
||||
@@ -1458,10 +1201,6 @@ class LocalAssetEntityCompanion
|
||||
i0.Expression<double>? latitude,
|
||||
i0.Expression<double>? longitude,
|
||||
i0.Expression<int>? playbackStyle,
|
||||
i0.Expression<String>? priorRemoteId,
|
||||
i0.Expression<String>? syncedChecksum,
|
||||
i0.Expression<String>? burstId,
|
||||
i0.Expression<bool>? isBurstRepresentative,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (name != null) 'name': name,
|
||||
@@ -1480,11 +1219,6 @@ class LocalAssetEntityCompanion
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||
if (priorRemoteId != null) 'prior_remote_id': priorRemoteId,
|
||||
if (syncedChecksum != null) 'synced_checksum': syncedChecksum,
|
||||
if (burstId != null) 'burst_id': burstId,
|
||||
if (isBurstRepresentative != null)
|
||||
'is_burst_representative': isBurstRepresentative,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1505,10 +1239,6 @@ class LocalAssetEntityCompanion
|
||||
i0.Value<double?>? latitude,
|
||||
i0.Value<double?>? longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
||||
i0.Value<String?>? priorRemoteId,
|
||||
i0.Value<String?>? syncedChecksum,
|
||||
i0.Value<String?>? burstId,
|
||||
i0.Value<bool>? isBurstRepresentative,
|
||||
}) {
|
||||
return i1.LocalAssetEntityCompanion(
|
||||
name: name ?? this.name,
|
||||
@@ -1527,11 +1257,6 @@ class LocalAssetEntityCompanion
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
||||
burstId: burstId ?? this.burstId,
|
||||
isBurstRepresentative:
|
||||
isBurstRepresentative ?? this.isBurstRepresentative,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1592,20 +1317,6 @@ class LocalAssetEntityCompanion
|
||||
),
|
||||
);
|
||||
}
|
||||
if (priorRemoteId.present) {
|
||||
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId.value);
|
||||
}
|
||||
if (syncedChecksum.present) {
|
||||
map['synced_checksum'] = i0.Variable<String>(syncedChecksum.value);
|
||||
}
|
||||
if (burstId.present) {
|
||||
map['burst_id'] = i0.Variable<String>(burstId.value);
|
||||
}
|
||||
if (isBurstRepresentative.present) {
|
||||
map['is_burst_representative'] = i0.Variable<bool>(
|
||||
isBurstRepresentative.value,
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1627,11 +1338,7 @@ class LocalAssetEntityCompanion
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle, ')
|
||||
..write('priorRemoteId: $priorRemoteId, ')
|
||||
..write('syncedChecksum: $syncedChecksum, ')
|
||||
..write('burstId: $burstId, ')
|
||||
..write('isBurstRepresentative: $isBurstRepresentative')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1645,11 +1352,3 @@ i0.Index get idxLocalAssetCreatedAt => i0.Index(
|
||||
'idx_local_asset_created_at',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
|
||||
);
|
||||
i0.Index get idxLocalAssetPriorRemoteId => i0.Index(
|
||||
'idx_local_asset_prior_remote_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
|
||||
);
|
||||
i0.Index get idxLocalAssetBurstId => i0.Index(
|
||||
'idx_local_asset_burst_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_burst_id ON local_asset_entity (burst_id)',
|
||||
);
|
||||
|
||||
@@ -7,13 +7,7 @@ import 'local_album_asset.entity.dart';
|
||||
mergedAsset:
|
||||
SELECT
|
||||
rae.id as remote_id,
|
||||
-- local_id links a remote to its on-device copy, normally by checksum. A reverted iOS
|
||||
-- edit re-encodes to fresh bytes so the checksum no longer matches, but its
|
||||
-- prior_remote_id still points at this remote, so fall back to that.
|
||||
COALESCE(
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1),
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.prior_remote_id = rae.id LIMIT 1)
|
||||
) as local_id,
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id,
|
||||
rae.name,
|
||||
rae."type",
|
||||
rae.created_at as created_at,
|
||||
@@ -24,12 +18,6 @@ SELECT
|
||||
rae.is_favorite,
|
||||
rae.thumb_hash,
|
||||
rae.checksum,
|
||||
-- the linked local's current checksum (same row local_id picks), so local
|
||||
-- renders are cache-keyed by the bytes on device, not the server value.
|
||||
COALESCE(
|
||||
(SELECT lae.checksum FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1),
|
||||
(SELECT lae.checksum FROM local_asset_entity lae WHERE lae.prior_remote_id = rae.id LIMIT 1)
|
||||
) as local_checksum,
|
||||
rae.owner_id,
|
||||
rae.live_photo_video_id,
|
||||
0 as orientation,
|
||||
@@ -53,9 +41,6 @@ WHERE
|
||||
rae.stack_id IS NULL
|
||||
OR rae.id = se.primary_asset_id
|
||||
)
|
||||
-- iOS burst: hide non-representative members only in the pre-stack-sync window;
|
||||
-- once stack_id is set the rae.id = se.primary_asset_id rule above already hides them.
|
||||
AND (rae.stack_id IS NOT NULL OR NOT EXISTS (SELECT 1 FROM local_asset_entity lae WHERE lae.checksum = rae.checksum AND lae.burst_id IS NOT NULL AND lae.is_burst_representative = 0))
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -72,7 +57,6 @@ SELECT
|
||||
lae.is_favorite,
|
||||
NULL as thumb_hash,
|
||||
lae.checksum,
|
||||
lae.checksum as local_checksum,
|
||||
NULL as owner_id,
|
||||
NULL as live_photo_video_id,
|
||||
lae.orientation,
|
||||
@@ -99,23 +83,6 @@ AND NOT EXISTS (
|
||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
||||
)
|
||||
-- iOS edit-in-progress / revert: if this local was already uploaded (its
|
||||
-- prior_remote_id resolves to a remote row), hide the local tile so the remote
|
||||
-- (the edit, or the flipped-back original) is the single source of truth. Kills
|
||||
-- the transient 2-tile flicker and stops a reverted local from re-appearing.
|
||||
-- A trashed prior still hides it — trashing on the server shouldn't pop the
|
||||
-- photo back onto the local timeline; only a hard delete (row gone) does.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
|
||||
)
|
||||
-- iOS burst: show only the representative as a local tile; other members still back up + stack.
|
||||
-- A rep-less group (Keep Everything / re-pick) has no representative, so show every
|
||||
-- frame as an individual instead of hiding the whole group.
|
||||
AND (
|
||||
lae.burst_id IS NULL
|
||||
OR lae.is_burst_representative = 1
|
||||
OR NOT EXISTS (SELECT 1 FROM local_asset_entity r WHERE r.burst_id = lae.burst_id AND r.is_burst_representative = 1)
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $limit;
|
||||
|
||||
@@ -148,8 +115,6 @@ FROM
|
||||
rae.stack_id IS NULL
|
||||
OR rae.id = se.primary_asset_id
|
||||
)
|
||||
-- iOS burst: hide non-representative members only in the pre-stack-sync window (see mergedAsset)
|
||||
AND (rae.stack_id IS NOT NULL OR NOT EXISTS (SELECT 1 FROM local_asset_entity lae WHERE lae.checksum = rae.checksum AND lae.burst_id IS NOT NULL AND lae.is_burst_representative = 0))
|
||||
UNION ALL
|
||||
SELECT
|
||||
CASE
|
||||
@@ -171,17 +136,6 @@ FROM
|
||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
||||
)
|
||||
-- iOS edit-in-progress / revert: hide a local already represented by a remote
|
||||
-- row (trashed included, same as the tile query above).
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
|
||||
)
|
||||
-- iOS burst: count only the representative; a rep-less group counts every frame (see mergedAsset)
|
||||
AND (
|
||||
lae.burst_id IS NULL
|
||||
OR lae.is_burst_representative = 1
|
||||
OR NOT EXISTS (SELECT 1 FROM local_asset_entity r WHERE r.burst_id = lae.burst_id AND r.is_burst_representative = 1)
|
||||
)
|
||||
)
|
||||
GROUP BY bucket_date
|
||||
ORDER BY bucket_date DESC;
|
||||
|
||||
+2
-5
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
);
|
||||
$arrayStartIndex += generatedlimit.amountOfVariables;
|
||||
return customSelect(
|
||||
'SELECT rae.id AS remote_id, COALESCE((SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1), (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.prior_remote_id = rae.id LIMIT 1)) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, COALESCE((SELECT lae.checksum FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1), (SELECT lae.checksum FROM local_asset_entity AS lae WHERE lae.prior_remote_id = rae.id LIMIT 1)) AS local_checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)AND(rae.stack_id IS NOT NULL OR NOT EXISTS (SELECT 1 AS _c0 FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum AND lae.burst_id IS NOT NULL AND lae.is_burst_representative = 0))UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, lae.checksum AS local_checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds)) AND(lae.burst_id IS NULL OR lae.is_burst_representative = 1 OR NOT EXISTS (SELECT 1 FROM local_asset_entity AS r WHERE r.burst_id = lae.burst_id AND r.is_burst_representative = 1))ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
variables: [
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
...generatedlimit.introducedVariables,
|
||||
@@ -58,7 +58,6 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
isFavorite: row.read<bool>('is_favorite'),
|
||||
thumbHash: row.readNullable<String>('thumb_hash'),
|
||||
checksum: row.readNullable<String>('checksum'),
|
||||
localChecksum: row.readNullable<String>('local_checksum'),
|
||||
ownerId: row.readNullable<String>('owner_id'),
|
||||
livePhotoVideoId: row.readNullable<String>('live_photo_video_id'),
|
||||
orientation: row.read<int>('orientation'),
|
||||
@@ -82,7 +81,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
|
||||
$arrayStartIndex += userIds.length;
|
||||
return customSelect(
|
||||
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)AND(rae.stack_id IS NOT NULL OR NOT EXISTS (SELECT 1 AS _c0 FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum AND lae.burst_id IS NOT NULL AND lae.is_burst_representative = 0))UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds)) AND(lae.burst_id IS NULL OR lae.is_burst_representative = 1 OR NOT EXISTS (SELECT 1 FROM local_asset_entity AS r WHERE r.burst_id = lae.burst_id AND r.is_burst_representative = 1))) GROUP BY bucket_date ORDER BY bucket_date DESC',
|
||||
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
|
||||
variables: [
|
||||
i0.Variable<int>(groupBy),
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
@@ -133,7 +132,6 @@ class MergedAssetResult {
|
||||
final bool isFavorite;
|
||||
final String? thumbHash;
|
||||
final String? checksum;
|
||||
final String? localChecksum;
|
||||
final String? ownerId;
|
||||
final String? livePhotoVideoId;
|
||||
final int orientation;
|
||||
@@ -158,7 +156,6 @@ class MergedAssetResult {
|
||||
required this.isFavorite,
|
||||
this.thumbHash,
|
||||
this.checksum,
|
||||
this.localChecksum,
|
||||
this.ownerId,
|
||||
this.livePhotoVideoId,
|
||||
required this.orientation,
|
||||
|
||||
@@ -55,8 +55,6 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
|
||||
}
|
||||
|
||||
extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
// localId callers attach it via a checksum-equality join, so the local's
|
||||
// bytes are the remote's — key local renders by the same checksum.
|
||||
RemoteAsset toDto({String? localId}) => RemoteAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
@@ -74,7 +72,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
visibility: visibility,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
localId: localId,
|
||||
localChecksum: localId == null ? null : checksum,
|
||||
stackId: stackId,
|
||||
isEdited: isEdited,
|
||||
deletedAt: deletedAt,
|
||||
|
||||
@@ -34,53 +34,23 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
/// - total: number of distinct assets in selected albums, excluding those that are also in excluded albums
|
||||
/// - backup: number of those assets that already exist on the server for [userId]
|
||||
/// - remainder: number of those assets that do not yet exist on the server for [userId]
|
||||
/// (includes processing), excluding handled iOS reverts (syncedChecksum == checksum
|
||||
/// with the prior upload still on the server — trashed counts, like the
|
||||
/// checksum arm; only a hard delete re-opens the asset)
|
||||
/// (includes processing)
|
||||
/// - processing: number of those assets that are still preparing/have a null checksum
|
||||
Future<({int total, int remainder, int processing})> getAllCounts(String userId) async {
|
||||
const sql = '''
|
||||
SELECT
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE lae.checksum IS NULL) AS processing_count,
|
||||
COUNT(*) FILTER (
|
||||
WHERE rae.id IS NULL
|
||||
AND (
|
||||
lae.checksum IS NULL
|
||||
OR lae.synced_checksum IS NULL
|
||||
OR lae.synced_checksum != lae.checksum
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM main.remote_asset_entity pr
|
||||
WHERE pr.id = lae.prior_remote_id
|
||||
)
|
||||
)
|
||||
) AS remainder_count
|
||||
COUNT(*) FILTER (WHERE rae.id IS NULL) AS remainder_count
|
||||
FROM local_asset_entity lae
|
||||
LEFT JOIN main.remote_asset_entity rae
|
||||
ON lae.checksum = rae.checksum AND rae.owner_id = ?1
|
||||
WHERE (
|
||||
EXISTS (
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM local_album_asset_entity laa
|
||||
INNER JOIN main.local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id
|
||||
AND la.backup_selection = ?2
|
||||
)
|
||||
-- iOS burst: a hidden member inherits candidacy from its representative,
|
||||
-- which is the one actually in the user's selected album.
|
||||
OR (lae.is_burst_representative = 0 AND lae.burst_id IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM local_asset_entity rep
|
||||
INNER JOIN local_album_asset_entity laa ON laa.asset_id = rep.id
|
||||
INNER JOIN main.local_album_entity la ON la.id = laa.album_id
|
||||
WHERE rep.burst_id = lae.burst_id AND rep.is_burst_representative = 1
|
||||
AND la.backup_selection = ?2
|
||||
-- exclude-wins propagates to the burst: the rep must not be excluded
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM local_album_asset_entity laa2
|
||||
INNER JOIN main.local_album_entity la2 ON la2.id = laa2.album_id
|
||||
WHERE laa2.asset_id = rep.id AND la2.backup_selection = ?3
|
||||
)
|
||||
))
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
@@ -104,7 +74,6 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
.getSingle();
|
||||
|
||||
final data = row.data;
|
||||
|
||||
return (
|
||||
total: (data['total_count'] as int?) ?? 0,
|
||||
remainder: (data['remainder_count'] as int?) ?? 0,
|
||||
@@ -112,60 +81,22 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/// Backup candidates. With [burstId], scoped to the non-representative members
|
||||
/// of that burst — used to re-enqueue a burst's gated frames once its
|
||||
/// representative has uploaded, without re-walking (and re-enqueuing) assets
|
||||
/// already in flight from the main pass.
|
||||
Future<List<LocalAsset>> getCandidates(String userId, {bool onlyHashed = true, String? burstId}) async {
|
||||
Future<List<LocalAsset>> getCandidates(String userId, {bool onlyHashed = true}) async {
|
||||
final selectedAlbumIds = _db.localAlbumEntity.selectOnly(distinct: true)
|
||||
..addColumns([_db.localAlbumEntity.id])
|
||||
..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected));
|
||||
|
||||
// iOS burst: a hidden member isn't a member of the user album its rep sits in
|
||||
// (Photos only adds the cover), so it inherits backup candidacy from its rep.
|
||||
// Matched with a correlated EXISTS (var-safe, mirrors getAllCounts) instead of
|
||||
// materialising the burst-id list — a large library could blow the SQLite
|
||||
// variable limit.
|
||||
final rep = _db.localAssetEntity.createAlias('rep');
|
||||
|
||||
final query = _db.localAssetEntity.select()
|
||||
..where(
|
||||
(lae) =>
|
||||
(existsQuery(
|
||||
_db.localAlbumAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..where(
|
||||
_db.localAlbumAssetEntity.albumId.isInQuery(selectedAlbumIds) &
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(lae.id),
|
||||
),
|
||||
) |
|
||||
(lae.isBurstRepresentative.equals(false) &
|
||||
lae.burstId.isNotNull() &
|
||||
existsQuery(
|
||||
rep.selectOnly()
|
||||
..addColumns([rep.id])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.localAlbumAssetEntity,
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(rep.id),
|
||||
useColumns: false,
|
||||
),
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumEntity.id.equalsExp(_db.localAlbumAssetEntity.albumId),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
rep.burstId.equalsExp(lae.burstId) &
|
||||
rep.isBurstRepresentative.equals(true) &
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
// exclude-wins propagates to the burst: a member only
|
||||
// inherits candidacy if its rep is itself a candidate
|
||||
// (in a selected album AND not in an excluded one).
|
||||
rep.id.isNotInQuery(_getExcludedSubquery()),
|
||||
),
|
||||
))) &
|
||||
existsQuery(
|
||||
_db.localAlbumAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..where(
|
||||
_db.localAlbumAssetEntity.albumId.isInQuery(selectedAlbumIds) &
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(lae.id),
|
||||
),
|
||||
) &
|
||||
notExistsQuery(
|
||||
_db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([_db.remoteAssetEntity.checksum])
|
||||
@@ -173,20 +104,6 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
|
||||
),
|
||||
) &
|
||||
// iOS revert: a reverted local hashes fresh (matches nothing remote),
|
||||
// but if it was already reconciled (syncedChecksum == current checksum)
|
||||
// it's handled, so don't re-queue it as a fresh upload. Suppress while
|
||||
// the prior row exists at all — trashed stays suppressed (same
|
||||
// convention as the checksum arm above); only a hard-deleted remote
|
||||
// must become a candidate again.
|
||||
(lae.checksum.isNull() |
|
||||
lae.syncedChecksum.isNull() |
|
||||
lae.syncedChecksum.equalsExp(lae.checksum).not() |
|
||||
notExistsQuery(
|
||||
_db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([_db.remoteAssetEntity.id])
|
||||
..where(_db.remoteAssetEntity.id.equalsExp(lae.priorRemoteId)),
|
||||
)) &
|
||||
lae.id.isNotInQuery(_getExcludedSubquery()),
|
||||
)
|
||||
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
|
||||
@@ -195,10 +112,6 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
query.where((lae) => lae.checksum.isNotNull());
|
||||
}
|
||||
|
||||
if (burstId != null) {
|
||||
query.where((lae) => lae.burstId.equals(burstId) & lae.isBurstRepresentative.equals(false));
|
||||
}
|
||||
|
||||
return query.map((localAsset) => localAsset.toDto()).get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 31;
|
||||
int get schemaVersion => 30;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -311,14 +311,6 @@ class Drift extends $Drift {
|
||||
from29To30: (m, v30) async {
|
||||
await m.alterTable(TableMigration(v30.settings));
|
||||
},
|
||||
from30To31: (m, v31) async {
|
||||
await m.addColumn(v31.localAssetEntity, v31.localAssetEntity.priorRemoteId);
|
||||
await m.addColumn(v31.localAssetEntity, v31.localAssetEntity.syncedChecksum);
|
||||
await m.addColumn(v31.localAssetEntity, v31.localAssetEntity.burstId);
|
||||
await m.addColumn(v31.localAssetEntity, v31.localAssetEntity.isBurstRepresentative);
|
||||
await m.createIndex(v31.idxLocalAssetPriorRemoteId);
|
||||
await m.createIndex(v31.idxLocalAssetBurstId);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -118,8 +118,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i4.idxLocalAssetChecksum,
|
||||
i4.idxLocalAssetCloudId,
|
||||
i4.idxLocalAssetCreatedAt,
|
||||
i4.idxLocalAssetPriorRemoteId,
|
||||
i4.idxLocalAssetBurstId,
|
||||
i3.idxStackPrimaryAssetId,
|
||||
i2.uQRemoteAssetsOwnerChecksum,
|
||||
i2.uQRemoteAssetsOwnerLibraryChecksum,
|
||||
|
||||
@@ -15920,679 +15920,6 @@ i1.GeneratedColumn<String> _column_224(String aliasedName) =>
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
|
||||
final class Schema31 extends i0.VersionedSchema {
|
||||
Schema31({required super.database}) : super(version: 31);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxLocalAssetCreatedAt,
|
||||
idxLocalAssetPriorRemoteId,
|
||||
idxLocalAssetBurstId,
|
||||
idxStackPrimaryAssetId,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
settings,
|
||||
assetOcrEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteExifCity,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxAssetFaceVisiblePerson,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
idxAssetEditAssetId,
|
||||
idxAssetOcrAssetId,
|
||||
];
|
||||
late final Shape33 userEntity = Shape33(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_112,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape50 remoteAssetEntity = Shape50(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_119,
|
||||
_column_120,
|
||||
_column_121,
|
||||
_column_122,
|
||||
_column_123,
|
||||
_column_124,
|
||||
_column_212,
|
||||
_column_125,
|
||||
_column_126,
|
||||
_column_127,
|
||||
_column_128,
|
||||
_column_129,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape35 stackEntity = Shape35(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_130,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape52 localAssetEntity = Shape52(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_133,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_137,
|
||||
_column_225,
|
||||
_column_226,
|
||||
_column_227,
|
||||
_column_228,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape48 remoteAlbumEntity = Shape48(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_138,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_139,
|
||||
_column_140,
|
||||
_column_141,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape38 localAlbumEntity = Shape38(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_115,
|
||||
_column_142,
|
||||
_column_143,
|
||||
_column_144,
|
||||
_column_145,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape39 localAlbumAssetEntity = Shape39(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_146, _column_147, _column_145],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCreatedAt = i1.Index(
|
||||
'idx_local_asset_created_at',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
|
||||
);
|
||||
final i1.Index idxLocalAssetPriorRemoteId = i1.Index(
|
||||
'idx_local_asset_prior_remote_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetBurstId = i1.Index(
|
||||
'idx_local_asset_burst_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_burst_id ON local_asset_entity (burst_id)',
|
||||
);
|
||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||
'idx_stack_primary_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
|
||||
'idx_remote_asset_owner_visibility_deleted_created',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
|
||||
);
|
||||
late final Shape40 authUserEntity = Shape40(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_148,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_149,
|
||||
_column_150,
|
||||
_column_151,
|
||||
_column_152,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_153, _column_154, _column_155],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape41 partnerEntity = Shape41(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_156, _column_157, _column_158],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape42 remoteExifEntity = Shape42(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_160,
|
||||
_column_161,
|
||||
_column_162,
|
||||
_column_163,
|
||||
_column_164,
|
||||
_column_117,
|
||||
_column_116,
|
||||
_column_165,
|
||||
_column_166,
|
||||
_column_167,
|
||||
_column_168,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_169,
|
||||
_column_170,
|
||||
_column_171,
|
||||
_column_172,
|
||||
_column_173,
|
||||
_column_174,
|
||||
_column_175,
|
||||
_column_176,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_159, _column_177],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_177, _column_153, _column_178],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape43 remoteAssetCloudIdEntity = Shape43(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_179,
|
||||
_column_180,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape44 memoryEntity = Shape44(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_124,
|
||||
_column_121,
|
||||
_column_113,
|
||||
_column_181,
|
||||
_column_182,
|
||||
_column_183,
|
||||
_column_184,
|
||||
_column_185,
|
||||
_column_186,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_159, _column_187],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape45 personEntity = Shape45(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_108,
|
||||
_column_188,
|
||||
_column_189,
|
||||
_column_190,
|
||||
_column_191,
|
||||
_column_192,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape46 assetFaceEntity = Shape46(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_193,
|
||||
_column_194,
|
||||
_column_195,
|
||||
_column_196,
|
||||
_column_197,
|
||||
_column_198,
|
||||
_column_199,
|
||||
_column_200,
|
||||
_column_201,
|
||||
_column_124,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_202, _column_203, _column_204],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape47 trashedLocalAssetEntity = Shape47(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_205,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_206,
|
||||
_column_137,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape32 assetEditEntity = Shape32(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_edit_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_207,
|
||||
_column_208,
|
||||
_column_209,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape49 settings = Shape49(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'settings',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY("key")'],
|
||||
columns: [_column_210, _column_224, _column_115],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape51 assetOcrEntity = Shape51(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_ocr_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_213,
|
||||
_column_214,
|
||||
_column_215,
|
||||
_column_216,
|
||||
_column_217,
|
||||
_column_218,
|
||||
_column_219,
|
||||
_column_220,
|
||||
_column_221,
|
||||
_column_222,
|
||||
_column_223,
|
||||
_column_201,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxRemoteExifCity = i1.Index(
|
||||
'idx_remote_exif_city',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_remote_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxPersonOwnerId = i1.Index(
|
||||
'idx_person_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||
'idx_asset_face_person_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||
'idx_asset_face_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
|
||||
'idx_asset_face_visible_person',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
final i1.Index idxAssetEditAssetId = i1.Index(
|
||||
'idx_asset_edit_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxAssetOcrAssetId = i1.Index(
|
||||
'idx_asset_ocr_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape52 extends i0.VersionedTable {
|
||||
Shape52({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationMs =>
|
||||
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get iCloudId =>
|
||||
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get adjustmentTime =>
|
||||
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<double> get latitude =>
|
||||
columnsByName['latitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get longitude =>
|
||||
columnsByName['longitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<int> get playbackStyle =>
|
||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get priorRemoteId =>
|
||||
columnsByName['prior_remote_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get syncedChecksum =>
|
||||
columnsByName['synced_checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get burstId =>
|
||||
columnsByName['burst_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isBurstRepresentative =>
|
||||
columnsByName['is_burst_representative']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_225(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'prior_remote_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_226(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'synced_checksum',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_227(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'burst_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i1.GeneratedColumn<int> _column_228(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'is_burst_representative',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints:
|
||||
'NOT NULL DEFAULT 0 CHECK (is_burst_representative IN (0, 1))',
|
||||
defaultValue: const i1.CustomExpression('0'),
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -16623,7 +15950,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
||||
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -16772,11 +16098,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from29To30(migrator, schema);
|
||||
return 30;
|
||||
case 30:
|
||||
final schema = Schema31(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from30To31(migrator, schema);
|
||||
return 31;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -16813,7 +16134,6 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
||||
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -16845,6 +16165,5 @@ i1.OnUpgrade stepByStep({
|
||||
from27To28: from27To28,
|
||||
from28To29: from28To29,
|
||||
from29To30: from29To30,
|
||||
from30To31: from30To31,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -235,47 +235,15 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<LocalAsset>> getAssetsToHash(String albumId) async {
|
||||
// iOS burst: hidden members live in the smart album, not a user album, so a
|
||||
// burst added to a backup album would never hash its other frames. Let a
|
||||
// member inherit hashing from its representative via a correlated EXISTS
|
||||
// (var-safe — a large library could blow the SQLite variable limit otherwise).
|
||||
final rep = _db.localAssetEntity.createAlias('rep');
|
||||
Future<List<LocalAsset>> getAssetsToHash(String albumId) {
|
||||
final query =
|
||||
_db.localAlbumAssetEntity.select().join([
|
||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||
])
|
||||
..where(_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAssetEntity.checksum.isNull())
|
||||
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]);
|
||||
|
||||
final query = _db.localAssetEntity.select()
|
||||
..where(
|
||||
(lae) =>
|
||||
lae.checksum.isNull() &
|
||||
(existsQuery(
|
||||
_db.localAlbumAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..where(
|
||||
_db.localAlbumAssetEntity.albumId.equals(albumId) &
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(lae.id),
|
||||
),
|
||||
) |
|
||||
(lae.isBurstRepresentative.equals(false) &
|
||||
lae.burstId.isNotNull() &
|
||||
existsQuery(
|
||||
rep.selectOnly()
|
||||
..addColumns([rep.id])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.localAlbumAssetEntity,
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(rep.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
rep.burstId.equalsExp(lae.burstId) &
|
||||
rep.isBurstRepresentative.equals(true) &
|
||||
_db.localAlbumAssetEntity.albumId.equals(albumId),
|
||||
),
|
||||
))),
|
||||
)
|
||||
..orderBy([(lae) => OrderingTerm.desc(lae.createdAt)]);
|
||||
|
||||
return query.map((row) => row.toDto()).get();
|
||||
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
|
||||
}
|
||||
|
||||
Future<void> updateCloudMapping(Map<String, String> cloudMapping) {
|
||||
@@ -337,10 +305,6 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
latitude: Value(asset.latitude),
|
||||
longitude: Value(asset.longitude),
|
||||
adjustmentTime: Value(asset.adjustmentTime),
|
||||
// Re-synced on every delta (DoUpdate carries the same companion) so a
|
||||
// Photos re-pick that moves the representative flag is reflected.
|
||||
burstId: Value(asset.burstId),
|
||||
isBurstRepresentative: Value(asset.isBurstRepresentative),
|
||||
);
|
||||
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
||||
_db.localAssetEntity,
|
||||
|
||||
@@ -64,84 +64,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> markSynced(String localId, {required String priorRemoteId, required String? syncedChecksum}) {
|
||||
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
|
||||
LocalAssetEntityCompanion(priorRemoteId: Value(priorRemoteId), syncedChecksum: Value(syncedChecksum)),
|
||||
);
|
||||
}
|
||||
|
||||
/// The remote id to stack a burst member under: the prior_remote_id of any
|
||||
/// already-uploaded member of the same burst (null until the first one lands).
|
||||
/// The representative uploads first (non-reps gate on this), so the first hit
|
||||
/// is the rep and `keepPrimary` pins it as the stack primary. Deliberately NOT
|
||||
/// filtered to the representative: iOS can move the rep flag (a Photos re-pick),
|
||||
/// and any in-stack member resolves to the same stack via linkAsset — so
|
||||
/// returning whichever member uploaded keeps every later frame in one stack
|
||||
/// instead of spawning a second when the cover moves. Stable order so repeated
|
||||
/// lookups pick the same anchor.
|
||||
Future<String?> getBurstParentRemoteId(String burstId, {String? ownerId}) async {
|
||||
// Prefer the remote id stamped by a frame this device already uploaded.
|
||||
final row =
|
||||
await (_db.localAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAssetEntity.priorRemoteId])
|
||||
..where(_db.localAssetEntity.burstId.equals(burstId) & _db.localAssetEntity.priorRemoteId.isNotNull())
|
||||
..orderBy([OrderingTerm.asc(_db.localAssetEntity.createdAt), OrderingTerm.asc(_db.localAssetEntity.id)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final priorRemoteId = row?.read(_db.localAssetEntity.priorRemoteId);
|
||||
if (priorRemoteId != null) {
|
||||
return priorRemoteId;
|
||||
}
|
||||
if (ownerId == null) {
|
||||
return null;
|
||||
}
|
||||
// Pre-existing burst: the representative was backed up before this feature, so
|
||||
// no local frame ever stamped a prior. Anchor onto the rep's already-synced
|
||||
// remote row (matched by checksum) so the hidden members can still stack.
|
||||
final rep =
|
||||
await (_db.localAssetEntity.selectOnly()
|
||||
..addColumns([_db.remoteAssetEntity.id])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum) &
|
||||
_db.remoteAssetEntity.ownerId.equals(ownerId),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.localAssetEntity.burstId.equals(burstId) & _db.localAssetEntity.isBurstRepresentative.equals(true),
|
||||
)
|
||||
..orderBy([OrderingTerm.asc(_db.localAssetEntity.createdAt), OrderingTerm.asc(_db.localAssetEntity.id)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
return rep?.read(_db.remoteAssetEntity.id);
|
||||
}
|
||||
|
||||
/// Whether the burst group still has a representative frame. A group can end up
|
||||
/// rep-less (every frame is_burst_representative=0) after a Photos "Keep
|
||||
/// Everything" / re-pick — its members can never anchor a stack, so callers
|
||||
/// upload them standalone instead of gating forever.
|
||||
Future<bool> burstHasRepresentative(String burstId) async {
|
||||
final row =
|
||||
await (_db.localAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAssetEntity.id])
|
||||
..where(
|
||||
_db.localAssetEntity.burstId.equals(burstId) & _db.localAssetEntity.isBurstRepresentative.equals(true),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
return row != null;
|
||||
}
|
||||
|
||||
/// Drops the edit-stacking stamps so the next backup cycle re-resolves the
|
||||
/// asset from scratch (used when the server says the stamped prior is gone).
|
||||
Future<void> clearSyncStamps(String localId) {
|
||||
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
|
||||
const LocalAssetEntityCompanion(priorRemoteId: Value(null), syncedChecksum: Value(null)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> delete(List<String> ids) {
|
||||
if (ids.isEmpty) {
|
||||
return Future.value();
|
||||
|
||||
@@ -46,9 +46,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
|
||||
return query.map((row) {
|
||||
final asset = row.readTable(_db.remoteAssetEntity).toDto();
|
||||
final localId = row.read(_db.localAssetEntity.id);
|
||||
// checksum-equality join: the local's bytes are the remote's
|
||||
return asset.copyWith(localId: localId, localChecksum: localId == null ? null : asset.checksum);
|
||||
return asset.copyWith(localId: row.read(_db.localAssetEntity.id));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,156 +1,23 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/stack.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class StackReconcileTarget {
|
||||
final String stackId;
|
||||
final String newPrimaryId;
|
||||
final String localAssetId;
|
||||
final String localAssetChecksum;
|
||||
|
||||
const StackReconcileTarget({
|
||||
required this.stackId,
|
||||
required this.newPrimaryId,
|
||||
required this.localAssetId,
|
||||
required this.localAssetChecksum,
|
||||
});
|
||||
}
|
||||
|
||||
enum PriorState { live, trashed, missing }
|
||||
|
||||
class DriftStackRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const DriftStackRepository(this._db) : super(_db);
|
||||
|
||||
// Find stacks whose primary should flip back after a revert: a local that was
|
||||
// uploaded as an edit (prior in the stack) now hashes to a DIFFERENT member
|
||||
// that isn't the primary. Two discriminators keep this from fighting stacks
|
||||
// the user arranged by hand: the matched member must not be the local's own
|
||||
// prior (a true revert has prior = the edit, member = the base), and the
|
||||
// local must be unreconciled (synced_checksum != checksum — the flip below
|
||||
// writes synced = checksum, which is what makes this self-limiting). Driven
|
||||
// from stack_entity so the work scales with the number of stacks (few), and
|
||||
// runs every hash cycle so a flip that failed offline gets retried.
|
||||
Future<List<StackReconcileTarget>> findRevertReconcileTargets() async {
|
||||
final rows = await _db
|
||||
.customSelect(
|
||||
'''
|
||||
SELECT
|
||||
s.id AS stack_id,
|
||||
member.id AS new_primary,
|
||||
local.id AS local_id,
|
||||
local.checksum AS local_checksum
|
||||
FROM stack_entity s
|
||||
INNER JOIN remote_asset_entity member
|
||||
ON member.stack_id = s.id
|
||||
AND member.deleted_at IS NULL
|
||||
INNER JOIN local_asset_entity local
|
||||
ON local.checksum = member.checksum
|
||||
AND local.prior_remote_id IS NOT NULL
|
||||
AND local.prior_remote_id != member.id
|
||||
AND local.synced_checksum IS NOT local.checksum
|
||||
INNER JOIN remote_asset_entity prior
|
||||
ON prior.id = local.prior_remote_id
|
||||
AND prior.stack_id = s.id
|
||||
AND prior.deleted_at IS NULL
|
||||
WHERE s.primary_asset_id != member.id
|
||||
''',
|
||||
readsFrom: {_db.localAssetEntity, _db.remoteAssetEntity, _db.stackEntity},
|
||||
)
|
||||
.get();
|
||||
Future<List<Stack>> getAll(String userId) {
|
||||
final query = _db.stackEntity.select()..where((e) => e.ownerId.equals(userId));
|
||||
|
||||
return rows
|
||||
.map(
|
||||
(row) => StackReconcileTarget(
|
||||
stackId: row.read<String>('stack_id'),
|
||||
newPrimaryId: row.read<String>('new_primary'),
|
||||
localAssetId: row.read<String>('local_id'),
|
||||
localAssetChecksum: row.read<String>('local_checksum'),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// A trashed or locked-folder (visibility = 3) remote can't be stacked onto,
|
||||
// so it reads as trashed; anything else is live.
|
||||
PriorState _stateFromBlocked(bool blocked) => blocked ? PriorState.trashed : PriorState.live;
|
||||
|
||||
// What the synced remote table knows about a stamped prior. missing is
|
||||
// ambiguous: either just uploaded and not synced back yet, or hard-deleted on
|
||||
// the server — the caller tells them apart via syncedChecksum (null = a chain
|
||||
// is still mid-flight, so the row simply hasn't synced yet). A locked-folder
|
||||
// row counts as trashed: the server refuses to stack onto it (and with a
|
||||
// message the dead-parent belt doesn't match), so defer until it's unlocked.
|
||||
Future<PriorState> priorState(String remoteId) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
// 3 = locked
|
||||
'SELECT (deleted_at IS NOT NULL OR visibility = 3) AS blocked FROM remote_asset_entity WHERE id = ? LIMIT 1',
|
||||
variables: [Variable<String>(remoteId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
if (row == null) {
|
||||
return PriorState.missing;
|
||||
}
|
||||
return _stateFromBlocked(row.read<bool>('blocked'));
|
||||
}
|
||||
|
||||
// The synced remote owned by [ownerId] with these exact bytes, if any. The
|
||||
// server keys assets by (owner, checksum), so at most one row matches.
|
||||
// Locked rows count as trashed here too, same reasoning as [priorState].
|
||||
Future<({PriorState state, String? remoteId})> remoteByChecksum(String checksum, String ownerId) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
// 3 = locked
|
||||
'SELECT id, (deleted_at IS NOT NULL OR visibility = 3) AS blocked FROM remote_asset_entity WHERE checksum = ? AND owner_id = ? LIMIT 1',
|
||||
variables: [Variable<String>(checksum), Variable<String>(ownerId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
if (row == null) {
|
||||
return (state: PriorState.missing, remoteId: null);
|
||||
}
|
||||
return (state: _stateFromBlocked(row.read<bool>('blocked')), remoteId: row.read<String>('id'));
|
||||
}
|
||||
|
||||
// The stack a remote asset belongs to, if any. Used by the revert path to find
|
||||
// the stack from prior_remote_id when the reverted bytes can't be checksum-matched.
|
||||
Future<String?> findStackIdByRemoteId(String remoteId) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
'SELECT stack_id FROM remote_asset_entity WHERE id = ? AND stack_id IS NOT NULL AND deleted_at IS NULL',
|
||||
variables: [Variable<String>(remoteId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
return row?.read<String?>('stack_id');
|
||||
}
|
||||
|
||||
// The stack's original base member to flip back to on revert: the earliest-
|
||||
// uploaded member that isn't the (latest-edit) prior. The base is uploaded
|
||||
// before its edits, so oldest uploaded_at = the original.
|
||||
Future<String?> findStackBaseId(String stackId, {required String excludeId}) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
'''
|
||||
SELECT id FROM remote_asset_entity
|
||||
WHERE stack_id = ? AND id != ? AND deleted_at IS NULL
|
||||
ORDER BY uploaded_at IS NULL, uploaded_at ASC, id ASC
|
||||
LIMIT 1
|
||||
''',
|
||||
variables: [Variable<String>(stackId), Variable<String>(excludeId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
return row?.read<String?>('id');
|
||||
}
|
||||
|
||||
// Optimistic local primary flip so the timeline updates immediately; the
|
||||
// server's stack-update websocket rewrites it shortly after.
|
||||
Future<void> setPrimary(String stackId, String primaryAssetId) {
|
||||
return (_db.stackEntity.update()..where((e) => e.id.equals(stackId))).write(
|
||||
StackEntityCompanion(primaryAssetId: Value(primaryAssetId)),
|
||||
);
|
||||
return query.map((stack) {
|
||||
return stack.toDto();
|
||||
}).get();
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackEntityData {
|
||||
Stack toDto() {
|
||||
return Stack(id: id, createdAt: createdAt, updatedAt: updatedAt, ownerId: ownerId, primaryAssetId: primaryAssetId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:io';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class StorageRepository {
|
||||
@@ -151,34 +150,4 @@ class StorageRepository {
|
||||
log.warning("Error deleting temporary directory", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Base originals for the edit chain live under Library/Caches (immich_base),
|
||||
/// not tmp, so [clearCache] can't wipe a chain still in flight across
|
||||
/// launches. Sweeps only files older than a day: live chains and concurrent
|
||||
/// foreground pair uploads keep their temps; orphans from dead chains go.
|
||||
Future<void> clearEditBaseCache() async {
|
||||
if (!CurrentPlatform.isIOS) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final cache = await getApplicationCacheDirectory();
|
||||
final dir = Directory('${cache.path}/immich_base');
|
||||
if (!await dir.exists()) {
|
||||
return;
|
||||
}
|
||||
final cutoff = DateTime.now().subtract(const Duration(days: 1));
|
||||
await for (final entry in dir.list()) {
|
||||
try {
|
||||
final stat = await entry.stat();
|
||||
if (stat.modified.isBefore(cutoff)) {
|
||||
await entry.delete(recursive: true);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error sweeping ${entry.path}", error, stackTrace);
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error sweeping edit base cache", error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dar
|
||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
|
||||
@@ -72,13 +71,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
await _db.remoteAssetCloudIdEntity.deleteAll();
|
||||
await _db.assetEditEntity.deleteAll();
|
||||
await _db.assetOcrEntity.deleteAll();
|
||||
// The edit-stacking stamps point at remote rows wiped above; left in
|
||||
// place they'd make the next backup (possibly a different account or
|
||||
// server) stack onto ids that no longer exist. Only stamped rows need
|
||||
// clearing, so skip the full-table rewrite when none are set.
|
||||
await (_db.localAssetEntity.update()
|
||||
..where((e) => e.priorRemoteId.isNotNull() | e.syncedChecksum.isNotNull()))
|
||||
.write(const LocalAssetEntityCompanion(priorRemoteId: Value(null), syncedChecksum: Value(null)));
|
||||
});
|
||||
} finally {
|
||||
// re-enable FK even if the transaction throws, otherwise the connection
|
||||
@@ -203,27 +195,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// websocket events are a point-in-time snapshot, so on the fast path don't overwrite
|
||||
// link state the checkpoint sync owns (a motion video uploads visible then gets hidden).
|
||||
RemoteAssetEntityCompanion _conflictUpdate(RemoteAssetEntityCompanion companion, bool fromWebsocket) {
|
||||
if (!fromWebsocket) {
|
||||
return companion;
|
||||
}
|
||||
// deletedAt is checkpoint-owned too: a debounced upload-ready snapshot always
|
||||
// carries null and must not resurrect an asset trashed in the meantime.
|
||||
return companion.copyWith(
|
||||
visibility: const Value.absent(),
|
||||
livePhotoVideoId: const Value.absent(),
|
||||
stackId: const Value.absent(),
|
||||
deletedAt: const Value.absent(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateAssetsV1(
|
||||
Iterable<SyncAssetV1> data, {
|
||||
String debugLabel = 'user',
|
||||
bool fromWebsocket = false,
|
||||
}) async {
|
||||
Future<void> updateAssetsV1(Iterable<SyncAssetV1> data, {String debugLabel = 'user'}) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final asset in data) {
|
||||
@@ -252,7 +224,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
batch.insert(
|
||||
_db.remoteAssetEntity,
|
||||
companion.copyWith(id: Value(asset.id)),
|
||||
onConflict: DoUpdate((_) => _conflictUpdate(companion, fromWebsocket)),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -262,11 +234,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAssetsV2(
|
||||
Iterable<SyncAssetV2> data, {
|
||||
String debugLabel = 'user',
|
||||
bool fromWebsocket = false,
|
||||
}) async {
|
||||
Future<void> updateAssetsV2(Iterable<SyncAssetV2> data, {String debugLabel = 'user'}) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final asset in data) {
|
||||
@@ -295,7 +263,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
batch.insert(
|
||||
_db.remoteAssetEntity,
|
||||
companion.copyWith(id: Value(asset.id)),
|
||||
onConflict: DoUpdate((_) => _conflictUpdate(companion, fromWebsocket)),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,7 +88,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
livePhotoVideoId: row.livePhotoVideoId,
|
||||
stackId: row.stackId,
|
||||
isEdited: row.isEdited,
|
||||
localChecksum: row.localChecksum,
|
||||
)
|
||||
: LocalAsset(
|
||||
id: row.localId!,
|
||||
|
||||
@@ -19,11 +19,6 @@ import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final backupAlbumCountProvider = FutureProvider.autoDispose<int>((ref) async {
|
||||
await ref.read(backupAlbumProvider.notifier).getAll();
|
||||
return ref.read(backupAlbumProvider).length;
|
||||
});
|
||||
|
||||
@RoutePage()
|
||||
class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget {
|
||||
const DriftBackupAlbumSelectionPage({super.key});
|
||||
@@ -49,6 +44,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
_searchFocusNode = FocusNode();
|
||||
|
||||
_enableSyncUploadAlbum.value = ref.read(appConfigProvider).backup.syncAlbums;
|
||||
ref.read(backupAlbumProvider.notifier).getAll();
|
||||
|
||||
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
||||
}
|
||||
@@ -83,7 +79,6 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLoading = ref.watch(backupAlbumCountProvider).isLoading;
|
||||
final albums = ref.watch(backupAlbumProvider);
|
||||
final albumCount = albums.length;
|
||||
// Filter albums based on search query
|
||||
@@ -251,32 +246,15 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
],
|
||||
),
|
||||
),
|
||||
if (filteredAlbums.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: _searchQuery.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text('album_search_not_found'.t(context: context)),
|
||||
)
|
||||
: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text('no_albums_found'.t(context: context)),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.crossAxisExtent > 600) {
|
||||
return _AlbumSelectionGrid(filteredAlbums: filteredAlbums);
|
||||
} else {
|
||||
return _AlbumSelectionList(filteredAlbums: filteredAlbums);
|
||||
}
|
||||
},
|
||||
),
|
||||
SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.crossAxisExtent > 600) {
|
||||
return _AlbumSelectionGrid(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
|
||||
} else {
|
||||
return _AlbumSelectionList(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_handleLinkedAlbumFuture != null)
|
||||
@@ -313,11 +291,27 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
|
||||
class _AlbumSelectionList extends StatelessWidget {
|
||||
final List<LocalAlbum> filteredAlbums;
|
||||
final String searchQuery;
|
||||
|
||||
const _AlbumSelectionList({required this.filteredAlbums});
|
||||
const _AlbumSelectionList({required this.filteredAlbums, required this.searchQuery});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (filteredAlbums.isEmpty && searchQuery.isNotEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text('album_search_not_found'.t(context: context)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredAlbums.isEmpty) {
|
||||
return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||
sliver: SliverList(
|
||||
@@ -331,11 +325,27 @@ class _AlbumSelectionList extends StatelessWidget {
|
||||
|
||||
class _AlbumSelectionGrid extends StatelessWidget {
|
||||
final List<LocalAlbum> filteredAlbums;
|
||||
final String searchQuery;
|
||||
|
||||
const _AlbumSelectionGrid({required this.filteredAlbums});
|
||||
const _AlbumSelectionGrid({required this.filteredAlbums, required this.searchQuery});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (filteredAlbums.isEmpty && searchQuery.isNotEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text('album_search_not_found'.t(context: context)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredAlbums.isEmpty) {
|
||||
return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
sliver: SliverGrid.builder(
|
||||
|
||||
@@ -355,14 +355,14 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
onError: (exception) => {
|
||||
log.severe('Failed to update auth info with access token: $accessToken'),
|
||||
ref.read(authProvider.notifier).logout(),
|
||||
context.router.replaceAll([const LoginRoute()]),
|
||||
context.replaceRoute(const LoginRoute()),
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
log.severe('Missing crucial offline login info - Logging out completely');
|
||||
unawaited(ref.read(authProvider.notifier).logout());
|
||||
unawaited(context.router.replaceAll([const LoginRoute()]));
|
||||
unawaited(context.replaceRoute(const LoginRoute()));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+10
-196
@@ -88,8 +88,6 @@ int _deepHash(Object? value) {
|
||||
|
||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
enum EditState { notEdited, edited, unknown }
|
||||
|
||||
class PlatformAsset {
|
||||
PlatformAsset({
|
||||
required this.id,
|
||||
@@ -106,8 +104,6 @@ class PlatformAsset {
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.playbackStyle,
|
||||
this.burstId,
|
||||
required this.isBurstRepresentative,
|
||||
});
|
||||
|
||||
String id;
|
||||
@@ -138,10 +134,6 @@ class PlatformAsset {
|
||||
|
||||
PlatformAssetPlaybackStyle playbackStyle;
|
||||
|
||||
String? burstId;
|
||||
|
||||
bool isBurstRepresentative;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
id,
|
||||
@@ -158,8 +150,6 @@ class PlatformAsset {
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
burstId,
|
||||
isBurstRepresentative,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -184,8 +174,6 @@ class PlatformAsset {
|
||||
latitude: result[11] as double?,
|
||||
longitude: result[12] as double?,
|
||||
playbackStyle: result[13]! as PlatformAssetPlaybackStyle,
|
||||
burstId: result[14] as String?,
|
||||
isBurstRepresentative: result[15]! as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,9 +199,7 @@ class PlatformAsset {
|
||||
_deepEquals(adjustmentTime, other.adjustmentTime) &&
|
||||
_deepEquals(latitude, other.latitude) &&
|
||||
_deepEquals(longitude, other.longitude) &&
|
||||
_deepEquals(playbackStyle, other.playbackStyle) &&
|
||||
_deepEquals(burstId, other.burstId) &&
|
||||
_deepEquals(isBurstRepresentative, other.isBurstRepresentative);
|
||||
_deepEquals(playbackStyle, other.playbackStyle);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -409,80 +395,6 @@ class CloudIdResult {
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class BaseResource {
|
||||
BaseResource({required this.path, required this.sha1});
|
||||
|
||||
String path;
|
||||
|
||||
String sha1;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[path, sha1];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static BaseResource decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return BaseResource(path: result[0]! as String, sha1: result[1]! as String);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! BaseResource || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(path, other.path) && _deepEquals(sha1, other.sha1);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class BaseLivePhoto {
|
||||
BaseLivePhoto({required this.still, this.video});
|
||||
|
||||
BaseResource still;
|
||||
|
||||
BaseResource? video;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[still, video];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static BaseLivePhoto decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return BaseLivePhoto(still: result[0]! as BaseResource, video: result[1] as BaseResource?);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! BaseLivePhoto || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(still, other.still) && _deepEquals(video, other.video);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -493,29 +405,20 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
} else if (value is PlatformAssetPlaybackStyle) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is EditState) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is PlatformAsset) {
|
||||
buffer.putUint8(131);
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is PlatformAlbum) {
|
||||
buffer.putUint8(132);
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SyncDelta) {
|
||||
buffer.putUint8(133);
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is HashResult) {
|
||||
buffer.putUint8(134);
|
||||
buffer.putUint8(133);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is CloudIdResult) {
|
||||
buffer.putUint8(135);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is BaseResource) {
|
||||
buffer.putUint8(136);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is BaseLivePhoto) {
|
||||
buffer.putUint8(137);
|
||||
buffer.putUint8(134);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
@@ -529,22 +432,15 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
|
||||
case 130:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : EditState.values[value];
|
||||
case 131:
|
||||
return PlatformAsset.decode(readValue(buffer)!);
|
||||
case 132:
|
||||
case 131:
|
||||
return PlatformAlbum.decode(readValue(buffer)!);
|
||||
case 133:
|
||||
case 132:
|
||||
return SyncDelta.decode(readValue(buffer)!);
|
||||
case 134:
|
||||
case 133:
|
||||
return HashResult.decode(readValue(buffer)!);
|
||||
case 135:
|
||||
case 134:
|
||||
return CloudIdResult.decode(readValue(buffer)!);
|
||||
case 136:
|
||||
return BaseResource.decode(readValue(buffer)!);
|
||||
case 137:
|
||||
return BaseLivePhoto.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
@@ -809,86 +705,4 @@ class NativeSyncApi {
|
||||
);
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
|
||||
}
|
||||
|
||||
Future<BaseResource?> getBaseResource(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
);
|
||||
return pigeonVar_replyValue as BaseResource?;
|
||||
}
|
||||
|
||||
/// Streams the bytes immich treats as the asset's canonical content — the same
|
||||
/// resource [hashAssets] hashes (`PHAsset.getResource()`, the `.isCurrent`
|
||||
/// rendition). Used to upload iOS burst members: they're invisible to
|
||||
/// photo_manager, so this is the only way to read their file, and streaming
|
||||
/// the same resource the hash measured keeps the server checksum aligned with
|
||||
/// the local one (else the asset shows cloud-only). iOS-only; android returns null.
|
||||
Future<BaseResource?> getCurrentResource(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCurrentResource$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
);
|
||||
return pigeonVar_replyValue as BaseResource?;
|
||||
}
|
||||
|
||||
Future<EditState> getEditState(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as EditState;
|
||||
}
|
||||
|
||||
Future<BaseLivePhoto?> getBaseLivePhoto(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
);
|
||||
return pigeonVar_replyValue as BaseLivePhoto?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -35,17 +34,11 @@ class _DriftLockedFolderPageState extends ConsumerState<DriftLockedFolderPage> w
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showOverlay = state != AppLifecycleState.resumed;
|
||||
});
|
||||
}
|
||||
if (state == AppLifecycleState.paused) {
|
||||
ref.read(authProvider.notifier).lockPinCode();
|
||||
context.navigateTo(const TabShellRoute());
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_showOverlay = state != AppLifecycleState.resumed;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -159,13 +159,7 @@ ImageProvider getFullImageProvider(
|
||||
provider = FileImage(File(localFilePath));
|
||||
} else if (_shouldUseLocalAsset(asset)) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
provider = LocalFullImageProvider(
|
||||
id: id,
|
||||
size: size,
|
||||
assetType: asset.type,
|
||||
isAnimated: asset.isAnimatedImage,
|
||||
checksum: _localRenderChecksum(asset),
|
||||
);
|
||||
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
|
||||
} else {
|
||||
final String assetId;
|
||||
final String thumbhash;
|
||||
@@ -193,7 +187,7 @@ ImageProvider getFullImageProvider(
|
||||
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
|
||||
if (_shouldUseLocalAsset(asset)) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
return LocalThumbProvider(id: id, size: size, assetType: asset.type, checksum: _localRenderChecksum(asset));
|
||||
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
|
||||
}
|
||||
|
||||
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
|
||||
@@ -201,14 +195,7 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
|
||||
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited) : null;
|
||||
}
|
||||
|
||||
// Cache key for rendering the LOCAL bytes: a remote linked via priorRemoteId carries
|
||||
// the server checksum, which doesn't move when the on-device bytes change again.
|
||||
String? _localRenderChecksum(BaseAsset asset) => asset is RemoteAsset ? asset.localChecksum : asset.checksum;
|
||||
|
||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||
asset.hasLocal &&
|
||||
(!asset.hasRemote || !SettingsRepository.instance.appConfig.image.preferRemote) &&
|
||||
!asset.isEdited &&
|
||||
// A prior-linked local that hasn't rehashed yet has no trustworthy cache key
|
||||
// (its bytes may differ from the server checksum) — render the remote instead.
|
||||
(asset is! RemoteAsset || asset.localChecksum != null);
|
||||
!asset.isEdited;
|
||||
|
||||
@@ -14,11 +14,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||
final Size size;
|
||||
final AssetType assetType;
|
||||
|
||||
// an on-device edit/revert keeps the same id but changes the bytes, so the checksum
|
||||
// is what keys a cached thumbnail to its render.
|
||||
final String? checksum;
|
||||
|
||||
LocalThumbProvider({required this.id, required this.assetType, this.checksum, this.size = kThumbnailResolution});
|
||||
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
|
||||
|
||||
@override
|
||||
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -48,13 +44,13 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||
return true;
|
||||
}
|
||||
if (other is LocalThumbProvider) {
|
||||
return id == other.id && checksum == other.checksum;
|
||||
return id == other.id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ checksum.hashCode;
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
|
||||
@@ -63,15 +59,8 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
final Size size;
|
||||
final AssetType assetType;
|
||||
final bool isAnimated;
|
||||
final String? checksum;
|
||||
|
||||
LocalFullImageProvider({
|
||||
required this.id,
|
||||
required this.assetType,
|
||||
required this.size,
|
||||
required this.isAnimated,
|
||||
this.checksum,
|
||||
});
|
||||
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
|
||||
|
||||
@override
|
||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -84,7 +73,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
return AnimatedImageStreamCompleter(
|
||||
stream: _animatedCodec(key, decode),
|
||||
scale: 1.0,
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
@@ -97,7 +86,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
@@ -174,11 +163,11 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
return true;
|
||||
}
|
||||
if (other is LocalFullImageProvider) {
|
||||
return id == other.id && size == other.size && isAnimated == other.isAnimated && checksum == other.checksum;
|
||||
return id == other.id && size == other.size && isAnimated == other.isAnimated;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode ^ checksum.hashCode;
|
||||
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/permission.provider.dart';
|
||||
@@ -116,7 +115,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
_safeRun(backgroundManager.syncLocal(full: CurrentPlatform.isAndroid ? true : false), "syncLocal"),
|
||||
_safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"),
|
||||
]);
|
||||
_ref.invalidate(driftMemoryFutureProvider);
|
||||
if (syncSuccess) {
|
||||
await Future.wait([
|
||||
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
|
||||
@@ -12,12 +13,6 @@ import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
|
||||
/// Max foreground-backup passes per run. iOS burst stacking needs a pass for the
|
||||
/// representative, then another for its now-eligible members; a few more cover
|
||||
/// multi-burst captures. Bounded so a stuck candidate can't loop forever — the
|
||||
/// no-progress break usually stops sooner.
|
||||
const _kMaxBackupPasses = 6;
|
||||
|
||||
class EnqueueStatus {
|
||||
final int enqueueCount;
|
||||
final int totalCount;
|
||||
@@ -214,9 +209,6 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
final BackgroundUploadService _backgroundUploadService;
|
||||
final UploadSpeedManager _uploadSpeedManager;
|
||||
Completer<void>? _cancelToken;
|
||||
// Ids uploaded in the current foreground-backup session, so the multi-pass
|
||||
// loop doesn't re-grab an asset whose remote row hasn't synced back locally yet.
|
||||
final Set<String> _sessionUploadedIds = {};
|
||||
|
||||
final _logger = Logger("DriftBackupNotifier");
|
||||
|
||||
@@ -264,7 +256,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
state = state.copyWith(isSyncing: isSyncing);
|
||||
}
|
||||
|
||||
Future<void> startForegroundBackup(String userId) async {
|
||||
Future<void> startForegroundBackup(String userId) {
|
||||
// Cancel any existing backup before starting a new one
|
||||
if (_cancelToken != null) {
|
||||
stopForegroundBackup();
|
||||
@@ -274,44 +266,16 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
|
||||
_cancelToken = Completer<void>();
|
||||
|
||||
// Ids uploaded this session: a just-uploaded asset stays a backup candidate
|
||||
// until its remote row syncs back locally, so the next pass would re-grab it
|
||||
// (the server dedups, but it wastes the transfer). Skipping known-uploaded
|
||||
// ids makes the multi-pass loop idempotent. Populated in the success handler.
|
||||
_sessionUploadedIds.clear();
|
||||
final callbacks = UploadCallbacks(
|
||||
onProgress: _handleForegroundBackupProgress,
|
||||
onSuccess: _handleForegroundBackupSuccess,
|
||||
onError: _handleForegroundBackupError,
|
||||
onICloudProgress: _handleICloudProgress,
|
||||
return _foregroundUploadService.uploadCandidates(
|
||||
userId,
|
||||
_cancelToken!,
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: _handleForegroundBackupProgress,
|
||||
onSuccess: _handleForegroundBackupSuccess,
|
||||
onError: _handleForegroundBackupError,
|
||||
onICloudProgress: _handleICloudProgress,
|
||||
),
|
||||
);
|
||||
|
||||
// iOS burst stacking needs more than one pass: a burst's representative
|
||||
// uploads first, then its members become eligible (they stack under it). So
|
||||
// loop only while a pass still has burst frames to work through - a non-burst
|
||||
// library (and all of Android) finishes in a single pass with no extra
|
||||
// candidate query. Capped so a stuck candidate can't spin forever. `myToken`
|
||||
// is captured once: if another backup supersedes this one (restart installs
|
||||
// a fresh token), `identical` fails and this loop exits instead of running
|
||||
// concurrently against the shared session state. `skipIds` keeps a just-
|
||||
// uploaded asset (remote row not synced back yet) from being re-grabbed.
|
||||
final myToken = _cancelToken;
|
||||
for (var pass = 0; pass < _kMaxBackupPasses; pass++) {
|
||||
if (!mounted || myToken == null || myToken.isCompleted || !identical(_cancelToken, myToken)) {
|
||||
break;
|
||||
}
|
||||
final result = await _foregroundUploadService.uploadCandidates(
|
||||
userId,
|
||||
myToken,
|
||||
callbacks: callbacks,
|
||||
skipIds: _sessionUploadedIds,
|
||||
);
|
||||
// Nothing attempted (all remaining candidates already uploaded this
|
||||
// session), or nothing burst-related to unblock a later pass → done.
|
||||
if (result.attempted == 0 || !result.hadBurst) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void stopForegroundBackup() {
|
||||
@@ -370,12 +334,6 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
}
|
||||
|
||||
void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) {
|
||||
_sessionUploadedIds.add(localAssetId);
|
||||
if (!mounted) {
|
||||
// Upload finished after the notifier was disposed (e.g. navigated away
|
||||
// mid-backup). Keep the session-id bookkeeping above but don't touch state.
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
|
||||
_uploadSpeedManager.removeTask(localAssetId);
|
||||
|
||||
@@ -422,19 +380,19 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
}
|
||||
_logger.info("Start background backup sequence");
|
||||
state = state.copyWith(error: BackupError.none);
|
||||
final pending = await _backgroundUploadService.getActiveBackupTaskCount();
|
||||
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
|
||||
return;
|
||||
}
|
||||
_logger.info("Found $pending pending tasks");
|
||||
_logger.info("Found ${tasks.length} pending tasks");
|
||||
|
||||
if (pending == 0) {
|
||||
if (tasks.isEmpty) {
|
||||
_logger.info("No pending tasks, starting new upload");
|
||||
return _backgroundUploadService.uploadBackupCandidates(userId);
|
||||
}
|
||||
|
||||
_logger.info("Resuming upload $pending assets");
|
||||
_logger.info("Resuming upload ${tasks.length} assets");
|
||||
return _backgroundUploadService.resume();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
import 'package:immich_mobile/domain/services/memory.service.dart';
|
||||
@@ -21,11 +19,6 @@ final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>(
|
||||
return const [];
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final nextMidnight = DateTime(now.year, now.month, now.day + 1);
|
||||
final timer = Timer(nextMidnight.difference(now) + const Duration(seconds: 5), ref.invalidateSelf);
|
||||
ref.onDispose(timer.cancel);
|
||||
|
||||
final service = ref.watch(driftMemoryServiceProvider);
|
||||
return service.getMemoryLane(userId);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
@@ -12,8 +11,6 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
|
||||
@@ -49,15 +46,6 @@ final localSyncServiceProvider = Provider(
|
||||
),
|
||||
);
|
||||
|
||||
final editRevertServiceProvider = Provider(
|
||||
(ref) => EditRevertService(
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
stackRepository: ref.watch(driftStackProvider),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
assetApiRepository: ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
final hashServiceProvider = Provider(
|
||||
(ref) => HashService(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
@@ -65,7 +53,5 @@ final hashServiceProvider = Provider(
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
cancellation: ref.watch(cancellationProvider),
|
||||
stackRepository: ref.watch(driftStackProvider),
|
||||
assetApiRepository: ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -53,18 +53,9 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
);
|
||||
final List<dynamic> _batchedAssetUploadReady = [];
|
||||
|
||||
// Batches a burst of stack updates (one per uploaded edit) into a single
|
||||
// remote sync. Kept separate from _batchDebouncer so the two don't overwrite
|
||||
// each other's pending action.
|
||||
final Debouncer _stackUpdateDebouncer = Debouncer(
|
||||
interval: const Duration(seconds: 2),
|
||||
maxWaitTime: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_batchDebouncer.dispose();
|
||||
_stackUpdateDebouncer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -114,7 +105,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
|
||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||
socket.on('on_new_release', _handleReleaseUpdates);
|
||||
socket.on('on_asset_stack_update', _handleAssetStackUpdate);
|
||||
} catch (e) {
|
||||
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
||||
}
|
||||
@@ -198,14 +188,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
|
||||
}
|
||||
|
||||
// Server stacked/restacked assets (e.g. an edit stacked onto its original).
|
||||
// Pull a fresh remote sync so the stack_entity lands and the timeline shows
|
||||
// the stacked primary instead of briefly hiding the asset. Debounced so a
|
||||
// backup of many edits doesn't trigger a sync per event.
|
||||
void _handleAssetStackUpdate(dynamic _) {
|
||||
_stackUpdateDebouncer.run(() => _ref.read(backgroundSyncProvider).runFreshRemoteSync());
|
||||
}
|
||||
|
||||
void _processBatchedAssetUploadReadyV1() {
|
||||
if (_batchedAssetUploadReady.isEmpty) {
|
||||
return;
|
||||
|
||||
@@ -73,10 +73,6 @@ class AssetApiRepository extends ApiRepository {
|
||||
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
|
||||
}
|
||||
|
||||
Future<void> setStackPrimary(String stackId, String primaryAssetId) async {
|
||||
await _stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: Optional.present(primaryAssetId)));
|
||||
}
|
||||
|
||||
Future<Response> downloadAsset(String id, {required bool edited}) {
|
||||
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
||||
}
|
||||
|
||||
@@ -30,11 +30,6 @@ class UploadRepository {
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kBackupEditPairGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kManualUploadGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
@@ -67,11 +62,6 @@ class UploadRepository {
|
||||
return FileDownloader().allTasks(group: group);
|
||||
}
|
||||
|
||||
/// The ENQUEUED or RUNNING task with this id, if any.
|
||||
Future<Task?> getTaskById(String taskId) {
|
||||
return FileDownloader().taskForId(taskId);
|
||||
}
|
||||
|
||||
Future<void> start() {
|
||||
return FileDownloader().start();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
|
||||
/// The iOS mobile-app metadata multipart field, shared by the foreground and
|
||||
/// background upload paths so the payload only has one definition. null when
|
||||
/// there's nothing to attach. Pass [adjustmentTime] only for an edited render;
|
||||
/// the unedited base carries none.
|
||||
String? cloudMetadataJson({
|
||||
required String? cloudId,
|
||||
required DateTime createdAt,
|
||||
String? adjustmentTime,
|
||||
String? latitude,
|
||||
String? longitude,
|
||||
}) {
|
||||
if (!CurrentPlatform.isIOS || cloudId == null) {
|
||||
return null;
|
||||
}
|
||||
return jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: cloudId,
|
||||
createdAt: createdAt.toIso8601String(),
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// What to do with an edited iOS photo when backing it up.
|
||||
sealed class EditPairPlan {
|
||||
const EditPairPlan();
|
||||
}
|
||||
|
||||
/// Not something we stack: positively not edited, identical bytes, or the
|
||||
/// original resource simply isn't there to recover.
|
||||
class NoEditPair extends EditPairPlan {
|
||||
const NoEditPair();
|
||||
}
|
||||
|
||||
/// Can't be decided right now: the prior upload sits in the server trash, or the
|
||||
/// adjustment metadata / original couldn't be read (offloaded to iCloud, network
|
||||
/// off, stalled read). Skip the asset this cycle — it stays a candidate and
|
||||
/// resolves once conditions change. Uploading anyway would mark the edit synced
|
||||
/// and permanently drop the original from backup.
|
||||
class DeferEditPair extends EditPairPlan {
|
||||
const DeferEditPair();
|
||||
}
|
||||
|
||||
/// Already uploaded before; stack the edit onto that remote id.
|
||||
class AbsorbIntoPrior extends EditPairPlan {
|
||||
final String parentId;
|
||||
const AbsorbIntoPrior(this.parentId);
|
||||
}
|
||||
|
||||
/// Upload the original first; [base] is its temp file.
|
||||
class UploadBaseFirst extends EditPairPlan {
|
||||
final BaseResource base;
|
||||
const UploadBaseFirst(this.base);
|
||||
}
|
||||
|
||||
/// Live photo edit: upload the original pair first (the [still] always, the [video]
|
||||
/// when one survives) and stack the edited live photo onto the original still.
|
||||
/// [video] is null when the original has no paired video to recover (e.g. the edit
|
||||
/// turned Live off), which degrades to a still-only parent.
|
||||
class UploadBaseLivePhotoFirst extends EditPairPlan {
|
||||
final BaseResource still;
|
||||
final BaseResource? video;
|
||||
const UploadBaseLivePhotoFirst(this.still, this.video);
|
||||
}
|
||||
|
||||
/// Works out how an edited photo should stack: reuse a prior upload, upload the
|
||||
/// original first, do nothing, or defer to a later cycle. Shared by the foreground
|
||||
/// and background upload paths. The caller already checked it's iOS; pass
|
||||
/// [isLivePhoto] for a live photo so the original pair (still + paired video) is
|
||||
/// read instead of a single still.
|
||||
///
|
||||
/// A photo that was never edited only carries the capture-time Photographic Style,
|
||||
/// which iOS stamps at [LocalAsset.createdAt]; a real edit moves [LocalAsset.adjustmentTime]
|
||||
/// later. When they match (or there's no adjustment at all) there's nothing to stack, so
|
||||
/// we skip the native read. Anything that moved the timestamp (edit, retime, revert) falls
|
||||
/// through to [NativeSyncApi.getBaseResource] / [NativeSyncApi.getBaseLivePhoto], which read
|
||||
/// the adjustment plist and decide.
|
||||
Future<EditPairPlan> resolveEditPair(
|
||||
NativeSyncApi nativeSyncApi,
|
||||
LocalAsset asset, {
|
||||
required DriftStackRepository stackRepository,
|
||||
required String? ownerId,
|
||||
Logger? log,
|
||||
bool isLivePhoto = false,
|
||||
}) async {
|
||||
final priorRemoteId = asset.priorRemoteId;
|
||||
if (priorRemoteId != null) {
|
||||
PriorState priorState;
|
||||
try {
|
||||
priorState = await stackRepository.priorState(priorRemoteId);
|
||||
} catch (error, stack) {
|
||||
log?.warning(() => "Failed to check prior remote $priorRemoteId for ${asset.id}", error, stack);
|
||||
return const DeferEditPair();
|
||||
}
|
||||
switch (priorState) {
|
||||
case PriorState.live:
|
||||
return AbsorbIntoPrior(priorRemoteId);
|
||||
case PriorState.trashed:
|
||||
// The prior sits in the server trash. Re-uploading the base would just
|
||||
// dedupe onto the trashed row and the edit would 400 stacking onto it
|
||||
// ("Cannot stack onto a trashed or missing asset"), so wait: restore
|
||||
// makes it live (absorb), emptying trash makes it missing (rebuild).
|
||||
return const DeferEditPair();
|
||||
case PriorState.missing:
|
||||
// No synced row for the stamp. With syncedChecksum unset a chain is
|
||||
// mid-flight and the row just hasn't synced back yet — resume onto it.
|
||||
// With syncedChecksum set the completed prior has since vanished from
|
||||
// the server (hard delete), so fall through and re-resolve from scratch.
|
||||
if (asset.syncedChecksum == null) {
|
||||
return AbsorbIntoPrior(priorRemoteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!_mightBeEdited(asset)) {
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
if (isLivePhoto) {
|
||||
return _resolveLivePair(nativeSyncApi, asset, stackRepository: stackRepository, ownerId: ownerId, log: log);
|
||||
}
|
||||
|
||||
BaseResource? base;
|
||||
try {
|
||||
// Native bounds each resource read (classify + still) at 120s idle; the outer
|
||||
// timeout only catches a reply that never comes back across the platform channel.
|
||||
base = await nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true).timeout(_baseReadTimeout);
|
||||
} catch (error, stack) {
|
||||
// Transient (timeout, unreadable plist, iCloud hiccup): defer instead of
|
||||
// uploading the edit standalone, which would permanently skip the original.
|
||||
log?.warning(() => "Failed to read base resource for ${asset.id}, deferring", error, stack);
|
||||
return const DeferEditPair();
|
||||
}
|
||||
if (base == null) {
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
// Identical bytes (e.g. auto-HDR), nothing real to stack. Drop the temp copy.
|
||||
if (base.sha1 == asset.checksum) {
|
||||
await _deleteTemp(base.path);
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
switch (await _planForExistingBase(stackRepository, base.sha1, ownerId, log: log)) {
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
await _deleteTemp(base.path);
|
||||
return AbsorbIntoPrior(parentId);
|
||||
case DeferEditPair():
|
||||
await _deleteTemp(base.path);
|
||||
return const DeferEditPair();
|
||||
default:
|
||||
return UploadBaseFirst(base);
|
||||
}
|
||||
}
|
||||
|
||||
/// The base bytes may already be on the server: backed up before the stamps
|
||||
/// existed, by another install, or after the stamps were belt-cleared. Absorb
|
||||
/// straight onto a live copy instead of re-uploading bytes the server has;
|
||||
/// defer while that copy sits in the trash — uploading would just dedupe onto
|
||||
/// the trashed row and the stack would 400. null = no copy, upload the base.
|
||||
Future<EditPairPlan?> _planForExistingBase(
|
||||
DriftStackRepository stackRepository,
|
||||
String baseSha1,
|
||||
String? ownerId, {
|
||||
Logger? log,
|
||||
}) async {
|
||||
if (ownerId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final dup = await stackRepository.remoteByChecksum(baseSha1, ownerId);
|
||||
return switch (dup.state) {
|
||||
PriorState.live => AbsorbIntoPrior(dup.remoteId!),
|
||||
PriorState.trashed => const DeferEditPair(),
|
||||
PriorState.missing => null,
|
||||
};
|
||||
} catch (error, stack) {
|
||||
log?.warning(() => "Failed to check base checksum against synced remotes", error, stack);
|
||||
return const DeferEditPair();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the original pair of an edited live photo. Skips stacking when the original
|
||||
/// still matches the current bytes (e.g. a video-only trim) — the base still would
|
||||
/// dedupe to the edit itself on the server, so it can't be its own stack parent; the
|
||||
/// edit just uploads normally. Temps are dropped on every non-stack outcome.
|
||||
Future<EditPairPlan> _resolveLivePair(
|
||||
NativeSyncApi nativeSyncApi,
|
||||
LocalAsset asset, {
|
||||
required DriftStackRepository stackRepository,
|
||||
required String? ownerId,
|
||||
Logger? log,
|
||||
}) async {
|
||||
BaseLivePhoto? live;
|
||||
try {
|
||||
// Up to three native reads here (classify + still + paired video), 120s idle each.
|
||||
live = await nativeSyncApi.getBaseLivePhoto(asset.id, allowNetworkAccess: true).timeout(_baseLiveReadTimeout);
|
||||
} catch (error, stack) {
|
||||
log?.warning(() => "Failed to read base live photo for ${asset.id}, deferring", error, stack);
|
||||
return const DeferEditPair();
|
||||
}
|
||||
if (live == null) {
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
if (live.still.sha1 == asset.checksum) {
|
||||
await _deleteTemp(live.still.path);
|
||||
await _deleteTemp(live.video?.path);
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
switch (await _planForExistingBase(stackRepository, live.still.sha1, ownerId, log: log)) {
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
await _deleteTemp(live.still.path);
|
||||
await _deleteTemp(live.video?.path);
|
||||
return AbsorbIntoPrior(parentId);
|
||||
case DeferEditPair():
|
||||
await _deleteTemp(live.still.path);
|
||||
await _deleteTemp(live.video?.path);
|
||||
return const DeferEditPair();
|
||||
default:
|
||||
return UploadBaseLivePhotoFirst(live.still, live.video);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteTemp(String? path) async {
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await File(path).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// iOS stamps the capture-time Photographic Style at the creation time and moves the
|
||||
/// adjustment timestamp on any later change. A gap past a small tolerance (capture jitter
|
||||
/// is sub-second, real edits are seconds apart) is worth a native check; no adjustment at
|
||||
/// all means the photo was never touched.
|
||||
bool _mightBeEdited(LocalAsset asset) {
|
||||
final adjustedAt = asset.adjustmentTime;
|
||||
if (adjustedAt == null) {
|
||||
return false;
|
||||
}
|
||||
return adjustedAt.difference(asset.createdAt).inSeconds.abs() > _editTimestampToleranceSeconds;
|
||||
}
|
||||
|
||||
const _editTimestampToleranceSeconds = 2;
|
||||
// Generous on purpose: the native idle watchdog (120s without a chunk) owns
|
||||
// stall detection, so these only catch a reply lost on the platform channel —
|
||||
// a tight bound here would kill big-but-flowing iCloud downloads.
|
||||
const _baseReadTimeout = Duration(minutes: 30);
|
||||
const _baseLiveReadTimeout = Duration(minutes: 45);
|
||||
@@ -1,31 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/cloud_metadata.dart';
|
||||
import 'package:immich_mobile/services/edit_pair.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||
@@ -47,10 +39,6 @@ final foregroundUploadServiceProvider = Provider((ref) {
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(connectivityApiProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(nativeSyncApiProvider),
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(editRevertServiceProvider),
|
||||
ref.watch(driftStackProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,10 +54,6 @@ class ForegroundUploadService {
|
||||
this._backupRepository,
|
||||
this._connectivityApi,
|
||||
this._assetMediaRepository,
|
||||
this._nativeSyncApi,
|
||||
this._localAssetRepository,
|
||||
this._editRevertService,
|
||||
this._stackRepository,
|
||||
);
|
||||
|
||||
final UploadRepository _uploadRepository;
|
||||
@@ -77,10 +61,6 @@ class ForegroundUploadService {
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final ConnectivityApi _connectivityApi;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final EditRevertService _editRevertService;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final Logger _logger = Logger('ForegroundUploadService');
|
||||
|
||||
bool shouldAbortUpload = false;
|
||||
@@ -94,31 +74,16 @@ class ForegroundUploadService {
|
||||
}
|
||||
|
||||
/// Bulk upload of backup candidates from selected albums
|
||||
/// Returns the number of candidates this pass attempted (after [skipIds]
|
||||
/// filtering), so the multi-pass driver can stop as soon as a pass has nothing
|
||||
/// left to do instead of walking the candidate set one extra time.
|
||||
Future<({int attempted, bool hadBurst})> uploadCandidates(
|
||||
Future<void> uploadCandidates(
|
||||
String userId,
|
||||
Completer<void> cancelToken, {
|
||||
UploadCallbacks callbacks = const UploadCallbacks(),
|
||||
bool useSequentialUpload = false,
|
||||
Set<String>? skipIds,
|
||||
}) async {
|
||||
var candidates = await _backupRepository.getCandidates(userId);
|
||||
if (skipIds != null && skipIds.isNotEmpty) {
|
||||
// Multi-pass driver passes the ids it already uploaded this session: a
|
||||
// freshly uploaded asset stays a candidate until its remote row syncs back
|
||||
// locally, so skipping it here stops the next pass re-uploading it (the
|
||||
// server would just dedup it, wasting the transfer).
|
||||
candidates = candidates.where((a) => !skipIds.contains(a.id)).toList();
|
||||
}
|
||||
final candidates = await _backupRepository.getCandidates(userId);
|
||||
if (candidates.isEmpty) {
|
||||
return (attempted: 0, hadBurst: false);
|
||||
return;
|
||||
}
|
||||
// Burst frames may unblock more candidates next pass (a member only becomes
|
||||
// eligible once its representative has uploaded), so the driver keeps going.
|
||||
// Without burst frames this pass is final - no wasted follow-up query.
|
||||
final hadBurst = candidates.any((a) => a.burstId != null);
|
||||
|
||||
final networkCapabilities = await _connectivityApi.getCapabilities();
|
||||
final hasWifi = networkCapabilities.isUnmetered;
|
||||
@@ -137,7 +102,6 @@ class ForegroundUploadService {
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
return (attempted: candidates.length, hadBurst: hadBurst);
|
||||
}
|
||||
|
||||
/// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues
|
||||
@@ -178,7 +142,7 @@ class ForegroundUploadService {
|
||||
await _executeWithWorkerPool<LocalAsset>(
|
||||
items: localAssets,
|
||||
cancelToken: cancelToken,
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks, surfaceDefers: true),
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,33 +232,11 @@ class ForegroundUploadService {
|
||||
await Future.wait(workerFutures);
|
||||
}
|
||||
|
||||
// Multipart fields common to every asset upload. deviceAssetId/deviceId are
|
||||
// required by server v2.7.5 and below (drop in v4.0 per #27818). Returns a
|
||||
// fresh mutable map so callers can add stackParentId/metadata/etc.
|
||||
Map<String, String> _baseUploadFields(LocalAsset asset) => {
|
||||
'deviceAssetId': asset.localId!,
|
||||
'deviceId': Store.get(StoreKey.deviceId),
|
||||
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
|
||||
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': asset.isFavorite.toString(),
|
||||
'duration': (asset.durationMs ?? 0).toString(),
|
||||
};
|
||||
|
||||
Future<void> _uploadSingleAsset(
|
||||
LocalAsset asset,
|
||||
Completer<void>? cancelToken, {
|
||||
required UploadCallbacks callbacks,
|
||||
bool surfaceDefers = false,
|
||||
}) async {
|
||||
// iOS burst non-representative: photo_manager can't resolve it (the entity
|
||||
// lookup below returns null), so fetch its bytes natively and upload it
|
||||
// stacked under the burst anchor. Burst frames are never edited or live, so
|
||||
// they skip the edit-pair + live-photo handling entirely.
|
||||
if (CurrentPlatform.isIOS && asset.burstId != null && !asset.isBurstRepresentative) {
|
||||
await _uploadBurstMember(asset, cancelToken, callbacks: callbacks);
|
||||
return;
|
||||
}
|
||||
|
||||
File? file;
|
||||
File? livePhotoFile;
|
||||
|
||||
@@ -308,55 +250,6 @@ class ForegroundUploadService {
|
||||
return;
|
||||
}
|
||||
|
||||
// A reverted iOS edit flips the stack back to the original and skips the upload.
|
||||
// Works for live photos too — getEditState reads the adjustment plist, which is
|
||||
// media-agnostic. Report the flipped-to base, not the pre-flip prior (the edit
|
||||
// being reverted away) — album-add consumers link whatever id this reports.
|
||||
if (CurrentPlatform.isIOS && asset.priorRemoteId != null) {
|
||||
final revertedTo = await _editRevertService.tryHandleRevert(asset);
|
||||
if (revertedTo != null) {
|
||||
callbacks.onSuccess?.call(asset.localId!, revertedTo);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final fields = _baseUploadFields(asset);
|
||||
|
||||
// Edit pair: upload the unedited original first and stack the edit onto it. For a
|
||||
// live photo that's the original still+video pair; this upload stays the edit and
|
||||
// its own edited motion uploads after, below. Resolved before anything is
|
||||
// materialized so a deferred or failed pair doesn't burn an iCloud download or a
|
||||
// motion upload every retry cycle, and before the edit's metadata is added so the
|
||||
// base isn't stamped with the edit's adjustmentTime.
|
||||
final base = await _resolveStackParent(asset, Map.of(fields), cancelToken, isLivePhoto: entity.isLivePhoto);
|
||||
if (base.deferred) {
|
||||
// Undecidable right now (prior in server trash, or the original couldn't be
|
||||
// read). The asset stays a candidate; auto backup retries silently, a manual
|
||||
// upload tells the user why nothing happened.
|
||||
_logger.fine(() => "Deferring upload for ${asset.localId}: edit pair undecidable this cycle");
|
||||
if (surfaceDefers) {
|
||||
callbacks.onError?.call(asset.localId!, "upload_deferred_edit_pair".t());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (base.baseFailed) {
|
||||
// The original couldn't be uploaded. Don't upload the edit on its own and mark
|
||||
// it synced — that would permanently drop the original from backup. Leave the
|
||||
// whole pair as a candidate to retry next cycle.
|
||||
_logger.warning(() => "Base upload failed for ${asset.localId}, retrying the pair later");
|
||||
if (base.isCancelled) {
|
||||
shouldAbortUpload = true;
|
||||
return;
|
||||
}
|
||||
if (base.errorMessage != null) {
|
||||
callbacks.onError?.call(asset.localId!, base.errorMessage!);
|
||||
if (base.errorMessage == _kQuotaError) {
|
||||
shouldAbortUpload = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
||||
|
||||
if (!isAvailableLocally && CurrentPlatform.isIOS) {
|
||||
@@ -424,13 +317,19 @@ class ForegroundUploadService {
|
||||
}
|
||||
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
if (base.stackParentId != null) {
|
||||
fields['stackParentId'] = base.stackParentId!;
|
||||
}
|
||||
final fields = {
|
||||
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
|
||||
'deviceAssetId': asset.localId!,
|
||||
'deviceId': deviceId,
|
||||
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
|
||||
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': asset.isFavorite.toString(),
|
||||
'duration': (asset.durationMs ?? 0).toString(),
|
||||
};
|
||||
|
||||
// The edit's own motion video, uploaded hidden so it never flashes onto the
|
||||
// timeline before the still below links it.
|
||||
// Upload live photo video first if available
|
||||
String? livePhotoVideoId;
|
||||
if (entity.isLivePhoto && livePhotoFile != null) {
|
||||
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
|
||||
@@ -439,7 +338,7 @@ class ForegroundUploadService {
|
||||
final livePhotoResult = await _uploadRepository.uploadFile(
|
||||
file: livePhotoFile,
|
||||
originalFileName: livePhotoTitle,
|
||||
fields: {...fields, 'visibility': kHiddenVisibility}..remove('stackParentId'),
|
||||
fields: fields,
|
||||
cancelToken: cancelToken,
|
||||
onProgress: onProgress != null
|
||||
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
|
||||
@@ -449,13 +348,6 @@ class ForegroundUploadService {
|
||||
|
||||
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
|
||||
livePhotoVideoId = livePhotoResult.remoteAssetId;
|
||||
} else if (livePhotoResult.isCancelled) {
|
||||
shouldAbortUpload = true;
|
||||
return;
|
||||
} else if (livePhotoResult.errorMessage == _kQuotaError) {
|
||||
callbacks.onError?.call(asset.localId!, livePhotoResult.errorMessage!);
|
||||
shouldAbortUpload = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,9 +356,19 @@ class ForegroundUploadService {
|
||||
}
|
||||
|
||||
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: true);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
if (CurrentPlatform.isIOS && asset.cloudId != null) {
|
||||
fields['metadata'] = jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: asset.cloudId,
|
||||
createdAt: asset.createdAt.toIso8601String(),
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
final onProgress = callbacks.onProgress;
|
||||
@@ -482,18 +384,6 @@ class ForegroundUploadService {
|
||||
);
|
||||
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
// Edit stacking is iOS-only; leave the columns untouched on Android so the
|
||||
// candidate guard and merged-timeline hide clause never engage there.
|
||||
if (CurrentPlatform.isIOS) {
|
||||
unawaited(
|
||||
_localAssetRepository
|
||||
.markSynced(asset.localId!, priorRemoteId: result.remoteAssetId!, syncedChecksum: asset.checksum)
|
||||
.catchError(
|
||||
(Object error, StackTrace stack) =>
|
||||
_logger.warning(() => "Failed to mark ${asset.localId} synced", error, stack),
|
||||
),
|
||||
);
|
||||
}
|
||||
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||
} else if (result.isCancelled) {
|
||||
_logger.warning(() => "Backup was cancelled by the user");
|
||||
@@ -506,21 +396,9 @@ class ForegroundUploadService {
|
||||
|
||||
callbacks.onError?.call(asset.localId!, result.errorMessage!);
|
||||
|
||||
if (result.errorMessage == _kQuotaError) {
|
||||
if (result.errorMessage == "Quota has been exceeded!") {
|
||||
shouldAbortUpload = true;
|
||||
}
|
||||
if (result.errorMessage!.contains(kDeadStackParentError)) {
|
||||
// The stamped prior no longer exists server-side; drop the stamps so
|
||||
// the next cycle re-resolves fresh instead of looping on the dead id.
|
||||
unawaited(
|
||||
_localAssetRepository
|
||||
.clearSyncStamps(asset.localId!)
|
||||
.catchError(
|
||||
(Object error, StackTrace stack) =>
|
||||
_logger.warning(() => "Failed to clear stamps for ${asset.localId}", error, stack),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
|
||||
@@ -537,238 +415,6 @@ class ForegroundUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Foreground upload of an iOS burst non-representative member. Streams the
|
||||
/// same rendition the hash measured ([NativeSyncApi.getCurrentResource]) — the
|
||||
/// member is invisible to photo_manager and matching the hashed bytes keeps it
|
||||
/// merging with its local — and stacks it under the burst anchor with
|
||||
/// `keepPrimary` so the representative stays the primary. Gated until the
|
||||
/// representative has uploaded; returns silently to be retried by the backup
|
||||
/// loop once the anchor resolves.
|
||||
Future<void> _uploadBurstMember(
|
||||
LocalAsset asset,
|
||||
Completer<void>? cancelToken, {
|
||||
required UploadCallbacks callbacks,
|
||||
}) async {
|
||||
final ownerId = Store.tryGet(StoreKey.currentUser)?.id;
|
||||
final parentRemoteId = await _localAssetRepository.getBurstParentRemoteId(asset.burstId!, ownerId: ownerId);
|
||||
if (parentRemoteId == null) {
|
||||
// No anchor. A rep-less group (Keep Everything / re-pick) can never anchor,
|
||||
// so upload the frame standalone instead of gating forever; if a rep still
|
||||
// exists the member is just waiting for it to upload.
|
||||
if (await _localAssetRepository.burstHasRepresentative(asset.burstId!)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
BaseResource? resource;
|
||||
try {
|
||||
resource = await _nativeSyncApi.getCurrentResource(asset.id, allowNetworkAccess: true);
|
||||
} catch (error, stack) {
|
||||
_logger.warning(() => "burst getCurrentResource failed for ${asset.id}: $error", error, stack);
|
||||
}
|
||||
if (resource == null) {
|
||||
callbacks.onError?.call(asset.localId!, "asset_not_found_on_device_ios".t());
|
||||
return;
|
||||
}
|
||||
|
||||
final file = File(resource.path);
|
||||
try {
|
||||
// Rep-less group → standalone (no stack); otherwise stack under the anchor.
|
||||
final fields = _baseUploadFields(asset)..addAll(burstStackFields(parentRemoteId));
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: true);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
}
|
||||
final originalFileName = p.setExtension(
|
||||
await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name,
|
||||
p.extension(resource.path),
|
||||
);
|
||||
|
||||
final onProgress = callbacks.onProgress;
|
||||
final result = await _uploadRepository.uploadFile(
|
||||
file: file,
|
||||
originalFileName: originalFileName,
|
||||
fields: fields,
|
||||
cancelToken: cancelToken,
|
||||
onProgress: onProgress != null
|
||||
? (bytes, totalBytes) => onProgress(asset.localId!, originalFileName, bytes, totalBytes)
|
||||
: null,
|
||||
logContext: 'burstMember[${asset.localId}]',
|
||||
);
|
||||
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
unawaited(
|
||||
_localAssetRepository
|
||||
.markSynced(asset.localId!, priorRemoteId: result.remoteAssetId!, syncedChecksum: asset.checksum)
|
||||
.catchError(
|
||||
(Object error, StackTrace stack) =>
|
||||
_logger.warning(() => "Failed to mark burst member ${asset.localId} synced", error, stack),
|
||||
),
|
||||
);
|
||||
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||
} else if (result.isCancelled) {
|
||||
shouldAbortUpload = true;
|
||||
} else if (result.errorMessage != null) {
|
||||
callbacks.onError?.call(asset.localId!, result.errorMessage!);
|
||||
if (result.errorMessage == _kQuotaError) {
|
||||
shouldAbortUpload = true;
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe(() => "Error uploading burst member ${asset.localId}: $error", stackTrace);
|
||||
callbacks.onError?.call(asset.localId!, error.toString());
|
||||
} finally {
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS still-image cloudId metadata as a JSON field, or null when there's
|
||||
/// nothing to attach. The base resource omits adjustmentTime (it's the
|
||||
/// unedited original); the edit includes it.
|
||||
String? _cloudMetadata(LocalAsset asset, {required bool includeAdjustment}) {
|
||||
return cloudMetadataJson(
|
||||
cloudId: asset.cloudId,
|
||||
createdAt: asset.createdAt,
|
||||
adjustmentTime: includeAdjustment ? asset.adjustmentTime?.toIso8601String() : null,
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Persists the uploaded base as the asset's prior so an interrupted run resumes
|
||||
/// by stacking onto it (AbsorbIntoPrior) instead of re-reading and re-uploading
|
||||
/// the original. syncedChecksum stays null: the edit itself is still pending.
|
||||
Future<void> _stampBaseUpload(LocalAsset asset, String baseRemoteId) async {
|
||||
try {
|
||||
await _localAssetRepository.markSynced(asset.localId!, priorRemoteId: baseRemoteId, syncedChecksum: null);
|
||||
} catch (error, stack) {
|
||||
_logger.warning(() => "Failed to stamp base upload for ${asset.localId}", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// For an edited iOS photo, uploads the original camera bytes so the edit can
|
||||
/// stack onto it. See [_StackParent] for the outcome.
|
||||
Future<_StackParent> _resolveStackParent(
|
||||
LocalAsset asset,
|
||||
Map<String, String> baseFields,
|
||||
Completer<void>? cancelToken, {
|
||||
bool isLivePhoto = false,
|
||||
}) async {
|
||||
if (!CurrentPlatform.isIOS) {
|
||||
return const _StackParent.none();
|
||||
}
|
||||
|
||||
final plan = await resolveEditPair(
|
||||
_nativeSyncApi,
|
||||
asset,
|
||||
stackRepository: _stackRepository,
|
||||
ownerId: Store.tryGet(StoreKey.currentUser)?.id,
|
||||
log: _logger,
|
||||
isLivePhoto: isLivePhoto,
|
||||
);
|
||||
switch (plan) {
|
||||
case NoEditPair():
|
||||
return const _StackParent.none();
|
||||
case DeferEditPair():
|
||||
return const _StackParent.deferred();
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
return _StackParent.parent(parentId);
|
||||
case UploadBaseLivePhotoFirst(:final still, :final video):
|
||||
return _uploadBaseLivePair(asset, baseFields, still, video, cancelToken);
|
||||
case UploadBaseFirst(:final base):
|
||||
final baseFile = File(base.path);
|
||||
try {
|
||||
final fields = Map.of(baseFields);
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: false);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
}
|
||||
final result = await _uploadRepository.uploadFile(
|
||||
file: baseFile,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(base.path)),
|
||||
fields: fields,
|
||||
cancelToken: cancelToken,
|
||||
logContext: 'baseResource[${asset.localId}]',
|
||||
);
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
await _stampBaseUpload(asset, result.remoteAssetId!);
|
||||
return _StackParent.parent(result.remoteAssetId!);
|
||||
}
|
||||
return _StackParent.failed(errorMessage: result.errorMessage, isCancelled: result.isCancelled);
|
||||
} finally {
|
||||
try {
|
||||
await baseFile.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads the original live pair (paired video then still) so the edited live photo
|
||||
/// can stack onto the original still. Returns the still's remote id as the parent, or
|
||||
/// [_StackParent.failed] if either hop fails so the edit isn't left unstacked.
|
||||
Future<_StackParent> _uploadBaseLivePair(
|
||||
LocalAsset asset,
|
||||
Map<String, String> baseFields,
|
||||
BaseResource still,
|
||||
BaseResource? video,
|
||||
Completer<void>? cancelToken,
|
||||
) async {
|
||||
final stillFile = File(still.path);
|
||||
final videoFile = video != null ? File(video.path) : null;
|
||||
try {
|
||||
final fields = Map.of(baseFields);
|
||||
|
||||
String? baseVideoId;
|
||||
if (videoFile != null) {
|
||||
final videoResult = await _uploadRepository.uploadFile(
|
||||
file: videoFile,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(videoFile.path)),
|
||||
// hidden so the original motion never flashes onto the timeline (copy: the
|
||||
// base still upload below reuses `fields`).
|
||||
fields: {...fields, 'visibility': kHiddenVisibility},
|
||||
cancelToken: cancelToken,
|
||||
logContext: 'baseLiveVideo[${asset.localId}]',
|
||||
);
|
||||
if (!(videoResult.isSuccess && videoResult.remoteAssetId != null)) {
|
||||
return _StackParent.failed(errorMessage: videoResult.errorMessage, isCancelled: videoResult.isCancelled);
|
||||
}
|
||||
baseVideoId = videoResult.remoteAssetId;
|
||||
}
|
||||
|
||||
if (baseVideoId != null) {
|
||||
fields['livePhotoVideoId'] = baseVideoId;
|
||||
}
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: false);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
}
|
||||
|
||||
final stillResult = await _uploadRepository.uploadFile(
|
||||
file: stillFile,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(still.path)),
|
||||
fields: fields,
|
||||
cancelToken: cancelToken,
|
||||
logContext: 'baseLiveStill[${asset.localId}]',
|
||||
);
|
||||
if (stillResult.isSuccess && stillResult.remoteAssetId != null) {
|
||||
await _stampBaseUpload(asset, stillResult.remoteAssetId!);
|
||||
return _StackParent.parent(stillResult.remoteAssetId!);
|
||||
}
|
||||
return _StackParent.failed(errorMessage: stillResult.errorMessage, isCancelled: stillResult.isCancelled);
|
||||
} finally {
|
||||
try {
|
||||
await stillFile.delete();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await videoFile?.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<UploadResult> _uploadSingleFile(
|
||||
File file, {
|
||||
required String deviceAssetId,
|
||||
@@ -815,42 +461,3 @@ class ForegroundUploadService {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Server's quota-rejection message (asset-media.service.ts requireQuota).
|
||||
const String _kQuotaError = "Quota has been exceeded!";
|
||||
|
||||
/// Outcome of resolving an edit's stack parent. [stackParentId] is the remote id
|
||||
/// to stack onto (null when the asset isn't an edit). [baseFailed] is true only
|
||||
/// when the original was found but its upload failed, so the edit must not be
|
||||
/// uploaded on its own; [deferred] means skip the asset this cycle (it stays a
|
||||
/// candidate); [errorMessage]/[isCancelled] carry why a failure happened so the
|
||||
/// caller can surface it and react to quota/cancel like the main upload does.
|
||||
class _StackParent {
|
||||
final String? stackParentId;
|
||||
final bool baseFailed;
|
||||
final bool deferred;
|
||||
final String? errorMessage;
|
||||
final bool isCancelled;
|
||||
|
||||
const _StackParent.none()
|
||||
: stackParentId = null,
|
||||
baseFailed = false,
|
||||
deferred = false,
|
||||
errorMessage = null,
|
||||
isCancelled = false;
|
||||
const _StackParent.parent(String this.stackParentId)
|
||||
: baseFailed = false,
|
||||
deferred = false,
|
||||
errorMessage = null,
|
||||
isCancelled = false;
|
||||
const _StackParent.failed({this.errorMessage, this.isCancelled = false})
|
||||
: stackParentId = null,
|
||||
baseFailed = true,
|
||||
deferred = false;
|
||||
const _StackParent.deferred()
|
||||
: stackParentId = null,
|
||||
baseFailed = false,
|
||||
deferred = true,
|
||||
errorMessage = null,
|
||||
isCancelled = false;
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -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-rc.2
|
||||
- API version: 3.0.0-rc.1
|
||||
- Generator version: 7.22.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
Generated
+3
-23
@@ -1593,9 +1593,6 @@ class AssetsApi {
|
||||
/// * [bool] isFavorite:
|
||||
/// Mark as favorite
|
||||
///
|
||||
/// * [bool] keepPrimary:
|
||||
/// When stacking via stackParentId, keep the parent/existing asset as the stack primary instead of promoting this one. Used by iOS burst frames.
|
||||
///
|
||||
/// * [String] livePhotoVideoId:
|
||||
/// Live photo video ID
|
||||
///
|
||||
@@ -1605,11 +1602,8 @@ class AssetsApi {
|
||||
/// * [MultipartFile] sidecarData:
|
||||
/// Sidecar file data
|
||||
///
|
||||
/// * [String] stackParentId:
|
||||
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
||||
///
|
||||
/// * [AssetVisibility] visibility:
|
||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, bool? keepPrimary, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets';
|
||||
|
||||
@@ -1660,10 +1654,6 @@ class AssetsApi {
|
||||
hasFields = true;
|
||||
mp.fields[r'isFavorite'] = parameterToString(isFavorite);
|
||||
}
|
||||
if (keepPrimary != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'keepPrimary'] = parameterToString(keepPrimary);
|
||||
}
|
||||
if (livePhotoVideoId != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'livePhotoVideoId'] = parameterToString(livePhotoVideoId);
|
||||
@@ -1677,10 +1667,6 @@ class AssetsApi {
|
||||
mp.fields[r'sidecarData'] = sidecarData.field;
|
||||
mp.files.add(sidecarData);
|
||||
}
|
||||
if (stackParentId != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'stackParentId'] = parameterToString(stackParentId);
|
||||
}
|
||||
if (visibility != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'visibility'] = parameterToString(visibility);
|
||||
@@ -1732,9 +1718,6 @@ class AssetsApi {
|
||||
/// * [bool] isFavorite:
|
||||
/// Mark as favorite
|
||||
///
|
||||
/// * [bool] keepPrimary:
|
||||
/// When stacking via stackParentId, keep the parent/existing asset as the stack primary instead of promoting this one. Used by iOS burst frames.
|
||||
///
|
||||
/// * [String] livePhotoVideoId:
|
||||
/// Live photo video ID
|
||||
///
|
||||
@@ -1744,12 +1727,9 @@ class AssetsApi {
|
||||
/// * [MultipartFile] sidecarData:
|
||||
/// Sidecar file data
|
||||
///
|
||||
/// * [String] stackParentId:
|
||||
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
||||
///
|
||||
/// * [AssetVisibility] visibility:
|
||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, bool? keepPrimary, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, keepPrimary: keepPrimary, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, stackParentId: stackParentId, visibility: visibility, abortTrigger: abortTrigger,);
|
||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
@@ -35,12 +35,6 @@ class PlatformAsset {
|
||||
|
||||
final PlatformAssetPlaybackStyle playbackStyle;
|
||||
|
||||
// iOS burst grouping. `burstId` = PHAsset.burstIdentifier (null for non-burst
|
||||
// assets). `isBurstRepresentative` = the auto-picked lead frame at detection
|
||||
// time. android always returns null/false (no burstIdentifier equivalent).
|
||||
final String? burstId;
|
||||
final bool isBurstRepresentative;
|
||||
|
||||
const PlatformAsset({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -56,8 +50,6 @@ class PlatformAsset {
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.playbackStyle = PlatformAssetPlaybackStyle.unknown,
|
||||
this.burstId,
|
||||
this.isBurstRepresentative = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,29 +103,6 @@ class CloudIdResult {
|
||||
const CloudIdResult({required this.assetId, this.error, this.cloudId});
|
||||
}
|
||||
|
||||
class BaseResource {
|
||||
final String path;
|
||||
final String sha1;
|
||||
|
||||
const BaseResource({required this.path, required this.sha1});
|
||||
}
|
||||
|
||||
// The readable originals of an edited live photo: the still always, the paired
|
||||
// video when the asset still carries one. Both are temp copies the caller
|
||||
// uploads then deletes.
|
||||
class BaseLivePhoto {
|
||||
final BaseResource still;
|
||||
final BaseResource? video;
|
||||
|
||||
const BaseLivePhoto({required this.still, this.video});
|
||||
}
|
||||
|
||||
// Whether an iOS asset currently carries a user edit, as opposed to a
|
||||
// capture-time Photographic Style or a reverted edit. `unknown` means the
|
||||
// adjustment data couldn't be read (e.g. the asset is offloaded to iCloud and
|
||||
// network wasn't allowed), so callers must not treat it as "not edited".
|
||||
enum EditState { notEdited, edited, unknown }
|
||||
|
||||
@HostApi()
|
||||
abstract class NativeSyncApi {
|
||||
@async
|
||||
@@ -174,26 +143,4 @@ abstract class NativeSyncApi {
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
||||
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
BaseResource? getBaseResource(String assetId, {bool allowNetworkAccess = false});
|
||||
|
||||
/// Streams the bytes immich treats as the asset's canonical content — the same
|
||||
/// resource [hashAssets] hashes (`PHAsset.getResource()`, the `.isCurrent`
|
||||
/// rendition). Used to upload iOS burst members: they're invisible to
|
||||
/// photo_manager, so this is the only way to read their file, and streaming
|
||||
/// the same resource the hash measured keeps the server checksum aligned with
|
||||
/// the local one (else the asset shows cloud-only). iOS-only; android returns null.
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
BaseResource? getCurrentResource(String assetId, {bool allowNetworkAccess = false});
|
||||
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
EditState getEditState(String assetId, {bool allowNetworkAccess = false});
|
||||
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
BaseLivePhoto? getBaseLivePhoto(String assetId, {bool allowNetworkAccess = false});
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.0.0-rc.2+3050
|
||||
version: 3.0.0-rc.1+3049
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -8,5 +7,3 @@ class MockSyncApi extends Mock implements SyncApi {}
|
||||
class MockServerApi extends Mock implements ServerApi {}
|
||||
|
||||
class MockPartnerApiRepository extends Mock implements PartnerApiRepository {}
|
||||
|
||||
class MockConnectivityApi extends Mock implements ConnectivityApi {}
|
||||
|
||||
@@ -4,12 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as domain;
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -32,68 +28,26 @@ SyncAssetV1 _createAsset({
|
||||
String ownerId = 'user-1',
|
||||
int? width,
|
||||
int? height,
|
||||
AssetVisibility visibility = AssetVisibility.timeline,
|
||||
AssetTypeEnum type = AssetTypeEnum.IMAGE,
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool isFavorite = false,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return SyncAssetV1(
|
||||
id: id,
|
||||
checksum: checksum,
|
||||
originalFileName: fileName,
|
||||
type: type,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
ownerId: ownerId,
|
||||
isFavorite: isFavorite,
|
||||
isFavorite: false,
|
||||
fileCreatedAt: DateTime(2024, 1, 1),
|
||||
fileModifiedAt: DateTime(2024, 1, 1),
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
localDateTime: DateTime(2024, 1, 1),
|
||||
visibility: visibility,
|
||||
visibility: AssetVisibility.timeline,
|
||||
width: width,
|
||||
height: height,
|
||||
deletedAt: deletedAt,
|
||||
deletedAt: null,
|
||||
duration: null,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
stackId: stackId,
|
||||
thumbhash: null,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
SyncAssetV2 _createAssetV2({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required String fileName,
|
||||
String ownerId = 'user-1',
|
||||
AssetVisibility visibility = AssetVisibility.timeline,
|
||||
AssetTypeEnum type = AssetTypeEnum.IMAGE,
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool isFavorite = false,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return SyncAssetV2(
|
||||
id: id,
|
||||
checksum: checksum,
|
||||
originalFileName: fileName,
|
||||
type: type,
|
||||
ownerId: ownerId,
|
||||
isFavorite: isFavorite,
|
||||
fileCreatedAt: DateTime(2024, 1, 1),
|
||||
fileModifiedAt: DateTime(2024, 1, 1),
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
localDateTime: DateTime(2024, 1, 1),
|
||||
visibility: visibility,
|
||||
width: null,
|
||||
height: null,
|
||||
deletedAt: deletedAt,
|
||||
duration: null,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
stackId: stackId,
|
||||
livePhotoVideoId: null,
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
isEdited: false,
|
||||
);
|
||||
@@ -235,168 +189,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncStreamRepository - websocket fast-path link state', () {
|
||||
Future<RemoteAssetEntityData> read(String id) =>
|
||||
(db.remoteAssetEntity.select()..where((t) => t.id.equals(id))).getSingle();
|
||||
|
||||
test('fromWebsocket does not clobber visibility the checkpoint sync already hid', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'motion-video';
|
||||
|
||||
// checkpoint sync stored the real server state: a hidden motion video
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(
|
||||
id: id,
|
||||
checksum: 'cs',
|
||||
fileName: 'IMG.mov',
|
||||
type: AssetTypeEnum.VIDEO,
|
||||
visibility: AssetVisibility.hidden,
|
||||
),
|
||||
]);
|
||||
|
||||
// a stale upload-ready event arrives with the upload-time state (timeline)
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(
|
||||
id: id,
|
||||
checksum: 'cs',
|
||||
fileName: 'IMG.mov',
|
||||
type: AssetTypeEnum.VIDEO,
|
||||
visibility: AssetVisibility.timeline,
|
||||
),
|
||||
], fromWebsocket: true);
|
||||
|
||||
expect((await read(id)).visibility, domain.AssetVisibility.hidden);
|
||||
});
|
||||
|
||||
test('authoritative sync (default) still overwrites visibility', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'asset-1';
|
||||
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', visibility: AssetVisibility.hidden),
|
||||
]);
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', visibility: AssetVisibility.timeline),
|
||||
]);
|
||||
|
||||
expect((await read(id)).visibility, domain.AssetVisibility.timeline);
|
||||
});
|
||||
|
||||
test('fromWebsocket still inserts a new asset with its payload visibility', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'new-asset';
|
||||
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', visibility: AssetVisibility.timeline),
|
||||
], fromWebsocket: true);
|
||||
|
||||
expect((await read(id)).visibility, domain.AssetVisibility.timeline);
|
||||
});
|
||||
|
||||
test('fromWebsocket conflict keeps checkpoint livePhotoVideoId and stackId but applies other fields', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'edited-still-1';
|
||||
const stackId = 'stack-001';
|
||||
|
||||
await db.stackEntity.insertOne(StackEntityCompanion.insert(id: stackId, ownerId: 'user-1', primaryAssetId: id));
|
||||
|
||||
// checkpoint linked the edited still to its base pair
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', livePhotoVideoId: 'live-vid-001', stackId: stackId),
|
||||
]);
|
||||
|
||||
// stale websocket snapshot from upload time: no links yet, but favorite since
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(id: id, checksum: 'cs', fileName: 'IMG_RENAMED.heic', isFavorite: true),
|
||||
], fromWebsocket: true);
|
||||
|
||||
final row = await read(id);
|
||||
expect(row.livePhotoVideoId, 'live-vid-001');
|
||||
expect(row.stackId, stackId);
|
||||
expect(row.name, 'IMG_RENAMED.heic', reason: 'non-link fields from the websocket payload must still apply');
|
||||
expect(row.isFavorite, isTrue);
|
||||
});
|
||||
|
||||
test('fromWebsocket conflict does not resurrect an asset the checkpoint trashed', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'trashed-asset';
|
||||
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', deletedAt: DateTime(2024, 2, 1)),
|
||||
]);
|
||||
|
||||
// debounced upload-ready snapshot always carries deletedAt null
|
||||
await sut.updateAssetsV1([_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic')], fromWebsocket: true);
|
||||
|
||||
expect((await read(id)).deletedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('authoritative sync (default) still overwrites livePhotoVideoId, stackId and deletedAt', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'unstacked-asset';
|
||||
const stackId = 'stack-002';
|
||||
|
||||
await db.stackEntity.insertOne(StackEntityCompanion.insert(id: stackId, ownerId: 'user-1', primaryAssetId: id));
|
||||
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(
|
||||
id: id,
|
||||
checksum: 'cs',
|
||||
fileName: 'IMG.heic',
|
||||
livePhotoVideoId: 'live-vid-002',
|
||||
stackId: stackId,
|
||||
deletedAt: DateTime(2024, 2, 1),
|
||||
),
|
||||
]);
|
||||
|
||||
// server unstacked + restored the asset; checkpoint sync must win
|
||||
await sut.updateAssetsV1([_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic')]);
|
||||
|
||||
final row = await read(id);
|
||||
expect(row.livePhotoVideoId, isNull);
|
||||
expect(row.stackId, isNull);
|
||||
expect(row.deletedAt, isNull);
|
||||
});
|
||||
|
||||
test('fromWebsocket does not clobber visibility through updateAssetsV2', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'motion-video-v2';
|
||||
|
||||
await sut.updateAssetsV2([
|
||||
_createAssetV2(
|
||||
id: id,
|
||||
checksum: 'cs',
|
||||
fileName: 'IMG.mov',
|
||||
type: AssetTypeEnum.VIDEO,
|
||||
visibility: AssetVisibility.hidden,
|
||||
),
|
||||
]);
|
||||
|
||||
await sut.updateAssetsV2([
|
||||
_createAssetV2(
|
||||
id: id,
|
||||
checksum: 'cs',
|
||||
fileName: 'IMG.mov',
|
||||
type: AssetTypeEnum.VIDEO,
|
||||
visibility: AssetVisibility.timeline,
|
||||
),
|
||||
], fromWebsocket: true);
|
||||
|
||||
expect((await read(id)).visibility, domain.AssetVisibility.hidden);
|
||||
});
|
||||
|
||||
test('fromWebsocket still inserts a new asset through updateAssetsV2', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'new-asset-v2';
|
||||
|
||||
await sut.updateAssetsV2([
|
||||
_createAssetV2(id: id, checksum: 'cs', fileName: 'IMG.heic', visibility: AssetVisibility.timeline),
|
||||
], fromWebsocket: true);
|
||||
|
||||
expect((await read(id)).visibility, domain.AssetVisibility.timeline);
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncStreamRepository - reset()', () {
|
||||
test('nulls linkedRemoteAlbumId on localAlbumEntity so FK refs do not dangle', () async {
|
||||
const localAlbumId = 'local-1';
|
||||
@@ -447,32 +239,5 @@ void main() {
|
||||
expect(after.name, equals('Camera'));
|
||||
expect(after.backupSelection, equals(BackupSelection.none));
|
||||
});
|
||||
|
||||
test('nulls priorRemoteId and syncedChecksum on localAssetEntity but keeps the row', () async {
|
||||
const localId = 'local-edited';
|
||||
|
||||
await db.localAssetEntity.insertOne(
|
||||
LocalAssetEntityCompanion.insert(
|
||||
id: localId,
|
||||
name: 'IMG.heic',
|
||||
type: domain.AssetType.image,
|
||||
checksum: const drift.Value('cs-local'),
|
||||
priorRemoteId: const drift.Value('prior-remote-1'),
|
||||
syncedChecksum: const drift.Value('cs-synced'),
|
||||
),
|
||||
);
|
||||
|
||||
await sut.reset();
|
||||
|
||||
final after = await (db.localAssetEntity.select()..where((t) => t.id.equals(localId))).getSingle();
|
||||
expect(
|
||||
after.priorRemoteId,
|
||||
isNull,
|
||||
reason: 'the remote rows the stamps point at were wiped — a later backup must not stack onto dead ids',
|
||||
);
|
||||
expect(after.syncedChecksum, isNull);
|
||||
expect(after.name, equals('IMG.heic'), reason: 'local asset row itself must be preserved');
|
||||
expect(after.checksum, equals('cs-local'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/domain/services/partner.service.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
@@ -15,5 +14,3 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockPartnerService extends Mock implements PartnerService {}
|
||||
|
||||
class MockEditRevertService extends Mock implements EditRevertService {}
|
||||
|
||||
@@ -133,7 +133,6 @@ void main() {
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
isBurstRepresentative: false,
|
||||
);
|
||||
|
||||
final assetsToRestore = [LocalAssetStub.image1];
|
||||
@@ -233,7 +232,6 @@ void main() {
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1732000000,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
isBurstRepresentative: false,
|
||||
);
|
||||
|
||||
final localAsset = platformAsset.toLocalAsset();
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
@@ -37,6 +36,7 @@ class _AbortCallbackWrapper {
|
||||
|
||||
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
|
||||
|
||||
|
||||
void main() {
|
||||
late SyncStreamService sut;
|
||||
late SyncStreamRepository mockSyncStreamRepo;
|
||||
@@ -620,174 +620,4 @@ void main() {
|
||||
verifyNever(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset());
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncStreamService - websocket fromWebsocket plumbing', () {
|
||||
SyncAssetV1 wsAssetV1(String id) => SyncAssetV1(
|
||||
checksum: 'checksum-$id',
|
||||
createdAt: DateTime(2025, 1, 2),
|
||||
deletedAt: null,
|
||||
duration: null,
|
||||
fileCreatedAt: DateTime(2025),
|
||||
fileModifiedAt: DateTime(2025, 1, 2),
|
||||
height: null,
|
||||
id: id,
|
||||
isEdited: false,
|
||||
isFavorite: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: DateTime(2025, 1, 3),
|
||||
originalFileName: '$id.jpg',
|
||||
ownerId: 'owner',
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
visibility: AssetVisibility.timeline,
|
||||
width: null,
|
||||
);
|
||||
|
||||
SyncAssetV2 wsAssetV2(String id) => SyncAssetV2(
|
||||
checksum: 'checksum-$id',
|
||||
createdAt: DateTime(2025, 1, 2),
|
||||
deletedAt: null,
|
||||
duration: null,
|
||||
fileCreatedAt: DateTime(2025),
|
||||
fileModifiedAt: DateTime(2025, 1, 2),
|
||||
height: null,
|
||||
id: id,
|
||||
isEdited: false,
|
||||
isFavorite: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: DateTime(2025, 1, 3),
|
||||
originalFileName: '$id.jpg',
|
||||
ownerId: 'owner',
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
visibility: AssetVisibility.timeline,
|
||||
width: null,
|
||||
);
|
||||
|
||||
SyncAssetExifV1 wsExif(String id) => SyncAssetExifV1(
|
||||
assetId: id,
|
||||
city: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
exifImageHeight: null,
|
||||
exifImageWidth: null,
|
||||
exposureTime: null,
|
||||
fNumber: null,
|
||||
fileSizeInByte: null,
|
||||
focalLength: null,
|
||||
fps: null,
|
||||
iso: null,
|
||||
latitude: null,
|
||||
lensModel: null,
|
||||
longitude: null,
|
||||
make: null,
|
||||
model: null,
|
||||
modifyDate: null,
|
||||
orientation: null,
|
||||
profileDescription: null,
|
||||
projectionType: null,
|
||||
rating: null,
|
||||
state: null,
|
||||
timeZone: null,
|
||||
);
|
||||
|
||||
// toJson keeps enums as objects; round-trip so fromJson sees plain JSON
|
||||
Map<String, dynamic> wsPayload(Map<String, dynamic> payload) =>
|
||||
jsonDecode(jsonEncode(payload)) as Map<String, dynamic>;
|
||||
|
||||
setUp(() {
|
||||
// stubs registered without fromWebsocket won't match calls that pass it
|
||||
when(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(
|
||||
any(),
|
||||
debugLabel: any(named: 'debugLabel'),
|
||||
fromWebsocket: any(named: 'fromWebsocket'),
|
||||
),
|
||||
).thenAnswer(successHandler);
|
||||
when(
|
||||
() => mockSyncStreamRepo.updateAssetsV2(
|
||||
any(),
|
||||
debugLabel: any(named: 'debugLabel'),
|
||||
fromWebsocket: any(named: 'fromWebsocket'),
|
||||
),
|
||||
).thenAnswer(successHandler);
|
||||
when(
|
||||
() => mockSyncStreamRepo.replaceAssetEditsV1(any(), any(), debugLabel: any(named: 'debugLabel')),
|
||||
).thenAnswer(successHandler);
|
||||
});
|
||||
|
||||
test('handleWsAssetUploadReadyV1Batch passes fromWebsocket true', () async {
|
||||
await sut.handleWsAssetUploadReadyV1Batch([
|
||||
wsPayload({'asset': wsAssetV1('ws-v1').toJson(), 'exif': wsExif('ws-v1').toJson()}),
|
||||
]);
|
||||
|
||||
verify(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
|
||||
).called(1);
|
||||
verify(() => mockSyncStreamRepo.updateAssetsExifV1(any(), debugLabel: any(named: 'debugLabel'))).called(1);
|
||||
verifyNever(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('handleWsAssetUploadReadyV2Batch passes fromWebsocket true', () async {
|
||||
await sut.handleWsAssetUploadReadyV2Batch([
|
||||
wsPayload({'asset': wsAssetV2('ws-v2').toJson(), 'exif': wsExif('ws-v2').toJson()}),
|
||||
]);
|
||||
|
||||
verify(
|
||||
() => mockSyncStreamRepo.updateAssetsV2(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
|
||||
).called(1);
|
||||
verify(() => mockSyncStreamRepo.updateAssetsExifV1(any(), debugLabel: any(named: 'debugLabel'))).called(1);
|
||||
verifyNever(
|
||||
() => mockSyncStreamRepo.updateAssetsV2(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('handleWsAssetEditReadyV1 passes fromWebsocket true', () async {
|
||||
await sut.handleWsAssetEditReadyV1(wsPayload({'asset': wsAssetV1('ws-edit-v1').toJson(), 'edit': []}));
|
||||
|
||||
verify(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
|
||||
).called(1);
|
||||
verify(
|
||||
() => mockSyncStreamRepo.replaceAssetEditsV1('ws-edit-v1', any(), debugLabel: any(named: 'debugLabel')),
|
||||
).called(1);
|
||||
verifyNever(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('handleWsAssetEditReadyV2 passes fromWebsocket true', () async {
|
||||
await sut.handleWsAssetEditReadyV2(wsPayload({'asset': wsAssetV2('ws-edit-v2').toJson(), 'edit': []}));
|
||||
|
||||
verify(
|
||||
() => mockSyncStreamRepo.updateAssetsV2(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
|
||||
).called(1);
|
||||
verify(
|
||||
() => mockSyncStreamRepo.replaceAssetEditsV1('ws-edit-v2', any(), debugLabel: any(named: 'debugLabel')),
|
||||
).called(1);
|
||||
verifyNever(
|
||||
() => mockSyncStreamRepo.updateAssetsV2(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('checkpoint sync keeps fromWebsocket false', () async {
|
||||
await simulateEvents([
|
||||
SyncStreamStub.assetModified(id: 'remote-checkpoint', checksum: 'checksum-cp', ack: 'cp-ack'),
|
||||
]);
|
||||
|
||||
verify(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
|
||||
).called(1);
|
||||
verifyNever(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
-4
@@ -34,7 +34,6 @@ import 'schema_v27.dart' as v27;
|
||||
import 'schema_v28.dart' as v28;
|
||||
import 'schema_v29.dart' as v29;
|
||||
import 'schema_v30.dart' as v30;
|
||||
import 'schema_v31.dart' as v31;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -100,8 +99,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v29.DatabaseAtV29(db);
|
||||
case 30:
|
||||
return v30.DatabaseAtV30(db);
|
||||
case 31:
|
||||
return v31.DatabaseAtV31(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -138,6 +135,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
28,
|
||||
29,
|
||||
30,
|
||||
31,
|
||||
];
|
||||
}
|
||||
|
||||
-10201
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,11 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
|
||||
void main() {
|
||||
late Drift db;
|
||||
@@ -24,64 +18,13 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
Future<void> insertUser(String id) =>
|
||||
db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: '$id@test.dev', name: id));
|
||||
|
||||
Future<void> insertRemote(
|
||||
String id,
|
||||
String ownerId, {
|
||||
required String checksum,
|
||||
required DateTime at,
|
||||
DateTime? deletedAt,
|
||||
}) => db
|
||||
.into(db.remoteAssetEntity)
|
||||
.insert(
|
||||
RemoteAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
type: AssetType.image,
|
||||
checksum: checksum,
|
||||
ownerId: ownerId,
|
||||
visibility: AssetVisibility.timeline,
|
||||
createdAt: Value(at),
|
||||
updatedAt: Value(at),
|
||||
uploadedAt: Value(at),
|
||||
deletedAt: Value(deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> insertLocal(String id, {required DateTime at, String? checksum, String? priorRemoteId}) => db
|
||||
.into(db.localAssetEntity)
|
||||
.insert(
|
||||
LocalAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
type: AssetType.image,
|
||||
checksum: Value(checksum),
|
||||
priorRemoteId: Value(priorRemoteId),
|
||||
createdAt: Value(at),
|
||||
updatedAt: Value(at),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> insertSelectedAlbumWith(String albumId, List<String> assetIds) async {
|
||||
await db
|
||||
.into(db.localAlbumEntity)
|
||||
.insert(
|
||||
LocalAlbumEntityCompanion.insert(id: albumId, name: albumId, backupSelection: BackupSelection.selected),
|
||||
);
|
||||
for (final assetId in assetIds) {
|
||||
await db
|
||||
.into(db.localAlbumAssetEntity)
|
||||
.insert(LocalAlbumAssetEntityCompanion.insert(assetId: assetId, albumId: albumId));
|
||||
}
|
||||
}
|
||||
|
||||
test('mergedBucket falls back to createdAt when localDateTime is null', () async {
|
||||
const userId = 'user-1';
|
||||
final createdAt = DateTime(2024, 1, 1, 12);
|
||||
|
||||
await insertUser(userId);
|
||||
await db
|
||||
.into(db.userEntity)
|
||||
.insert(UserEntityCompanion.insert(id: userId, email: 'user-1@test.dev', name: 'User 1'));
|
||||
|
||||
await db
|
||||
.into(db.remoteAssetEntity)
|
||||
@@ -106,150 +49,4 @@ void main() {
|
||||
expect(buckets.single.assetCount, 1);
|
||||
expect(buckets.single.bucketDate, isNotEmpty);
|
||||
});
|
||||
|
||||
// Reproduces the on-server shape of an edited live photo: 2 stills stacked
|
||||
// (primary = the edit) + 2 hidden motion videos. The hidden videos must never
|
||||
// become timeline tiles, and the stack collapses to its primary still.
|
||||
test('edited live photo: hidden motion videos excluded, stack collapses to its primary', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
await db
|
||||
.into(db.stackEntity)
|
||||
.insert(StackEntityCompanion.insert(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit-still'));
|
||||
|
||||
Future<void> ins(String id, AssetType type, AssetVisibility vis, {String? lpv, String? stackId}) => db
|
||||
.into(db.remoteAssetEntity)
|
||||
.insert(
|
||||
RemoteAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: '$id.x',
|
||||
type: type,
|
||||
checksum: 'cs-$id',
|
||||
ownerId: userId,
|
||||
visibility: vis,
|
||||
createdAt: Value(t),
|
||||
updatedAt: Value(t),
|
||||
uploadedAt: Value(t),
|
||||
livePhotoVideoId: Value(lpv),
|
||||
stackId: Value(stackId),
|
||||
),
|
||||
);
|
||||
|
||||
await ins('orig-still', AssetType.image, AssetVisibility.timeline, lpv: 'orig-video', stackId: 'stack-1');
|
||||
await ins('edit-still', AssetType.image, AssetVisibility.timeline, lpv: 'edit-video', stackId: 'stack-1');
|
||||
await ins('orig-video', AssetType.video, AssetVisibility.hidden);
|
||||
await ins('edit-video', AssetType.video, AssetVisibility.hidden);
|
||||
|
||||
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
|
||||
final ids = rows.map((r) => r.remoteId).toList();
|
||||
|
||||
expect(ids, isNot(contains('edit-video')), reason: 'hidden edit motion video must not be a timeline tile');
|
||||
expect(ids, isNot(contains('orig-video')), reason: 'hidden orig motion video must not be a timeline tile');
|
||||
expect(ids, isNot(contains('orig-still')), reason: 'non-primary stack member collapses behind the primary');
|
||||
expect(ids, ['edit-still'], reason: 'the stack shows exactly once, as its primary');
|
||||
});
|
||||
|
||||
test('local tile hidden when prior_remote_id points at a live remote', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
await insertRemote('live-remote', userId, checksum: 'cs-server', at: t);
|
||||
// re-encoded bytes: checksum no longer matches the remote, but prior does
|
||||
await insertLocal('hidden-local', at: t, checksum: 'cs-rerendered', priorRemoteId: 'live-remote');
|
||||
await insertLocal('plain-local', at: t);
|
||||
await insertSelectedAlbumWith('album-1', ['hidden-local', 'plain-local']);
|
||||
|
||||
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
|
||||
final localOnlyIds = rows.where((r) => r.remoteId == null).map((r) => r.localId).toList();
|
||||
|
||||
expect(localOnlyIds, isNot(contains('hidden-local')), reason: 'local already live on server must not get a tile');
|
||||
expect(localOnlyIds, contains('plain-local'));
|
||||
expect(rows, hasLength(2));
|
||||
|
||||
final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [userId]).get();
|
||||
final bucketTotal = buckets.fold<int>(0, (sum, b) => sum + b.assetCount);
|
||||
expect(bucketTotal, rows.length, reason: 'bucket counts must match the visible tiles');
|
||||
});
|
||||
|
||||
test('local tile hidden when the prior remote is trashed', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
await insertRemote('trashed-remote', userId, checksum: 'cs-server', at: t, deletedAt: t);
|
||||
await insertLocal('local-1', at: t, checksum: 'cs-rerendered', priorRemoteId: 'trashed-remote');
|
||||
await insertSelectedAlbumWith('album-1', ['local-1']);
|
||||
|
||||
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
|
||||
expect(rows, isEmpty, reason: 'trashing on the server must not pop the photo back onto the local timeline');
|
||||
|
||||
final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [userId]).get();
|
||||
expect(buckets, isEmpty);
|
||||
});
|
||||
|
||||
test('local tile shows again when the prior remote row is gone', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
// hard delete: sync removed the remote row entirely, only that re-opens the local
|
||||
await insertLocal('local-1', at: t, checksum: 'cs-rerendered', priorRemoteId: 'gone-remote');
|
||||
await insertSelectedAlbumWith('album-1', ['local-1']);
|
||||
|
||||
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.single.remoteId, null);
|
||||
expect(rows.single.localId, 'local-1');
|
||||
|
||||
final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [userId]).get();
|
||||
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 1);
|
||||
});
|
||||
|
||||
test('remote row falls back to prior_remote_id for local_id and local_checksum', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
await insertRemote('remote-1', userId, checksum: 'cs-server', at: t);
|
||||
await insertLocal('local-1', at: t, checksum: 'cs-on-device', priorRemoteId: 'remote-1');
|
||||
|
||||
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
|
||||
final row = rows.single;
|
||||
|
||||
expect(row.remoteId, 'remote-1');
|
||||
expect(row.localId, 'local-1');
|
||||
expect(row.localChecksum, 'cs-on-device', reason: 'local render key must be the on-device bytes');
|
||||
expect(row.checksum, 'cs-server');
|
||||
});
|
||||
|
||||
test('checksum match links local_id and local_checksum', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
await insertRemote('remote-1', userId, checksum: 'cs-same', at: t);
|
||||
await insertLocal('local-1', at: t, checksum: 'cs-same');
|
||||
|
||||
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
|
||||
final row = rows.single;
|
||||
|
||||
expect(row.remoteId, 'remote-1');
|
||||
expect(row.localId, 'local-1');
|
||||
expect(row.localChecksum, 'cs-same');
|
||||
expect(row.localChecksum, row.checksum);
|
||||
});
|
||||
|
||||
test('timeline repository maps local_checksum into RemoteAsset.localChecksum', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
await insertRemote('remote-match', userId, checksum: 'cs-same', at: t);
|
||||
await insertLocal('local-match', at: t, checksum: 'cs-same');
|
||||
await insertRemote('remote-prior', userId, checksum: 'cs-server', at: t);
|
||||
await insertLocal('local-prior', at: t, checksum: 'cs-on-device', priorRemoteId: 'remote-prior');
|
||||
|
||||
final assets = await DriftTimelineRepository(db).main([userId], GroupAssetsBy.day).assetSource(0, 100);
|
||||
final byId = {for (final a in assets.whereType<RemoteAsset>()) a.id: a};
|
||||
|
||||
expect(byId, hasLength(2));
|
||||
expect(byId['remote-match']?.localChecksum, 'cs-same');
|
||||
expect(byId['remote-prior']?.localChecksum, 'cs-on-device', reason: 'prior-linked local with re-encoded bytes');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:immich_mobile/infrastructure/repositories/partner.repository.dar
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
@@ -39,8 +38,6 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
|
||||
|
||||
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
||||
|
||||
class MockDriftStackRepository extends Mock implements DriftStackRepository {}
|
||||
|
||||
class MockStorageRepository extends Mock implements StorageRepository {}
|
||||
|
||||
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
|
||||
|
||||
@@ -135,101 +135,6 @@ void main() {
|
||||
expect(result.remainder, 2); // local2 + local3
|
||||
expect(result.processing, 1); // local3
|
||||
});
|
||||
|
||||
test('reconciled asset with live prior remote counts in total but not remainder', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
// uploaded as edit pair: prior remote is live, but no remote row matches the local checksum
|
||||
final prior = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final local = await ctx.newLocalAsset(checksum: 'edited-1', syncedChecksum: 'edited-1', priorRemoteId: prior.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
expect(result.remainder, 0);
|
||||
expect(result.processing, 0);
|
||||
});
|
||||
|
||||
test('reverted-handled asset counts in total but not remainder', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
// revert handled: local re-hashed fresh, stamped synced + prior pointing at the base remote
|
||||
final prior = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final local = await ctx.newLocalAsset(
|
||||
checksum: 'reverted-1',
|
||||
syncedChecksum: 'reverted-1',
|
||||
priorRemoteId: prior.id,
|
||||
);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
|
||||
test('reconciled asset with trashed prior remote stays out of remainder', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
// prior was trashed, not hard-deleted: the row still exists, so the revert
|
||||
// stays handled — only a missing row re-opens the asset
|
||||
final prior = await ctx.newRemoteAsset(ownerId: userId, deletedAt: DateTime(2025, 6));
|
||||
final local = await ctx.newLocalAsset(checksum: 'edited-3', syncedChecksum: 'edited-3', priorRemoteId: prior.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
|
||||
test('reconciled asset with hard-deleted prior remote counts in remainder', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
// prior remote row is gone -> needs re-upload
|
||||
final local = await ctx.newLocalAsset(checksum: 'edited-2', syncedChecksum: 'edited-2', priorRemoteId: 'gone');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
expect(result.remainder, 1);
|
||||
});
|
||||
|
||||
test('burst members inherit their representative\'s selected album in the counts', () async {
|
||||
// getAllCounts drives the UI count + the foreground loop early-exit, so it
|
||||
// must agree with getCandidates: hidden members count when the cover's
|
||||
// album is selected, even though only the cover is album-tied.
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 3);
|
||||
expect(result.remainder, 3);
|
||||
expect(result.processing, 0);
|
||||
});
|
||||
|
||||
test('burst members are not counted when their representative album is not selected', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 0);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
|
||||
test('burst members are not counted when the rep is in both a selected and an excluded album', () async {
|
||||
// exclude-wins must propagate to the hidden members: the rep is suppressed,
|
||||
// so its members must not leak into the counts either.
|
||||
final selected = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final excluded = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: rep.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: excluded.id, assetId: rep.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 0);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('getCandidates', () {
|
||||
@@ -259,58 +164,6 @@ void main() {
|
||||
expect(result.first.id, asset.id);
|
||||
});
|
||||
|
||||
test('burst member inherits candidacy from its representative in a selected album', () async {
|
||||
// iOS adds only the burst cover to a user album; the hidden members live in
|
||||
// the smart album. They must still back up when the cover's album is selected.
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
final member1 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
final member2 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
// only the representative is an album member
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result.map((a) => a.id).toSet(), {rep.id, member1.id, member2.id});
|
||||
});
|
||||
|
||||
test('burst member is NOT a candidate when its representative album is not selected', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep.id);
|
||||
|
||||
expect(await sut.getCandidates(userId), isEmpty);
|
||||
});
|
||||
|
||||
test('burstId filter returns only that burst\'s non-representative members', () async {
|
||||
// The bg re-enqueue path calls getCandidates(userId, burstId: b) to grab just
|
||||
// one burst's gated frames once its anchor lands — not the rep, not other bursts.
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final rep1 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
final m1a = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
final m1b = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
final rep2 = await ctx.newLocalAsset(burstId: 'b2', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b2', isBurstRepresentative: false);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep1.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep2.id);
|
||||
|
||||
final result = await sut.getCandidates(userId, burstId: 'b1');
|
||||
expect(result.map((a) => a.id).toSet(), {m1a.id, m1b.id});
|
||||
});
|
||||
|
||||
test('burst member is NOT a candidate when the rep is in both a selected and an excluded album', () async {
|
||||
// exclude-wins propagates: the rep is held back, so its hidden members must
|
||||
// not upload either.
|
||||
final selected = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final excluded = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: rep.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: excluded.id, assetId: rep.id);
|
||||
|
||||
expect(await sut.getCandidates(userId), isEmpty);
|
||||
});
|
||||
|
||||
test('excludes asset already backed up for the same user', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId);
|
||||
@@ -387,64 +240,5 @@ void main() {
|
||||
expect(result.length, 1);
|
||||
expect(result.first.id, asset.id);
|
||||
});
|
||||
|
||||
test('includes re-edited asset whose synced checksum is stale', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final prior = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final local = await ctx.newLocalAsset(checksum: 'edit-v2', syncedChecksum: 'edit-v1', priorRemoteId: prior.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result.length, 1);
|
||||
expect(result.first.id, local.id);
|
||||
});
|
||||
|
||||
test('excludes reconciled asset with live prior remote', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final prior = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final local = await ctx.newLocalAsset(
|
||||
checksum: 'reverted-2',
|
||||
syncedChecksum: 'reverted-2',
|
||||
priorRemoteId: prior.id,
|
||||
);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes reconciled asset whose prior remote was trashed', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final prior = await ctx.newRemoteAsset(ownerId: userId, deletedAt: DateTime(2025, 6));
|
||||
final local = await ctx.newLocalAsset(
|
||||
checksum: 'reverted-3',
|
||||
syncedChecksum: 'reverted-3',
|
||||
priorRemoteId: prior.id,
|
||||
);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('includes reconciled asset whose prior remote was hard-deleted', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final local = await ctx.newLocalAsset(checksum: 'edit-v3', syncedChecksum: 'edit-v3', priorRemoteId: 'gone');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result.length, 1);
|
||||
expect(result.first.id, local.id);
|
||||
});
|
||||
|
||||
test('includes asset with null checksum and synced checksum set when onlyHashed is false', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset(checksumOption: const Option.none(), syncedChecksum: 'old');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getCandidates(userId, onlyHashed: false);
|
||||
expect(result.length, 1);
|
||||
expect(result.first.id, asset.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
import '../repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
late DriftLocalAlbumRepository sut;
|
||||
|
||||
setUp(() {
|
||||
ctx = MediumRepositoryContext();
|
||||
sut = DriftLocalAlbumRepository(ctx.db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
group('getAssetsToHash', () {
|
||||
test('returns unhashed assets in the album', () async {
|
||||
final album = await ctx.newLocalAlbum();
|
||||
final a = await ctx.newLocalAsset(checksumOption: const Option.none());
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: a.id);
|
||||
|
||||
final result = await sut.getAssetsToHash(album.id);
|
||||
expect(result.map((e) => e.id), [a.id]);
|
||||
});
|
||||
|
||||
test('skips assets that already have a checksum', () async {
|
||||
final album = await ctx.newLocalAlbum();
|
||||
final hashed = await ctx.newLocalAsset(checksum: 'abc');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: hashed.id);
|
||||
|
||||
expect(await sut.getAssetsToHash(album.id), isEmpty);
|
||||
});
|
||||
|
||||
test('hashes burst members whose representative is in the album (members not album-tied)', () async {
|
||||
// iOS only puts the burst cover in a user album; the hidden members must
|
||||
// still be hashed so they can become backup candidates.
|
||||
final album = await ctx.newLocalAlbum();
|
||||
final rep = await ctx.newLocalAsset(
|
||||
checksumOption: const Option.none(),
|
||||
burstId: 'b1',
|
||||
isBurstRepresentative: true,
|
||||
);
|
||||
final member = await ctx.newLocalAsset(
|
||||
checksumOption: const Option.none(),
|
||||
burstId: 'b1',
|
||||
isBurstRepresentative: false,
|
||||
);
|
||||
// only the representative is an album member
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep.id);
|
||||
|
||||
final result = await sut.getAssetsToHash(album.id);
|
||||
expect(result.map((e) => e.id).toSet(), {rep.id, member.id});
|
||||
});
|
||||
|
||||
test('does not hash burst members of a representative in a different album', () async {
|
||||
final album = await ctx.newLocalAlbum();
|
||||
final otherAlbum = await ctx.newLocalAlbum();
|
||||
final rep = await ctx.newLocalAsset(
|
||||
checksumOption: const Option.none(),
|
||||
burstId: 'b1',
|
||||
isBurstRepresentative: true,
|
||||
);
|
||||
await ctx.newLocalAsset(checksumOption: const Option.none(), burstId: 'b1', isBurstRepresentative: false);
|
||||
// rep is in otherAlbum, not the album we query
|
||||
await ctx.newLocalAlbumAsset(albumId: otherAlbum.id, assetId: rep.id);
|
||||
|
||||
expect(await sut.getAssetsToHash(album.id), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -19,105 +19,6 @@ void main() {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
group('getBurstParentRemoteId', () {
|
||||
test('returns null until a member of the burst has uploaded', () async {
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1'), isNull);
|
||||
});
|
||||
|
||||
test('returns the representative prior once it has uploaded', () async {
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true, priorRemoteId: 'rep-remote');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1'), 'rep-remote');
|
||||
});
|
||||
|
||||
test('returns an uploaded member prior even when the representative flag has moved away from it', () async {
|
||||
// Moving-cover invariant: the anchor is whichever frame uploaded first, NOT
|
||||
// the current representative. Here the only uploaded frame is a non-rep
|
||||
// (the cover moved to a not-yet-uploaded frame) — it must still anchor the
|
||||
// stack so later frames don't spawn a second one.
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false, priorRemoteId: 'member-remote');
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1'), 'member-remote');
|
||||
});
|
||||
|
||||
test('ignores priors from other bursts', () async {
|
||||
await ctx.newLocalAsset(burstId: 'other', isBurstRepresentative: true, priorRemoteId: 'other-remote');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1'), isNull);
|
||||
});
|
||||
|
||||
test('falls back to the rep already-synced remote (matched by checksum) for a pre-existing burst', () async {
|
||||
// The whole burst was backed up before this feature, so no local frame ever
|
||||
// stamped a prior. The rep is on the server already (checksum match) — its
|
||||
// remote id must anchor the hidden members so they can still stack.
|
||||
final user = await ctx.newUser();
|
||||
final repRemote = await ctx.newRemoteAsset(ownerId: user.id, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false, checksum: 'mem-sum');
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1', ownerId: user.id), repRemote.id);
|
||||
});
|
||||
|
||||
test('checksum fallback only matches the rep owned by the given user', () async {
|
||||
final me = await ctx.newUser();
|
||||
final other = await ctx.newUser();
|
||||
await ctx.newRemoteAsset(ownerId: other.id, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false, checksum: 'mem-sum');
|
||||
|
||||
// rep is only on another user's server account → no anchor for me.
|
||||
expect(await sut.getBurstParentRemoteId('b1', ownerId: me.id), isNull);
|
||||
});
|
||||
|
||||
test('a stamped local prior wins over the checksum fallback', () async {
|
||||
final user = await ctx.newUser();
|
||||
await ctx.newRemoteAsset(ownerId: user.id, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(
|
||||
burstId: 'b1',
|
||||
isBurstRepresentative: false,
|
||||
checksum: 'mem-sum',
|
||||
priorRemoteId: 'member-remote',
|
||||
);
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1', ownerId: user.id), 'member-remote');
|
||||
});
|
||||
|
||||
test('without ownerId the checksum fallback is skipped', () async {
|
||||
final user = await ctx.newUser();
|
||||
await ctx.newRemoteAsset(ownerId: user.id, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true, checksum: 'rep-sum');
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('burstHasRepresentative', () {
|
||||
test('true when the group has a representative', () async {
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
|
||||
expect(await sut.burstHasRepresentative('b1'), isTrue);
|
||||
});
|
||||
|
||||
test('false for a rep-less group (Keep Everything / re-pick)', () async {
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
|
||||
expect(await sut.burstHasRepresentative('b1'), isFalse);
|
||||
});
|
||||
|
||||
test('false for an unknown burst', () async {
|
||||
expect(await sut.burstHasRepresentative('nope'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('getRemovalCandidates', () {
|
||||
final cutoffDate = DateTime(2024, 1, 1);
|
||||
final beforeCutoff = DateTime(2023, 12, 31);
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
|
||||
import '../repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
late DriftStackRepository sut;
|
||||
late String userId;
|
||||
|
||||
setUp(() async {
|
||||
ctx = MediumRepositoryContext();
|
||||
sut = DriftStackRepository(ctx.db);
|
||||
final user = await ctx.newUser();
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
group('priorState', () {
|
||||
test('live for a live remote', () async {
|
||||
await ctx.newRemoteAsset(id: 'live', ownerId: userId);
|
||||
expect(await sut.priorState('live'), PriorState.live);
|
||||
});
|
||||
|
||||
test('trashed for a trashed remote', () async {
|
||||
await ctx.newRemoteAsset(id: 'trashed', ownerId: userId, deletedAt: DateTime(2025, 6));
|
||||
expect(await sut.priorState('trashed'), PriorState.trashed);
|
||||
});
|
||||
|
||||
test('trashed for a locked remote (server refuses to stack onto it)', () async {
|
||||
await ctx.newRemoteAsset(id: 'locked', ownerId: userId, visibility: AssetVisibility.locked);
|
||||
expect(await sut.priorState('locked'), PriorState.trashed);
|
||||
});
|
||||
|
||||
test('missing for a remote that was never synced', () async {
|
||||
expect(await sut.priorState('missing'), PriorState.missing);
|
||||
});
|
||||
});
|
||||
|
||||
group('remoteByChecksum', () {
|
||||
test('returns live with the id for a live owned remote', () async {
|
||||
await ctx.newRemoteAsset(id: 'remote-1', ownerId: userId, checksum: 'base-sum');
|
||||
|
||||
final dup = await sut.remoteByChecksum('base-sum', userId);
|
||||
|
||||
expect(dup.state, PriorState.live);
|
||||
expect(dup.remoteId, 'remote-1');
|
||||
});
|
||||
|
||||
test('returns trashed with the id for a trashed owned remote', () async {
|
||||
await ctx.newRemoteAsset(id: 'remote-1', ownerId: userId, checksum: 'base-sum', deletedAt: DateTime(2025, 6));
|
||||
|
||||
final dup = await sut.remoteByChecksum('base-sum', userId);
|
||||
|
||||
expect(dup.state, PriorState.trashed);
|
||||
expect(dup.remoteId, 'remote-1');
|
||||
});
|
||||
|
||||
test('returns trashed with the id for a locked owned remote', () async {
|
||||
await ctx.newRemoteAsset(
|
||||
id: 'remote-1',
|
||||
ownerId: userId,
|
||||
checksum: 'base-sum',
|
||||
visibility: AssetVisibility.locked,
|
||||
);
|
||||
|
||||
final dup = await sut.remoteByChecksum('base-sum', userId);
|
||||
|
||||
expect(dup.state, PriorState.trashed);
|
||||
expect(dup.remoteId, 'remote-1');
|
||||
});
|
||||
|
||||
test('returns missing when no remote has the bytes', () async {
|
||||
await ctx.newRemoteAsset(id: 'remote-1', ownerId: userId, checksum: 'other-sum');
|
||||
|
||||
final dup = await sut.remoteByChecksum('base-sum', userId);
|
||||
|
||||
expect(dup.state, PriorState.missing);
|
||||
expect(dup.remoteId, isNull);
|
||||
});
|
||||
|
||||
test("ignores another user's remote with the same bytes (owner-scoped)", () async {
|
||||
final other = await ctx.newUser();
|
||||
await ctx.newRemoteAsset(id: 'theirs', ownerId: other.id, checksum: 'base-sum');
|
||||
|
||||
final dup = await sut.remoteByChecksum('base-sum', userId);
|
||||
|
||||
expect(dup.state, PriorState.missing);
|
||||
expect(dup.remoteId, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('findStackIdByRemoteId', () {
|
||||
test('returns the stack id for a stacked remote', () async {
|
||||
final base = await ctx.newRemoteAsset(id: 'base', ownerId: userId);
|
||||
final stack = await ctx.newStack(ownerId: userId, primaryAssetId: base.id);
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: stack.id);
|
||||
expect(await sut.findStackIdByRemoteId('edit'), stack.id);
|
||||
});
|
||||
|
||||
test('returns null for an unstacked remote', () async {
|
||||
await ctx.newRemoteAsset(id: 'lonely', ownerId: userId);
|
||||
expect(await sut.findStackIdByRemoteId('lonely'), isNull);
|
||||
});
|
||||
|
||||
test('returns null for a trashed remote', () async {
|
||||
final base = await ctx.newRemoteAsset(id: 'base', ownerId: userId);
|
||||
final stack = await ctx.newStack(ownerId: userId, primaryAssetId: base.id);
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: stack.id, deletedAt: DateTime(2025, 6));
|
||||
expect(await sut.findStackIdByRemoteId('edit'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('findStackBaseId', () {
|
||||
test('returns the earliest-uploaded member that is not the excluded one', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025));
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
|
||||
|
||||
// base uploaded before the edit → it's the flip target.
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), 'base');
|
||||
});
|
||||
|
||||
test('returns null when the only member is excluded', () async {
|
||||
final base = await ctx.newRemoteAsset(id: 'solo', ownerId: userId, stackId: 'stack-1');
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: base.id);
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'solo'), isNull);
|
||||
});
|
||||
|
||||
test('skips trashed members', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(
|
||||
id: 'base',
|
||||
ownerId: userId,
|
||||
stackId: 'stack-1',
|
||||
uploadedAt: DateTime(2025),
|
||||
deletedAt: DateTime(2025, 6),
|
||||
);
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), isNull);
|
||||
});
|
||||
|
||||
test('live shape: unstacked hidden motion videos can never win', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit-still');
|
||||
await ctx.newRemoteAsset(id: 'base-still', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025));
|
||||
await ctx.newRemoteAsset(id: 'edit-still', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
|
||||
// The live pair's motion videos: hidden, no stack, uploaded before everything.
|
||||
await ctx.newRemoteAsset(
|
||||
id: 'base-video',
|
||||
ownerId: userId,
|
||||
type: AssetType.video,
|
||||
visibility: AssetVisibility.hidden,
|
||||
uploadedAt: DateTime(2024),
|
||||
);
|
||||
await ctx.newRemoteAsset(
|
||||
id: 'edit-video',
|
||||
ownerId: userId,
|
||||
type: AssetType.video,
|
||||
visibility: AssetVisibility.hidden,
|
||||
uploadedAt: DateTime(2024, 2),
|
||||
);
|
||||
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit-still'), 'base-still');
|
||||
});
|
||||
|
||||
test('oldest member wins even when it is a dedup-reused edit (known limit)', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'prior-edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
|
||||
// An edit re-used by server dedup, uploaded before the base ever was.
|
||||
await ctx.newRemoteAsset(id: 'dedup-edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025));
|
||||
await ctx.newRemoteAsset(id: 'prior-edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 3));
|
||||
|
||||
// Heuristic is oldest uploaded_at, so the reused edit beats the real base.
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'prior-edit'), 'dedup-edit');
|
||||
});
|
||||
|
||||
test('a member with NULL uploaded_at sorts last', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'unsynced', ownerId: userId, stackId: 'stack-1');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 3));
|
||||
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), 'base');
|
||||
});
|
||||
});
|
||||
|
||||
group('findRevertReconcileTargets', () {
|
||||
test('finds a local that hashed back to a non-primary stack member', () async {
|
||||
// Stack: primary = edit, also holds base. The local's checksum matches base.
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
|
||||
|
||||
final targets = await sut.findRevertReconcileTargets();
|
||||
|
||||
expect(targets, hasLength(1));
|
||||
expect(targets.first.stackId, 'stack-1');
|
||||
expect(targets.first.newPrimaryId, 'base');
|
||||
expect(targets.first.localAssetId, 'local-1');
|
||||
expect(targets.first.localAssetChecksum, 'base-sum');
|
||||
});
|
||||
|
||||
test('stops matching once the primary flips to the matched member', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(), hasLength(1));
|
||||
|
||||
await sut.setPrimary('stack-1', 'base');
|
||||
expect(await sut.findRevertReconcileTargets(), isEmpty);
|
||||
});
|
||||
|
||||
test('returns nothing when the local already matches the primary', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'edit-sum', priorRemoteId: 'edit');
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(), isEmpty);
|
||||
});
|
||||
|
||||
test('ignores a local whose prior remote was trashed', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(
|
||||
id: 'edit',
|
||||
ownerId: userId,
|
||||
stackId: 'stack-1',
|
||||
checksum: 'edit-sum',
|
||||
deletedAt: DateTime(2025, 6),
|
||||
);
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(), isEmpty);
|
||||
});
|
||||
|
||||
test('ignores a local whose prior is not in any stack', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
await ctx.newRemoteAsset(id: 'unstacked', ownerId: userId, checksum: 'other-sum');
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'unstacked');
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(), isEmpty);
|
||||
});
|
||||
|
||||
test('leaves a manual stack of two backed-up locals alone (no ping-pong)', () async {
|
||||
// The user stacked two ordinary photos by hand. Each local is steady-state:
|
||||
// synced == checksum and prior = its own member, so neither side may ever
|
||||
// flip the primary back and forth.
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'remote-a');
|
||||
await ctx.newRemoteAsset(id: 'remote-a', ownerId: userId, stackId: 'stack-1', checksum: 'a-sum');
|
||||
await ctx.newRemoteAsset(id: 'remote-b', ownerId: userId, stackId: 'stack-1', checksum: 'b-sum');
|
||||
await ctx.newLocalAsset(id: 'local-a', checksum: 'a-sum', syncedChecksum: 'a-sum', priorRemoteId: 'remote-a');
|
||||
await ctx.newLocalAsset(id: 'local-b', checksum: 'b-sum', syncedChecksum: 'b-sum', priorRemoteId: 'remote-b');
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(), isEmpty);
|
||||
});
|
||||
|
||||
test('finds a true revert: prior is the edit, checksum hashed back to the base', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
// Unreconciled: the chain last synced the edit bytes, the local now holds the base bytes.
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', syncedChecksum: 'edit-sum', priorRemoteId: 'edit');
|
||||
|
||||
final targets = await sut.findRevertReconcileTargets();
|
||||
|
||||
expect(targets, hasLength(1));
|
||||
expect(targets.first.stackId, 'stack-1');
|
||||
expect(targets.first.newPrimaryId, 'base');
|
||||
expect(targets.first.localAssetId, 'local-1');
|
||||
expect(targets.first.localAssetChecksum, 'base-sum');
|
||||
});
|
||||
|
||||
test('stops matching once the flip writes synced = checksum', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', syncedChecksum: 'edit-sum', priorRemoteId: 'edit');
|
||||
|
||||
final targets = await sut.findRevertReconcileTargets();
|
||||
expect(targets, hasLength(1));
|
||||
|
||||
// The reconcile flip rolls the stamps forward; that's what makes it self-limiting.
|
||||
final target = targets.first;
|
||||
await DriftLocalAssetRepository(
|
||||
ctx.db,
|
||||
).markSynced(target.localAssetId, priorRemoteId: target.newPrimaryId, syncedChecksum: target.localAssetChecksum);
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
|
||||
@@ -50,126 +48,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('burst display', () {
|
||||
test('local-only burst shows only the representative tile', () async {
|
||||
final user = await ctx.newUser();
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
final member1 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
final member2 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
for (final a in [rep, member1, member2]) {
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: a.id);
|
||||
}
|
||||
|
||||
final query = sut.main([user.id], .day);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 1);
|
||||
|
||||
final assets = await query.assetSource(0, 10);
|
||||
expect(assets, hasLength(1));
|
||||
expect(assets.single.localId, rep.id);
|
||||
});
|
||||
|
||||
test('remote burst members are hidden by the local flag even before the stack syncs', () async {
|
||||
// Both remotes have stack_id NULL (the transient window before the server's
|
||||
// StackUpdate reaches the client). The normal stack rule would show both;
|
||||
// clause B hides the non-rep by its local flag, while stack_id IS NULL keeps the rep.
|
||||
final user = await ctx.newUser();
|
||||
final repRemote = await ctx.newRemoteAsset(ownerId: user.id, checksum: 'c-rep');
|
||||
await ctx.newRemoteAsset(ownerId: user.id, checksum: 'c-member');
|
||||
await ctx.newLocalAsset(checksum: 'c-rep', burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(checksum: 'c-member', burstId: 'b1', isBurstRepresentative: false);
|
||||
|
||||
final query = sut.main([user.id], .day);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 1);
|
||||
|
||||
final assets = await query.assetSource(0, 10);
|
||||
expect(assets, hasLength(1));
|
||||
expect((assets.single as RemoteAsset).id, repRemote.id);
|
||||
});
|
||||
|
||||
test('a synced burst stack still shows its primary after a Photos re-pick moves the local rep flag', () async {
|
||||
// Regression: once the stack synced (stack_id set, primary = old rep) the
|
||||
// user re-picks the cover in Photos, so the old rep's local row flips to
|
||||
// is_burst_representative = 0. Clause B must NOT hide the synced primary —
|
||||
// the stack would vanish from the grid. Scoping clause B to stack_id IS NULL
|
||||
// hands the synced case back to the primary rule.
|
||||
final user = await ctx.newUser();
|
||||
final primary = await ctx.newRemoteAsset(ownerId: user.id, checksum: 'c-rep');
|
||||
final member = await ctx.newRemoteAsset(ownerId: user.id, checksum: 'c-member');
|
||||
final stack = await ctx.newStack(ownerId: user.id, primaryAssetId: primary.id);
|
||||
await (ctx.db.update(
|
||||
ctx.db.remoteAssetEntity,
|
||||
)..where((t) => t.id.isIn([primary.id, member.id]))).write(RemoteAssetEntityCompanion(stackId: Value(stack.id)));
|
||||
// local rep flag has moved off the old rep (now a non-rep frame locally)
|
||||
await ctx.newLocalAsset(checksum: 'c-rep', burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAsset(checksum: 'c-member', burstId: 'b1', isBurstRepresentative: false);
|
||||
|
||||
final query = sut.main([user.id], .day);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 1);
|
||||
|
||||
final assets = await query.assetSource(0, 10);
|
||||
expect(assets, hasLength(1));
|
||||
expect((assets.single as RemoteAsset).id, primary.id);
|
||||
});
|
||||
|
||||
test('a rep-less local burst group shows every frame as an individual (no vanish)', () async {
|
||||
// After "Keep Everything"/re-pick a burst group can end up with zero
|
||||
// is_burst_representative=1 frames. Those frames must NOT all hide — show
|
||||
// each as its own tile so nothing vanishes from the grid.
|
||||
final user = await ctx.newUser();
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final f1 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
final f2 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
final f3 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
for (final a in [f1, f2, f3]) {
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: a.id);
|
||||
}
|
||||
|
||||
final query = sut.main([user.id], .day);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 3);
|
||||
|
||||
final assets = await query.assetSource(0, 10);
|
||||
expect(assets.map((a) => a.localId).toSet(), {f1.id, f2.id, f3.id});
|
||||
});
|
||||
|
||||
test(
|
||||
'a synced stack does not duplicate when the rep flag moves onto a non-primary member (Keep Everything)',
|
||||
() async {
|
||||
// After "Keep Everything", iOS moves representsBurst onto a different frame.
|
||||
// If that frame is a non-primary member of an already-synced stack, the grid
|
||||
// must still show only the stack primary — not also surface the moved-rep
|
||||
// member as a second tile (the clause-A duplicate this removed).
|
||||
final user = await ctx.newUser();
|
||||
final primary = await ctx.newRemoteAsset(ownerId: user.id, checksum: 'c-primary');
|
||||
final member = await ctx.newRemoteAsset(ownerId: user.id, checksum: 'c-member');
|
||||
final stack = await ctx.newStack(ownerId: user.id, primaryAssetId: primary.id);
|
||||
await (ctx.db.update(ctx.db.remoteAssetEntity)..where((t) => t.id.isIn([primary.id, member.id]))).write(
|
||||
RemoteAssetEntityCompanion(stackId: Value(stack.id)),
|
||||
);
|
||||
// the local rep flag now sits on the member frame (matches the non-primary remote)
|
||||
await ctx.newLocalAsset(checksum: 'c-primary', burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAsset(checksum: 'c-member', burstId: 'b1', isBurstRepresentative: true);
|
||||
|
||||
final query = sut.main([user.id], .day);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 1);
|
||||
|
||||
final assets = await query.assetSource(0, 10);
|
||||
expect(assets, hasLength(1));
|
||||
expect((assets.single as RemoteAsset).id, primary.id);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('person assets', () {
|
||||
test('does not duplicate an asset that has multiple face records for the same person', () async {
|
||||
// Regression check for #26723: an INNER JOIN between remote_asset_entity and asset_face_entity
|
||||
|
||||
@@ -18,7 +18,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
@@ -103,7 +102,6 @@ class MediumRepositoryContext {
|
||||
String? stackId,
|
||||
String? thumbHash,
|
||||
String? libraryId,
|
||||
DateTime? uploadedAt,
|
||||
}) async {
|
||||
id ??= TestUtils.uuid();
|
||||
createdAt ??= TestUtils.date();
|
||||
@@ -130,19 +128,6 @@ class MediumRepositoryContext {
|
||||
localDateTime: .new(createdAt.toLocal()),
|
||||
thumbHash: .new(TestUtils.uuid(thumbHash)),
|
||||
libraryId: .new(TestUtils.uuid(libraryId)),
|
||||
uploadedAt: .new(uploadedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<StackEntityData> newStack({String? id, String? ownerId, required String primaryAssetId}) {
|
||||
return db
|
||||
.into(db.stackEntity)
|
||||
.insertReturning(
|
||||
StackEntityCompanion(
|
||||
id: .new(TestUtils.uuid(id)),
|
||||
ownerId: .new(TestUtils.uuid(ownerId)),
|
||||
primaryAssetId: .new(primaryAssetId),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -276,10 +261,6 @@ class MediumRepositoryContext {
|
||||
int? durationMs,
|
||||
int? orientation,
|
||||
DateTime? updatedAt,
|
||||
String? priorRemoteId,
|
||||
String? syncedChecksum,
|
||||
String? burstId,
|
||||
bool? isBurstRepresentative,
|
||||
}) async {
|
||||
id ??= TestUtils.uuid();
|
||||
return db
|
||||
@@ -301,10 +282,6 @@ class MediumRepositoryContext {
|
||||
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
|
||||
latitude: .new(latitude ?? TestUtils.randDouble(-90, 90)),
|
||||
longitude: .new(longitude ?? TestUtils.randDouble(-180, 180)),
|
||||
priorRemoteId: .new(priorRemoteId),
|
||||
syncedChecksum: .new(syncedChecksum),
|
||||
burstId: .new(burstId),
|
||||
isBurstRepresentative: .new(isBurstRepresentative ?? false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
|
||||
import '../../../unit/presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await SettingsRepository.ensureInitialized(db);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
LocalAsset makeLocalAsset(String id, {String? checksum}) => LocalAsset(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
checksum: checksum,
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025),
|
||||
updatedAt: DateTime(2025),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
RemoteAsset makeMergedRemoteAsset({String? localChecksum}) => RemoteAsset(
|
||||
id: 'R',
|
||||
localId: 'X',
|
||||
name: 'x.jpg',
|
||||
ownerId: 'owner',
|
||||
checksum: 'server',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025),
|
||||
updatedAt: DateTime(2025),
|
||||
isEdited: false,
|
||||
localChecksum: localChecksum,
|
||||
);
|
||||
|
||||
group('local image provider cache keys include checksum', () {
|
||||
test('LocalThumbProvider with a different checksum is a different key', () {
|
||||
final c1 = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1');
|
||||
final c2 = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c2');
|
||||
final c1Again = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1');
|
||||
|
||||
// an on-device edit keeps the id but re-hashes to a new checksum → must miss the cache
|
||||
expect(c1 == c2, isFalse);
|
||||
expect(c1.hashCode == c2.hashCode, isFalse);
|
||||
// same id + same checksum → same key (cache hit)
|
||||
expect(c1 == c1Again, isTrue);
|
||||
expect(c1.hashCode, c1Again.hashCode);
|
||||
});
|
||||
|
||||
test('LocalFullImageProvider with a different checksum is a different key', () {
|
||||
final c1 = LocalFullImageProvider(
|
||||
id: 'L',
|
||||
assetType: AssetType.image,
|
||||
size: const Size(100, 100),
|
||||
isAnimated: false,
|
||||
checksum: 'c1',
|
||||
);
|
||||
final c2 = LocalFullImageProvider(
|
||||
id: 'L',
|
||||
assetType: AssetType.image,
|
||||
size: const Size(100, 100),
|
||||
isAnimated: false,
|
||||
checksum: 'c2',
|
||||
);
|
||||
|
||||
expect(c1 == c2, isFalse);
|
||||
expect(c1.hashCode == c2.hashCode, isFalse);
|
||||
});
|
||||
|
||||
test('LocalThumbProvider null vs non-null checksum is a different key', () {
|
||||
final unhashed = LocalThumbProvider(id: 'L', assetType: AssetType.image);
|
||||
final hashed = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1');
|
||||
|
||||
// a rehash takes the checksum null → 'c1'; the stale render must not be reused
|
||||
expect(unhashed == hashed, isFalse);
|
||||
expect(hashed == unhashed, isFalse);
|
||||
});
|
||||
|
||||
test('LocalFullImageProvider null vs non-null checksum is a different key', () {
|
||||
final unhashed = LocalFullImageProvider(
|
||||
id: 'L',
|
||||
assetType: AssetType.image,
|
||||
size: const Size(100, 100),
|
||||
isAnimated: false,
|
||||
);
|
||||
final hashed = LocalFullImageProvider(
|
||||
id: 'L',
|
||||
assetType: AssetType.image,
|
||||
size: const Size(100, 100),
|
||||
isAnimated: false,
|
||||
checksum: 'c1',
|
||||
);
|
||||
|
||||
expect(unhashed == hashed, isFalse);
|
||||
expect(hashed == unhashed, isFalse);
|
||||
});
|
||||
|
||||
test('LocalThumbProvider equality ignores size', () {
|
||||
final small = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1', size: const Size(50, 50));
|
||||
final big = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1', size: const Size(200, 200));
|
||||
|
||||
// viewer fast-path: any cached thumb render is reusable regardless of requested size
|
||||
expect(small == big, isTrue);
|
||||
expect(small.hashCode, big.hashCode);
|
||||
});
|
||||
|
||||
test('LocalFullImageProvider with same id, size, isAnimated and checksum is equal', () {
|
||||
final a = LocalFullImageProvider(
|
||||
id: 'L',
|
||||
assetType: AssetType.image,
|
||||
size: const Size(100, 100),
|
||||
isAnimated: false,
|
||||
checksum: 'c1',
|
||||
);
|
||||
final b = LocalFullImageProvider(
|
||||
id: 'L',
|
||||
assetType: AssetType.image,
|
||||
size: const Size(100, 100),
|
||||
isAnimated: false,
|
||||
checksum: 'c1',
|
||||
);
|
||||
|
||||
expect(a == b, isTrue);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
});
|
||||
});
|
||||
|
||||
group('factory checksum plumbing', () {
|
||||
test('getThumbnailImageProvider carries the local asset checksum', () {
|
||||
final provider = getThumbnailImageProvider(makeLocalAsset('X', checksum: 'c1'));
|
||||
|
||||
expect(provider, isA<LocalThumbProvider>());
|
||||
expect((provider as LocalThumbProvider).checksum, 'c1');
|
||||
});
|
||||
|
||||
test('getFullImageProvider carries the local asset checksum', () {
|
||||
final provider = getFullImageProvider(makeLocalAsset('X', checksum: 'c1'));
|
||||
|
||||
expect(provider, isA<LocalFullImageProvider>());
|
||||
expect((provider as LocalFullImageProvider).checksum, 'c1');
|
||||
});
|
||||
|
||||
test('same id with a different checksum produces unequal providers', () {
|
||||
final thumb1 = getThumbnailImageProvider(makeLocalAsset('X', checksum: 'c1'));
|
||||
final thumb2 = getThumbnailImageProvider(makeLocalAsset('X', checksum: 'c2'));
|
||||
final full1 = getFullImageProvider(makeLocalAsset('X', checksum: 'c1'));
|
||||
final full2 = getFullImageProvider(makeLocalAsset('X', checksum: 'c2'));
|
||||
|
||||
expect(thumb1 == thumb2, isFalse);
|
||||
expect(full1 == full2, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('RemoteAsset localChecksum preference', () {
|
||||
test('merged remote keys local renders by localChecksum when set', () {
|
||||
final asset = makeMergedRemoteAsset(localChecksum: 'device');
|
||||
|
||||
// localId set → hasLocal, so the factory takes the local path
|
||||
expect(asset.hasLocal, isTrue);
|
||||
|
||||
final thumb = getThumbnailImageProvider(asset);
|
||||
expect(thumb, isA<LocalThumbProvider>());
|
||||
expect((thumb as LocalThumbProvider).checksum, 'device');
|
||||
|
||||
final full = getFullImageProvider(asset);
|
||||
expect(full, isA<LocalFullImageProvider>());
|
||||
expect((full as LocalFullImageProvider).checksum, 'device');
|
||||
});
|
||||
|
||||
test('merged remote without a localChecksum renders the remote, not the local bytes', () async {
|
||||
// A prior-linked local that hasn't rehashed yet has no trustworthy cache
|
||||
// key — its bytes may differ from the server checksum.
|
||||
await PresentationContext.create();
|
||||
final asset = makeMergedRemoteAsset();
|
||||
|
||||
final thumb = getThumbnailImageProvider(asset);
|
||||
expect(thumb, isNot(isA<LocalThumbProvider>()));
|
||||
|
||||
final full = getFullImageProvider(asset);
|
||||
expect(full, isNot(isA<LocalFullImageProvider>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user