Merge branch 'main' into improve_focus

This commit is contained in:
Min Idzelis 2025-04-23 20:10:45 -04:00 committed by GitHub
commit 6e06f9f5aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 1344 additions and 2187 deletions

View File

@ -224,7 +224,7 @@ jobs:
BUILD_SOURCE_COMMIT=${{ github.sha }}
- name: Export digest
run: |
run: | # zizmor: ignore[template-injection]
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
@ -426,7 +426,7 @@ jobs:
BUILD_SOURCE_COMMIT=${{ github.sha }}
- name: Export digest
run: |
run: | # zizmor: ignore[template-injection]
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
@ -535,6 +535,7 @@ jobs:
run: exit 1
- name: All jobs passed or skipped
if: ${{ !(contains(needs.*.result, 'failure')) }}
# zizmor: ignore[template-injection]
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}"
success-check-ml:
@ -549,4 +550,5 @@ jobs:
run: exit 1
- name: All jobs passed or skipped
if: ${{ !(contains(needs.*.result, 'failure')) }}
# zizmor: ignore[template-injection]
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}"

View File

@ -1,6 +1,6 @@
name: Docs deploy
on:
workflow_run:
workflow_run: # zizmor: ignore[dangerous-triggers] no attacker inputs are used here
workflows: ['Docs build']
types:
- completed
@ -115,22 +115,22 @@ jobs:
- name: Load parameters
id: parameters
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env:
PARAM_JSON: ${{ needs.checks.outputs.parameters }}
with:
script: |
const json = `${{ needs.checks.outputs.parameters }}`;
const parameters = JSON.parse(json);
const parameters = JSON.parse(process.env.PARAM_JSON);
core.setOutput("event", parameters.event);
core.setOutput("name", parameters.name);
core.setOutput("shouldDeploy", parameters.shouldDeploy);
- run: |
echo "Starting docs deployment for ${{ steps.parameters.outputs.event }} ${{ steps.parameters.outputs.name }}"
- name: Download artifact
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env:
ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }}
with:
script: |
let artifact = ${{ needs.checks.outputs.artifact }};
let artifact = JSON.parse(process.env.ARTIFACT_JSON);
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,

View File

@ -1,6 +1,6 @@
name: Docs destroy
on:
pull_request_target:
pull_request_target: # zizmor: ignore[dangerous-triggers] no attacker inputs are used here
types: [closed]
permissions: {}

View File

@ -1,7 +1,7 @@
name: PR Label Validation
on:
pull_request_target:
pull_request_target: # zizmor: ignore[dangerous-triggers] no attacker inputs are used here
types: [opened, labeled, unlabeled, synchronize]
permissions: {}

View File

@ -1,6 +1,6 @@
name: 'Pull Request Labeler'
on:
- pull_request_target
- pull_request_target # zizmor: ignore[dangerous-triggers] no attacker inputs are used here
permissions: {}

View File

@ -47,7 +47,10 @@ jobs:
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Bump version
run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"
env:
SERVER_BUMP: ${{ inputs.serverBump }}
MOBILE_BUMP: ${{ inputs.mobileBump }}
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
- name: Commit and tag
id: push-tag
@ -61,6 +64,8 @@ jobs:
build_mobile:
uses: ./.github/workflows/build-mobile.yml
needs: bump_version
permissions:
contents: read
secrets:
KEY_JKS: ${{ secrets.KEY_JKS }}
ALIAS: ${{ secrets.ALIAS }}

View File

@ -95,3 +95,30 @@ jobs:
- name: Run dart custom_lint
run: dart run custom_lint
working-directory: ./mobile
zizmor:
name: zizmor
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v5
- name: Run zizmor 🌈
run: uvx zizmor --format=sarif . > results.sarif
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
category: zizmor

View File

@ -57,4 +57,5 @@ jobs:
run: exit 1
- name: All jobs passed or skipped
if: ${{ !(contains(needs.*.result, 'failure')) }}
# zizmor: ignore[template-injection]
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}"

6
cli/package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.61",
"version": "2.2.63",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.61",
"version": "2.2.63",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"chokidar": "^4.0.3",
@ -54,7 +54,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.131.3",
"version": "1.132.1",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.61",
"version": "2.2.63",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@ -1,4 +1,12 @@
[
{
"label": "v1.132.1",
"url": "https://v1.132.1.archive.immich.app"
},
{
"label": "v1.132.0",
"url": "https://v1.132.0.archive.immich.app"
},
{
"label": "v1.131.3",
"url": "https://v1.131.3.archive.immich.app"

8
e2e/package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.131.3",
"version": "1.132.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.131.3",
"version": "1.132.1",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@ -44,7 +44,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.61",
"version": "2.2.63",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@ -93,7 +93,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.131.3",
"version": "1.132.1",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.131.3",
"version": "1.132.1",
"description": "",
"main": "index.js",
"type": "module",

View File

@ -39,11 +39,11 @@
"authentication_settings_disable_all": "¿Estás seguro de que deseas desactivar todos los métodos de inicio de sesión? Esto desactivará por completo el inicio de sesión.",
"authentication_settings_reenable": "Para reactivarlo, utiliza un <link>Comando del servidor</link>.",
"background_task_job": "Tareas en segundo plano",
"backup_database": "Respaldar base de datos",
"backup_database_enable_description": "Activar respaldo de base de datos",
"backup_keep_last_amount": "Cantidad de respaldos previos a mantener",
"backup_settings": "Ajustes de respaldo",
"backup_settings_description": "Administrar configuración de respaldo de base de datos",
"backup_database": "Crear volcado de base de datos",
"backup_database_enable_description": "Activar volcado de base de datos",
"backup_keep_last_amount": "Cantidad de volcados previos a mantener",
"backup_settings": "Ajustes de volcado de base de datos",
"backup_settings_description": "Administrar configuración de volcado de base de datos. Nota: estas tareas no están monitorizadas y no se notificarán los fallos.",
"check_all": "Verificar todo",
"cleanup": "Limpieza",
"cleared_jobs": "Trabajos borrados para: {job}",
@ -91,9 +91,9 @@
"image_thumbnail_quality_description": "Calidad de miniatura de 1 a 100. Es mejor cuanto más alto es el valor pero genera archivos más grandes y puede reducir la capacidad de respuesta de la aplicación.",
"image_thumbnail_title": "Ajustes de las miniaturas",
"job_concurrency": "{job}: Procesos simultáneos",
"job_created": "Trabajo creado",
"job_created": "Tarea creada",
"job_not_concurrency_safe": "Esta tarea no es segura para la simultaneidad.",
"job_settings": "Configuración tareas",
"job_settings": "Configuración de tareas",
"job_settings_description": "Administrar tareas simultáneas",
"job_status": "Estado de la tarea",
"jobs_delayed": "{jobCount, plural, one {# retrasado} other {# retrasados}}",
@ -169,7 +169,7 @@
"migration_job_description": "Migrar miniaturas de archivos y caras a la estructura de carpetas más reciente",
"no_paths_added": "No se han añadido carpetas",
"no_pattern_added": "No se han añadido patrones",
"note_apply_storage_label_previous_assets": "Nota: para aplicar una Etiqueta de Almacenamient a un elemento anteriormente cargado, lanza el",
"note_apply_storage_label_previous_assets": "Nota: para aplicar una Etiqueta de Almacenamiento a un elemento anteriormente cargado, lanza el",
"note_cannot_be_changed_later": "NOTA: ¡No se puede cambiar posteriormente!",
"notification_email_from_address": "Desde",
"notification_email_from_address_description": "Dirección de correo electrónico del remitente, por ejemplo: \"Immich Photo Server <noreply@example.com>\"",
@ -252,12 +252,12 @@
"storage_template_migration": "Migración de plantillas de almacenamiento",
"storage_template_migration_description": "Aplicar la <link>{template}</link> actual a los elementos subidos previamente",
"storage_template_migration_info": "La plantilla de almacenamiento convertirá todas las extensiones a minúscula. Los cambios en las plantillas solo se aplican a los elementos nuevos. Para aplicarlos retroactivamente a los elementos subidos previamente ejecute la <link>{job}</link>.",
"storage_template_migration_job": "Migración de la plantilla de almacenamiento",
"storage_template_migration_job": "Tarea de migración de la plantilla de almacenamiento",
"storage_template_more_details": "Para obtener más detalles sobre esta función, consulte la <template-link>Plantilla de almacenamiento</template-link> y sus <implications-link>implicaciones</implications-link>",
"storage_template_onboarding_description": "Cuando está habilitada, esta función organizará automáticamente los archivos según una plantilla definida por el usuario. Debido a problemas de estabilidad, la función se ha desactivado de forma predeterminada. Para obtener más información, consulte la <link>documentación</link>.",
"storage_template_path_length": "Límite aproximado de la longitud de la ruta: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "Plantilla de almacenamiento",
"storage_template_settings_description": "Administre la estructura de carpetas y el nombre de archivo del recurso cargado",
"storage_template_settings_description": "Administrar la estructura de carpetas y el nombre de archivo del recurso cargado",
"storage_template_user_label": "<code>{label}</code> es la etiqueta de almacenamiento del usuario",
"system_settings": "Ajustes del Sistema",
"tag_cleanup_job": "Limpieza de etiquetas",
@ -345,7 +345,7 @@
"trash_settings": "Configuración papelera",
"trash_settings_description": "Administrar la configuración de la papelera",
"untracked_files": "Archivos sin seguimiento",
"untracked_files_description": "La aplicación no rastrea estos archivos. Puede ser el resultado de movimientos fallidos, cargas interrumpidas o sin procesar debido a un error",
"untracked_files_description": "La aplicación no rastrea estos archivos. Puede ser el resultado de movimientos fallidos, subidas interrumpidas o sin procesar debido a un error",
"user_cleanup_job": "Limpieza de usuarios",
"user_delete_delay": "La cuenta <b>{user}</b> y los archivos se programarán para su eliminación permanente en {delay, plural, one {# día} other {# días}}.",
"user_delete_delay_settings": "Eliminar retardo",
@ -429,7 +429,7 @@
"allow_dark_mode": "Permitir modo oscuro",
"allow_edits": "Permitir edición",
"allow_public_user_to_download": "Permitir descargar al usuario público",
"allow_public_user_to_upload": "Permitir cargar al usuario publico",
"allow_public_user_to_upload": "Permitir subir al usuario publico",
"alt_text_qr_code": "Código QR",
"anti_clockwise": "En sentido antihorario",
"api_key": "Clave API",
@ -473,7 +473,7 @@
"asset_skipped": "Omitido",
"asset_skipped_in_trash": "En la papelera",
"asset_uploaded": "Subido",
"asset_uploading": "Cargando…",
"asset_uploading": "Subiendo…",
"asset_viewer_settings_subtitle": "Administra las configuracioens de tu visor de fotos",
"asset_viewer_settings_title": "Visor de Archivos",
"assets": "elementos",
@ -482,7 +482,7 @@
"assets_added_to_name_count": "Añadido {count, plural, one {# asset} other {# assets}} a {hasName, select, true {<b>{name}</b>} other {new album}}",
"assets_count": "{count, plural, one {# activo} other {# activos}}",
"assets_deleted_permanently": "{} elementos(s) eliminado(s) permanentemente",
"assets_deleted_permanently_from_server": "{} recurso(s) eliminados de forma permanente del servidor de Immich",
"assets_deleted_permanently_from_server": "{} recurso(s) eliminado(s) de forma permanente del servidor de Immich",
"assets_moved_to_trash_count": "{count, plural, one {# elemento movido} other {# elementos movidos}} a la papelera",
"assets_permanently_deleted_count": "Eliminado permanentemente {count, plural, one {# elemento} other {# elementos}}",
"assets_removed_count": "Eliminado {count, plural, one {# elemento} other {# elementos}}",
@ -492,7 +492,7 @@
"assets_restored_successfully": "{} elemento(s) restaurado(s) exitosamente",
"assets_trashed": "{} elemento(s) eliminado(s)",
"assets_trashed_count": "Borrado {count, plural, one {# elemento} other {# elementos}}",
"assets_trashed_from_server": "{} recurso(s) enviados a la papelera desde el servidor de Immich",
"assets_trashed_from_server": "{} recurso(s) enviado(s) a la papelera desde el servidor de Immich",
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} ya forma parte del álbum",
"authorized_devices": "Dispositivos Autorizados",
"automatic_endpoint_switching_subtitle": "Conectarse localmente a través de la Wi-Fi designada cuando esté disponible y usar conexiones alternativas en otros lugares",
@ -510,11 +510,11 @@
"backup_all": "Todos",
"backup_background_service_backup_failed_message": "Error al copiar elementos. Reintentando…",
"backup_background_service_connection_failed_message": "Error al conectar con el servidor. Reintentando…",
"backup_background_service_current_upload_notification": "Cargando {}",
"backup_background_service_current_upload_notification": "Subiendo {}",
"backup_background_service_default_notification": "Comprobando nuevos elementos…",
"backup_background_service_error_title": "Error de copia de seguridad",
"backup_background_service_in_progress_notification": "Creando copia de seguridad de tus elementos…",
"backup_background_service_upload_failure_notification": "Error al cargar {}",
"backup_background_service_upload_failure_notification": "Error al subir {}",
"backup_controller_page_albums": "Álbumes de copia de seguridad",
"backup_controller_page_background_app_refresh_disabled_content": "Activa la actualización en segundo plano de la aplicación en Configuración > General > Actualización en segundo plano para usar la copia de seguridad en segundo plano.",
"backup_controller_page_background_app_refresh_disabled_title": "Actualización en segundo plano desactivada",
@ -536,7 +536,7 @@
"backup_controller_page_backup_selected": "Seleccionado: ",
"backup_controller_page_backup_sub": "Fotos y videos respaldados",
"backup_controller_page_created": "Creado el: {}",
"backup_controller_page_desc_backup": "Active la copia de seguridad para cargar automáticamente los nuevos elementos al servidor.",
"backup_controller_page_desc_backup": "Active la copia de seguridad para subir automáticamente los nuevos elementos al servidor cuando se abre la aplicación.",
"backup_controller_page_excluded": "Excluido: ",
"backup_controller_page_failed": "Fallidos ({})",
"backup_controller_page_filename": "Nombre del archivo: {} [{}]",
@ -554,11 +554,11 @@
"backup_controller_page_total_sub": "Todas las fotos y vídeos únicos de los álbumes seleccionados",
"backup_controller_page_turn_off": "Apagar la copia de seguridad",
"backup_controller_page_turn_on": "Activar la copia de seguridad",
"backup_controller_page_uploading_file_info": "Cargando información del archivo",
"backup_controller_page_uploading_file_info": "Subiendo información del archivo",
"backup_err_only_album": "No se puede eliminar el único álbum",
"backup_info_card_assets": "elementos",
"backup_manual_cancelled": "Cancelado",
"backup_manual_in_progress": "Subida en progreso. Espere",
"backup_manual_in_progress": "Subida ya en progreso. Vuelve a intentarlo más tarde",
"backup_manual_success": "Éxito",
"backup_manual_title": "Estado de la subida",
"backup_options_page_title": "Opciones de Copia de Seguridad",
@ -767,7 +767,7 @@
"download_enqueue": "Descarga en cola",
"download_error": "Error al descargar",
"download_failed": "Descarga fallida",
"download_filename": "Archivo: {}",
"download_filename": "archivo: {}",
"download_finished": "Descarga completada",
"download_include_embedded_motion_videos": "Vídeos incrustados",
"download_include_embedded_motion_videos_description": "Incluir vídeos incrustados en fotografías en movimiento como un archivo separado",
@ -978,7 +978,7 @@
"external": "Externo",
"external_libraries": "Bibliotecas Externas",
"external_network": "Red externa",
"external_network_sheet_info": "Cuando no estés conectado a la red WiFi preferida, la aplicación se conectará al servidor utilizando la primera de las siguientes URLs a la que pueda acceder, comenzando desde la parte superior de la lista hacia abajo",
"external_network_sheet_info": "Cuando no estés conectado a la red Wi-Fi preferida, la aplicación se conectará al servidor utilizando la primera de las siguientes URLs a la que pueda acceder, comenzando desde la parte superior de la lista hacia abajo",
"face_unassigned": "Sin asignar",
"failed": "Fallido",
"failed_to_load_assets": "Error al cargar los activos",
@ -1125,7 +1125,7 @@
"local_network": "Local network",
"local_network_sheet_info": "La aplicación se conectará al servidor a través de esta URL cuando utilice la red Wi-Fi especificada",
"location_permission": "Permiso de ubicación",
"location_permission_content": "Para usar la función de cambio automático, Immich necesita permiso de ubicación precisa para poder leer el nombre de la red WiFi actual",
"location_permission_content": "Para usar la función de cambio automático, Immich necesita permiso de ubicación precisa para poder leer el nombre de la red Wi-Fi actual",
"location_picker_choose_on_map": "Elegir en el mapa",
"location_picker_latitude_error": "Introduce una latitud válida",
"location_picker_latitude_hint": "Introduce tu latitud aquí",
@ -1263,7 +1263,7 @@
"no_shared_albums_message": "Crea un álbum para compartir fotos y vídeos con personas de tu red",
"not_in_any_album": "Sin álbum",
"not_selected": "No seleccionado",
"note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar la etiqueta de almacenamiento a los archivos cargados previamente, ejecute el",
"note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar la etiqueta de almacenamiento a los archivos subidos previamente, ejecute el",
"notes": "Notas",
"notification_permission_dialog_content": "Para activar las notificaciones, ve a Configuración y selecciona permitir.",
"notification_permission_list_tile_content": "Concede permiso para habilitar las notificaciones.",
@ -1432,6 +1432,8 @@
"recent_searches": "Búsquedas recientes",
"recently_added": "Añadidos recientemente",
"recently_added_page_title": "Recién Agregadas",
"recently_taken": "Recientemente tomado",
"recently_taken_page_title": "Recientemente Tomado",
"refresh": "Actualizar",
"refresh_encoded_videos": "Recargar los vídeos codificados",
"refresh_faces": "Actualizar caras",
@ -1615,7 +1617,7 @@
"settings_saved": "Ajustes guardados",
"share": "Compartir",
"share_add_photos": "Agregar fotos",
"share_assets_selected": "{} seleccionados",
"share_assets_selected": "{} seleccionado(s)",
"share_dialog_preparing": "Preparando...",
"shared": "Compartido",
"shared_album_activities_input_disable": "Los comentarios están deshabilitados",
@ -1629,7 +1631,7 @@
"shared_by_user": "Compartido por {user}",
"shared_by_you": "Compartido por ti",
"shared_from_partner": "Fotos de {partner}",
"shared_intent_upload_button_progress_text": "{} / {} Cargados",
"shared_intent_upload_button_progress_text": "{} / {} Cargado(s)",
"shared_link_app_bar_title": "Enlaces compartidos",
"shared_link_clipboard_copied_massage": "Copiado al portapapeles",
"shared_link_clipboard_text": "Enlace: {}\nContraseña: {}",
@ -1790,7 +1792,7 @@
"trash_no_results_message": "Las fotos y videos que se envíen a la papelera aparecerán aquí.",
"trash_page_delete_all": "Eliminar todos",
"trash_page_empty_trash_dialog_content": "¿Está seguro que quiere eliminar los elementos? Estos elementos serán eliminados de Immich permanentemente",
"trash_page_info": "Los archivos en la papelera serán eliminados automáticamente después de {} días",
"trash_page_info": "Los archivos en la papelera serán eliminados automáticamente de forma permanente después de {} días",
"trash_page_no_assets": "No hay elementos en la papelera",
"trash_page_restore_all": "Restaurar todos",
"trash_page_select_assets_btn": "Seleccionar elementos",
@ -1818,22 +1820,22 @@
"unstack": "Desapilar",
"unstacked_assets_count": "Desapilado(s) {count, plural, one {# elemento} other {# elementos}}",
"untracked_files": "Archivos no monitorizados",
"untracked_files_decription": "Estos archivos no están siendo monitorizados por la aplicación. Es posible que sean resultado de errores al moverlos, cargas interrumpidas o por un fallo de la aplicación",
"untracked_files_decription": "Estos archivos no están siendo monitorizados por la aplicación. Es posible que sean resultado de errores al moverlos, subidas interrumpidas o por un fallo de la aplicación",
"up_next": "A continuación",
"updated_password": "Contraseña actualizada",
"upload": "Subir",
"upload_concurrency": "Cargas simultáneas",
"upload_concurrency": "Subidas simultáneas",
"upload_dialog_info": "¿Quieres hacer una copia de seguridad al servidor de los elementos seleccionados?",
"upload_dialog_title": "Subir elementos",
"upload_errors": "Carga completada con {count, plural, one {# error} other {# errores}}, actualice la página para ver los nuevos recursos de carga.",
"upload_errors": "Subida completada con {count, plural, one {# error} other {# errores}}, actualice la página para ver los nuevos recursos de la subida.",
"upload_progress": "Restante {remaining, number} - Procesado {processed, number}/{total, number}",
"upload_skipped_duplicates": "Saltado {count, plural, one {# duplicate asset} other {# duplicate assets}}",
"upload_status_duplicates": "Duplicados",
"upload_status_errors": "Errores",
"upload_status_uploaded": "Subido",
"upload_success": "Carga realizada correctamente, actualice la página para ver los nuevos recursos de carga.",
"upload_success": "Subida realizada correctamente, actualice la página para ver los nuevos recursos de subida.",
"upload_to_immich": "Subir a Immich ({})",
"uploading": "Cargando",
"uploading": "Subiendo",
"url": "URL",
"usage": "Uso",
"use_current_connection": "Usar conexión actual",

View File

@ -915,6 +915,8 @@
"hide_unnamed_people": "Sakrij neimenovane osobe",
"host": "Domaćin",
"hour": "Sat",
"ignore_icloud_photos": "Ignoriraj iCloud fotografije",
"ignore_icloud_photos_description": "Fotografije pohranjene na iCloudu neće biti učitane na Immich poslužitelj",
"image": "Slika",
"image_alt_text_date": "{isVideo, select, true {Video} other {Image}} snimljeno {date}",
"image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1} {date}",
@ -926,6 +928,10 @@
"image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1} i {person2} {date}",
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1}, {person2} i {person3} {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1}, {person2} i {additionalCount, number} drugih {date}",
"image_saved_successfully": "Slika je spremljena",
"image_viewer_page_state_provider_download_started": "Preuzimanje započelo",
"image_viewer_page_state_provider_download_success": "Uspješno Preuzimanje",
"image_viewer_page_state_provider_share_error": "Greška pri dijeljenju",
"immich_logo": "Immich Logo",
"immich_web_interface": "Immich Web Sučelje",
"import_from_json": "Uvoz iz JSON-a",

View File

@ -1432,6 +1432,8 @@
"recent_searches": "Pencarian terkini",
"recently_added": "Recently added",
"recently_added_page_title": "Baru Ditambahkan",
"recently_taken": "Diambil terkini",
"recently_taken_page_title": "Diambil Terkini",
"refresh": "Segarkan",
"refresh_encoded_videos": "Segarkan video terenkode",
"refresh_faces": "Segarkan wajah",

View File

@ -1432,6 +1432,7 @@
"recent_searches": "최근 검색",
"recently_added": "최근 추가",
"recently_added_page_title": "최근 추가",
"recently_taken": "최근 촬영됨",
"refresh": "새로고침",
"refresh_encoded_videos": "동영상 재인코딩",
"refresh_faces": "얼굴 새로고침",

View File

@ -85,7 +85,7 @@
"image_quality": "Kvalitet",
"image_resolution": "Oppløsning",
"image_resolution_description": "Høyere oppløsninger kan bevare flere detaljer, men det tar lengre tid å kode, har større filstørrelser og kan redusere appresponsen.",
"image_settings": "Bildeinnstilliinger",
"image_settings": "Bildeinnstillinger",
"image_settings_description": "Administrer kvalitet og oppløsning på genererte bilder",
"image_thumbnail_description": "Små miniatyrbilder med strippet metadata, brukt når du ser på grupper av bilder som hovedtidslinjen",
"image_thumbnail_quality_description": "Miniatyrbildekvalitet fra 1-100. Høyere er bedre, men produserer større filer og kan redusere appens respons.",
@ -371,6 +371,8 @@
"admin_password": "Administrator Passord",
"administration": "Administrasjon",
"advanced": "Avansert",
"advanced_settings_enable_alternate_media_filter_subtitle": "Bruk denne innstillingen for å filtrere mediefiler under synkronisering basert på alternative kriterier. Bruk kun denne innstillingen dersom man opplever problemer med at applikasjonen ikke oppdager alle album.",
"advanced_settings_enable_alternate_media_filter_title": "[EKSPERIMENTELT] Bruk alternativ enhet album synk filter",
"advanced_settings_log_level_title": "Loggnivå: {}",
"advanced_settings_prefer_remote_subtitle": "Noen enheter er veldige trege til å hente mikrobilder fra enheten. Aktiver denne innstillingen for å hente de eksternt istedenfor.",
"advanced_settings_prefer_remote_title": "Foretrekk eksterne bilder",
@ -378,6 +380,8 @@
"advanced_settings_proxy_headers_title": "Proxy headere",
"advanced_settings_self_signed_ssl_subtitle": "Hopper over SSL sertifikatverifikasjon for server-endepunkt. Påkrevet for selvsignerte sertifikater.",
"advanced_settings_self_signed_ssl_title": "Tillat selvsignerte SSL sertifikater",
"advanced_settings_sync_remote_deletions_subtitle": "Automatisk slette eller gjenopprette filer på denne enheten hvis den handlingen har blitt gjort på nettsiden",
"advanced_settings_sync_remote_deletions_title": "Synk sletting fra nettsiden [EKSPERIMENTELT]",
"advanced_settings_tile_subtitle": "Avanserte brukerinnstillinger",
"advanced_settings_troubleshooting_subtitle": "Aktiver ekstra funksjoner for feilsøking",
"advanced_settings_troubleshooting_title": "Feilsøking",
@ -992,6 +996,7 @@
"filetype": "Filtype",
"filter": "Filter",
"filter_people": "Filtrer personer",
"filter_places": "Filtrer steder",
"find_them_fast": "Finn dem raskt ved søking av navn",
"fix_incorrect_match": "Fiks feilaktig match",
"folder": "Folder",
@ -1282,6 +1287,7 @@
"onboarding_welcome_user": "Velkommen, {user}",
"online": "Tilkoblet",
"only_favorites": "Bare favoritter",
"open": "Åpne",
"open_in_map_view": "Åpne i kartvisning",
"open_in_openstreetmap": "Åpne i OpenStreetMap",
"open_the_search_filters": "Åpne søkefiltrene",
@ -1426,6 +1432,8 @@
"recent_searches": "Nylige søk",
"recently_added": "Nylig lagt til",
"recently_added_page_title": "Nylig lagt til",
"recently_taken": "Nylig tatt",
"recently_taken_page_title": "Nylig tatt",
"refresh": "Oppdater",
"refresh_encoded_videos": "Oppdater kodete videoer",
"refresh_faces": "Oppdater ansikter",

View File

@ -978,7 +978,7 @@
"external": "Zunanji",
"external_libraries": "Zunanje knjižnice",
"external_network": "Zunanje omrežje",
"external_network_sheet_info": "Ko aplikacija ni v želenem omrežju WiFi, se bo povezala s strežnikom prek prvega od spodnjih URL-jev, ki jih lahko doseže, začenši od zgoraj navzdol",
"external_network_sheet_info": "Ko aplikacija ni v želenem omrežju Wi-Fi, se bo povezala s strežnikom prek prvega od spodnjih URL-jev, ki jih lahko doseže, začenši od zgoraj navzdol",
"face_unassigned": "Nedodeljen",
"failed": "Ni uspelo",
"failed_to_load_assets": "Sredstev ni bilo mogoče naložiti",
@ -1125,7 +1125,7 @@
"local_network": "Lokalno omrežje",
"local_network_sheet_info": "Aplikacija se bo povezala s strežnikom prek tega URL-ja, ko bo uporabljala navedeno omrežje Wi-Fi",
"location_permission": "Dovoljenje za lokacijo",
"location_permission_content": "Za uporabo funkcije samodejnega preklapljanja potrebuje Immich dovoljenje za natančno lokacijo, da lahko prebere ime trenutnega omrežja WiFi",
"location_permission_content": "Za uporabo funkcije samodejnega preklapljanja potrebuje Immich dovoljenje za natančno lokacijo, da lahko prebere ime trenutnega omrežja Wi-Fi",
"location_picker_choose_on_map": "Izberi na zemljevidu",
"location_picker_latitude_error": "Vnesi veljavno zemljepisno širino",
"location_picker_latitude_hint": "Tukaj vnesi svojo zemljepisno širino",
@ -1432,6 +1432,8 @@
"recent_searches": "Nedavna iskanja",
"recently_added": "Nedavno dodano",
"recently_added_page_title": "Nedavno dodano",
"recently_taken": "Nedavno uporabljen",
"recently_taken_page_title": "Nedavno Uporabljen",
"refresh": "Osveži",
"refresh_encoded_videos": "Osveži kodirane videoposnetke",
"refresh_faces": "Osveži obraze",

View File

@ -378,6 +378,7 @@
"advanced_settings_proxy_headers_title": "Proxy-headers",
"advanced_settings_self_signed_ssl_subtitle": "Hoppar över SSL-certifikatverifiering för serverändpunkten. Krävs för självsignerade certifikat.",
"advanced_settings_self_signed_ssl_title": "Tillåt självsignerade SSL-certifikat",
"advanced_settings_sync_remote_deletions_title": "Synkonisera fjärradering [EXPERIMENTELL]",
"advanced_settings_tile_subtitle": "Avancerade användarinställningar",
"advanced_settings_troubleshooting_subtitle": "Aktivera funktioner för felsökning",
"advanced_settings_troubleshooting_title": "Felsökning",
@ -992,6 +993,7 @@
"filetype": "Filtyp",
"filter": "Filter",
"filter_people": "Filtrera personer",
"filter_places": "Filtrera platser",
"find_them_fast": "Hitta dem snabbt efter namn med sök",
"fix_incorrect_match": "Fixa inkorrekt matchning",
"folder": "Mapp",

View File

@ -6,7 +6,6 @@
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
@ -125,4 +124,4 @@
<data android:scheme="geo" />
</intent>
</queries>
</manifest>
</manifest>

View File

@ -1,40 +1,25 @@
package app.alextran.immich
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.provider.Settings
import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
import java.security.MessageDigest
import java.io.FileInputStream
import kotlinx.coroutines.*
/**
* Android plugin for Dart `BackgroundService` and file trash operations
* Android plugin for Dart `BackgroundService`
*
* Receives messages/method calls from the foreground Dart side to manage
* the background service, e.g. start (enqueue), stop (cancel)
*/
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private var methodChannel: MethodChannel? = null
private var fileTrashChannel: MethodChannel? = null
private var context: Context? = null
private var pendingResult: Result? = null
private val PERMISSION_REQUEST_CODE = 1001
private var activityBinding: ActivityPluginBinding? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
@ -44,10 +29,6 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
context = ctx
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
methodChannel?.setMethodCallHandler(this)
// Add file trash channel
fileTrashChannel = MethodChannel(messenger, "file_trash")
fileTrashChannel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@ -57,14 +38,11 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
fileTrashChannel?.setMethodCallHandler(null)
fileTrashChannel = null
}
override fun onMethodCall(call: MethodCall, result: Result) {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val ctx = context!!
when (call.method) {
// Existing BackgroundService methods
"enable" -> {
val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
@ -136,180 +114,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
}
}
// File Trash methods moved from MainActivity
"moveToTrash" -> {
val fileName = call.argument<String>("fileName")
if (fileName != null) {
if (hasManageStoragePermission()) {
val success = moveToTrash(fileName)
result.success(success)
} else {
result.error("PERMISSION_DENIED", "Storage permission required", null)
}
} else {
result.error("INVALID_NAME", "The file name is not specified.", null)
}
}
"restoreFromTrash" -> {
val fileName = call.argument<String>("fileName")
if (fileName != null) {
if (hasManageStoragePermission()) {
val success = untrashImage(fileName)
result.success(success)
} else {
result.error("PERMISSION_DENIED", "Storage permission required", null)
}
} else {
result.error("INVALID_NAME", "The file name is not specified.", null)
}
}
"requestManageStoragePermission" -> {
if (!hasManageStoragePermission()) {
requestManageStoragePermission(result)
} else {
Log.e("Manage storage permission", "Permission already granted")
result.success(true)
}
}
else -> result.notImplemented()
}
}
// File Trash methods moved from MainActivity
private fun hasManageStoragePermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
true
}
}
private fun requestManageStoragePermission(result: Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
pendingResult = result // Store the result callback
val activity = activityBinding?.activity ?: return
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:${activity.packageName}")
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
} else {
result.success(true)
}
}
private fun moveToTrash(fileName: String): Boolean {
val contentResolver = context?.contentResolver ?: return false
val uri = getFileUri(fileName)
Log.e("FILE_URI", uri.toString())
return uri?.let { moveToTrash(it) } ?: false
}
private fun moveToTrash(contentUri: Uri): Boolean {
val contentResolver = context?.contentResolver ?: return false
return try {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.IS_TRASHED, 1) // Move to trash
}
val updated = contentResolver.update(contentUri, values, null, null)
updated > 0
} catch (e: Exception) {
Log.e("TrashError", "Error moving to trash", e)
false
}
}
private fun getFileUri(fileName: String): Uri? {
val contentResolver = context?.contentResolver ?: return null
val contentUri = MediaStore.Files.getContentUri("external")
val projection = arrayOf(MediaStore.Images.Media._ID)
val selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ?"
val selectionArgs = arrayOf(fileName)
var fileUri: Uri? = null
contentResolver.query(contentUri, projection, selection, selectionArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
fileUri = ContentUris.withAppendedId(contentUri, id)
}
}
return fileUri
}
private fun untrashImage(name: String): Boolean {
val contentResolver = context?.contentResolver ?: return false
val uri = getTrashedFileUri(contentResolver, name)
Log.e("FILE_URI", uri.toString())
return uri?.let { untrashImage(it) } ?: false
}
private fun untrashImage(contentUri: Uri): Boolean {
val contentResolver = context?.contentResolver ?: return false
return try {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.IS_TRASHED, 0) // Restore file
}
val updated = contentResolver.update(contentUri, values, null, null)
updated > 0
} catch (e: Exception) {
Log.e("TrashError", "Error restoring file", e)
false
}
}
private fun getTrashedFileUri(contentResolver: ContentResolver, fileName: String): Uri? {
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val projection = arrayOf(MediaStore.Files.FileColumns._ID)
val queryArgs = Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?")
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
}
contentResolver.query(contentUri, projection, queryArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
return ContentUris.withAppendedId(contentUri, id)
}
}
return null
}
// ActivityAware implementation
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivity() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
// ActivityResultListener implementation
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == PERMISSION_REQUEST_CODE) {
val granted = hasManageStoragePermission()
pendingResult?.success(granted)
pendingResult = null
return true
}
return false
}
}
private const val TAG = "BackgroundServicePlugin"
private const val BUFFER_SIZE = 2 * 1024 * 1024
private const val BUFFER_SIZE = 2 * 1024 * 1024;

View File

@ -2,12 +2,14 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import androidx.annotation.NonNull
import android.os.Bundle
import android.content.Intent
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin())
// No need to set up method channel here as it's now handled in the plugin
}
}

View File

@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 193,
"android.injected.version.name" => "1.131.3",
"android.injected.version.code" => 195,
"android.injected.version.name" => "1.132.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')

View File

@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201;
CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@ -685,7 +685,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201;
CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201;
CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@ -748,7 +748,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201;
CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -791,7 +791,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201;
CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -831,7 +831,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201;
CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.131.3</string>
<string>1.132.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -93,7 +93,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>201</string>
<string>202</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.131.3"
version_number: "1.132.1"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@ -65,7 +65,6 @@ enum StoreKey<T> {
// Video settings
loadOriginalVideo<bool>._(136),
manageLocalMediaAndroid<bool>._(137),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000);

View File

@ -1,5 +0,0 @@
abstract interface class ILocalFilesManager {
Future<bool> moveToTrash(String fileName);
Future<bool> restoreFromTrash(String fileName);
Future<bool> requestManageStoragePermission();
}

View File

@ -2,11 +2,14 @@ import 'dart:async';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
import 'package:logging/logging.dart';
/// The local image provider for an asset
/// Only viable
@ -15,11 +18,16 @@ class ImmichLocalThumbnailProvider
final Asset asset;
final int height;
final int width;
final CacheManager? cacheManager;
final Logger log = Logger("ImmichLocalThumbnailProvider");
final String? userId;
ImmichLocalThumbnailProvider({
required this.asset,
this.height = 256,
this.width = 256,
this.cacheManager,
this.userId,
}) : assert(asset.local != null, 'Only usable when asset.local is set');
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
@ -36,11 +44,10 @@ class ImmichLocalThumbnailProvider
ImmichLocalThumbnailProvider key,
ImageDecoderCallback decode,
) {
final chunkEvents = StreamController<ImageChunkEvent>();
final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiImageStreamCompleter(
codec: _codec(key.asset, decode, chunkEvents),
codec: _codec(key.asset, cache, decode),
scale: 1.0,
chunkEvents: chunkEvents.stream,
informationCollector: () sync* {
yield ErrorDescription(key.asset.fileName);
},
@ -50,25 +57,38 @@ class ImmichLocalThumbnailProvider
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
Asset assetData,
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
final thumbBytes = await assetData.local
?.thumbnailDataWithSize(ThumbnailSize(width, height));
if (thumbBytes == null) {
chunkEvents.close();
final cacheKey =
'$userId${assetData.localId}${assetData.checksum}$width$height';
final fileFromCache = await cache.getFileFromCache(cacheKey);
if (fileFromCache != null) {
try {
final buffer =
await ui.ImmutableBuffer.fromFilePath(fileFromCache.file.path);
final codec = await decode(buffer);
yield codec;
return;
} catch (error) {
log.severe('Found thumbnail in cache, but loading it failed', error);
}
}
final thumbnailBytes = await assetData.local?.thumbnailDataWithSize(
ThumbnailSize(width, height),
quality: 80,
);
if (thumbnailBytes == null) {
throw StateError(
"Loading thumb for local photo ${asset.fileName} failed",
"Loading thumb for local photo ${assetData.fileName} failed",
);
}
try {
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
final codec = await decode(buffer);
yield codec;
} finally {
chunkEvents.close();
}
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbnailBytes);
final codec = await decode(buffer);
yield codec;
await cache.putFile(cacheKey, thumbnailBytes);
}
@override

View File

@ -23,7 +23,6 @@ enum PendingAction {
assetDelete,
assetUploaded,
assetHidden,
assetTrash,
}
class PendingChange {
@ -161,7 +160,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_upload_success', _handleOnUploadSuccess);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleOnAssetTrash);
socket.on('on_asset_trash', _handleServerUpdates);
socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_stack_update', _handleServerUpdates);
@ -208,26 +207,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_debounce.run(handlePendingChanges);
}
Future<void> _handlePendingTrashes() async {
final trashChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetTrash)
.toList();
if (trashChanges.isNotEmpty) {
List<String> remoteIds = trashChanges
.expand((a) => (a.value as List).map((e) => e.toString()))
.toList();
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
await _ref.read(assetProvider.notifier).getAllAsset();
state = state.copyWith(
pendingChanges: state.pendingChanges
.whereNot((c) => trashChanges.contains(c))
.toList(),
);
}
}
Future<void> _handlePendingDeletes() async {
final deleteChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetDelete)
@ -288,7 +267,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
await _handlePendingUploaded();
await _handlePendingDeletes();
await _handlingPendingHidden();
await _handlePendingTrashes();
}
void _handleOnConfigUpdate(dynamic _) {
@ -307,10 +285,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
void _handleOnAssetDelete(dynamic data) =>
addPendingChange(PendingAction.assetDelete, data);
void _handleOnAssetTrash(dynamic data) {
addPendingChange(PendingAction.assetTrash, data);
}
void _handleOnAssetHidden(dynamic data) =>
addPendingChange(PendingAction.assetHidden, data);

View File

@ -1,23 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/utils/local_files_manager.dart';
final localFilesManagerRepositoryProvider =
Provider((ref) => LocalFilesManagerRepository());
class LocalFilesManagerRepository implements ILocalFilesManager {
@override
Future<bool> moveToTrash(String fileName) async {
return await LocalFilesManager.moveToTrash(fileName);
}
@override
Future<bool> restoreFromTrash(String fileName) async {
return await LocalFilesManager.restoreFromTrash(fileName);
}
@override
Future<bool> requestManageStoragePermission() async {
return await LocalFilesManager.requestManageStoragePermission();
}
}

View File

@ -61,7 +61,6 @@ enum AppSettingsEnum<T> {
0,
),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),

View File

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -17,10 +16,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
@ -28,10 +25,8 @@ import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/etag.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/repositories/partner.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
@ -53,8 +48,6 @@ final syncServiceProvider = Provider(
ref.watch(userRepositoryProvider),
ref.watch(userServiceProvider),
ref.watch(etagRepositoryProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(localFilesManagerRepositoryProvider),
ref.watch(partnerApiRepositoryProvider),
ref.watch(userApiRepositoryProvider),
),
@ -76,8 +69,6 @@ class SyncService {
final IUserApiRepository _userApiRepository;
final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService');
final AppSettingsService _appSettingsService;
final ILocalFilesManager _localFilesManager;
SyncService(
this._hashService,
@ -91,8 +82,6 @@ class SyncService {
this._userRepository,
this._userService,
this._eTagRepository,
this._appSettingsService,
this._localFilesManager,
this._partnerApiRepository,
this._userApiRepository,
);
@ -249,19 +238,8 @@ class SyncService {
return null;
}
Future<void> _moveToTrashMatchedAssets(Iterable<String> idsToDelete) async {
final List<Asset> localAssets = await _assetRepository.getAllLocal();
final List<Asset> matchedAssets = localAssets
.where((asset) => idsToDelete.contains(asset.remoteId))
.toList();
for (var asset in matchedAssets) {
_localFilesManager.moveToTrash(asset.fileName);
}
}
/// Deletes remote-only assets, updates merged assets to be local-only
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) async {
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) {
return _assetRepository.transaction(() async {
await _assetRepository.deleteAllByRemoteId(
idsToDelete,
@ -271,12 +249,6 @@ class SyncService {
idsToDelete,
state: AssetState.merged,
);
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
await _moveToTrashMatchedAssets(idsToDelete);
}
if (merged.isEmpty) return;
for (final Asset asset in merged) {
asset.remoteId = null;
@ -818,27 +790,10 @@ class SyncService {
return (existing, toUpsert);
}
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
for (var asset in assetsList) {
if (asset.isTrashed) {
_localFilesManager.moveToTrash(asset.fileName);
} else {
_localFilesManager.restoreFromTrash(asset.fileName);
}
}
}
/// Inserts or updates the assets in the database with their ExifInfo (if any)
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
if (assets.isEmpty) return;
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
_toggleTrashStatusForAssets(assets);
}
try {
await _assetRepository.transaction(() async {
await _assetRepository.updateAll(assets);

View File

@ -1,39 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class LocalFilesManager {
static const MethodChannel _channel = MethodChannel('file_trash');
static Future<bool> moveToTrash(String fileName) async {
try {
final bool success =
await _channel.invokeMethod('moveToTrash', {'fileName': fileName});
return success;
} on PlatformException catch (e) {
debugPrint('Error moving to trash: ${e.message}');
return false;
}
}
static Future<bool> restoreFromTrash(String fileName) async {
try {
final bool success = await _channel
.invokeMethod('restoreFromTrash', {'fileName': fileName});
return success;
} on PlatformException catch (e) {
debugPrint('Error restoring file: ${e.message}');
return false;
}
}
static Future<bool> requestManageStoragePermission() async {
try {
final bool success =
await _channel.invokeMethod('requestManageStoragePermission');
return success;
} on PlatformException catch (e) {
debugPrint('Error requesting permission: ${e.message}');
return false;
}
}
}

View File

@ -1,7 +1,7 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
@ -9,8 +9,9 @@ import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/thumbhash_placeholder.dart';
import 'package:octo_image/octo_image.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class ImmichThumbnail extends HookWidget {
class ImmichThumbnail extends HookConsumerWidget {
const ImmichThumbnail({
this.asset,
this.width = 250,
@ -31,6 +32,7 @@ class ImmichThumbnail extends HookWidget {
static ImageProvider imageProvider({
Asset? asset,
String? assetId,
String? userId,
int thumbnailSize = 256,
}) {
if (asset == null && assetId == null) {
@ -48,6 +50,7 @@ class ImmichThumbnail extends HookWidget {
asset: asset,
height: thumbnailSize,
width: thumbnailSize,
userId: userId,
);
} else {
return ImmichRemoteThumbnailProvider(
@ -59,8 +62,10 @@ class ImmichThumbnail extends HookWidget {
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
Uint8List? blurhash = useBlurHashRef(asset).value;
final userId = ref.watch(currentUserProvider)?.id;
if (asset == null) {
return Container(
color: Colors.grey,
@ -79,6 +84,7 @@ class ImmichThumbnail extends HookWidget {
octoSet: blurHashOrPlaceholder(blurhash),
image: ImmichThumbnail.imageProvider(
asset: asset,
userId: userId,
),
width: width,
height: height,

View File

@ -1,13 +1,11 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
@ -27,8 +25,6 @@ class AdvancedSettings extends HookConsumerWidget {
final advancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid =
useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert =
@ -44,16 +40,6 @@ class AdvancedSettings extends HookConsumerWidget {
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
);
Future<bool> checkAndroidVersion() async {
if (Platform.isAndroid) {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
int sdkVersion = androidInfo.version.sdkInt;
return sdkVersion >= 30;
}
return false;
}
final advancedSettings = [
SettingsSwitchListTile(
enabled: true,
@ -61,29 +47,6 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
FutureBuilder<bool>(
future: checkAndroidVersion(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return SettingsSwitchListTile(
enabled: true,
valueNotifier: manageLocalMediaAndroid,
title: "advanced_settings_sync_remote_deletions_title".tr(),
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async {
if (value) {
final result = await ref
.read(localFilesManagerRepositoryProvider)
.requestManageStoragePermission();
manageLocalMediaAndroid.value = result;
}
},
);
} else {
return const SizedBox.shrink();
}
},
),
SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(args: [logLevel]),
valueNotifier: levelId,

View File

@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.131.3
- API version: 1.132.1
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@ -13,37 +13,58 @@ part of openapi.api;
class OAuthCallbackDto {
/// Returns a new [OAuthCallbackDto] instance.
OAuthCallbackDto({
this.codeVerifier,
this.state,
required this.url,
required this.state,
required this.codeVerifier,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? codeVerifier;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? state;
String url;
String state;
String codeVerifier;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is OAuthCallbackDto &&
other.url == url &&
other.state == state &&
other.codeVerifier == codeVerifier;
bool operator ==(Object other) => identical(this, other) || other is OAuthCallbackDto &&
other.codeVerifier == codeVerifier &&
other.state == state &&
other.url == url;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(url.hashCode) + (state.hashCode) + (codeVerifier.hashCode);
// ignore: unnecessary_parenthesis
(codeVerifier == null ? 0 : codeVerifier!.hashCode) +
(state == null ? 0 : state!.hashCode) +
(url.hashCode);
@override
String toString() =>
'OAuthCallbackDto[url=$url, state=$state, codeVerifier=$codeVerifier]';
String toString() => 'OAuthCallbackDto[codeVerifier=$codeVerifier, state=$state, url=$url]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'url'] = this.url;
json[r'state'] = this.state;
json[r'codeVerifier'] = this.codeVerifier;
if (this.codeVerifier != null) {
json[r'codeVerifier'] = this.codeVerifier;
} else {
// json[r'codeVerifier'] = null;
}
if (this.state != null) {
json[r'state'] = this.state;
} else {
// json[r'state'] = null;
}
json[r'url'] = this.url;
return json;
}
@ -56,18 +77,15 @@ class OAuthCallbackDto {
final json = value.cast<String, dynamic>();
return OAuthCallbackDto(
codeVerifier: mapValueOfType<String>(json, r'codeVerifier'),
state: mapValueOfType<String>(json, r'state'),
url: mapValueOfType<String>(json, r'url')!,
state: mapValueOfType<String>(json, r'state')!,
codeVerifier: mapValueOfType<String>(json, r'codeVerifier')!,
);
}
return null;
}
static List<OAuthCallbackDto> listFromJson(
dynamic json, {
bool growable = false,
}) {
static List<OAuthCallbackDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <OAuthCallbackDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
@ -95,19 +113,13 @@ class OAuthCallbackDto {
}
// maps a json object with a list of OAuthCallbackDto-objects as value to a dart map
static Map<String, List<OAuthCallbackDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
static Map<String, List<OAuthCallbackDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<OAuthCallbackDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = OAuthCallbackDto.listFromJson(
entry.value,
growable: growable,
);
map[entry.key] = OAuthCallbackDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
@ -116,7 +128,6 @@ class OAuthCallbackDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'url',
'state',
'codeVerifier',
};
}

View File

@ -13,37 +13,58 @@ part of openapi.api;
class OAuthConfigDto {
/// Returns a new [OAuthConfigDto] instance.
OAuthConfigDto({
this.codeChallenge,
required this.redirectUri,
required this.state,
required this.codeChallenge,
this.state,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? codeChallenge;
String redirectUri;
String state;
String codeChallenge;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? state;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is OAuthConfigDto &&
other.redirectUri == redirectUri &&
other.state == state &&
other.codeChallenge == codeChallenge;
bool operator ==(Object other) => identical(this, other) || other is OAuthConfigDto &&
other.codeChallenge == codeChallenge &&
other.redirectUri == redirectUri &&
other.state == state;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(redirectUri.hashCode) + (state.hashCode) + (codeChallenge.hashCode);
// ignore: unnecessary_parenthesis
(codeChallenge == null ? 0 : codeChallenge!.hashCode) +
(redirectUri.hashCode) +
(state == null ? 0 : state!.hashCode);
@override
String toString() =>
'OAuthConfigDto[redirectUri=$redirectUri, state=$state, codeChallenge=$codeChallenge]';
String toString() => 'OAuthConfigDto[codeChallenge=$codeChallenge, redirectUri=$redirectUri, state=$state]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'redirectUri'] = this.redirectUri;
json[r'state'] = this.state;
json[r'codeChallenge'] = this.codeChallenge;
if (this.codeChallenge != null) {
json[r'codeChallenge'] = this.codeChallenge;
} else {
// json[r'codeChallenge'] = null;
}
json[r'redirectUri'] = this.redirectUri;
if (this.state != null) {
json[r'state'] = this.state;
} else {
// json[r'state'] = null;
}
return json;
}
@ -56,18 +77,15 @@ class OAuthConfigDto {
final json = value.cast<String, dynamic>();
return OAuthConfigDto(
codeChallenge: mapValueOfType<String>(json, r'codeChallenge'),
redirectUri: mapValueOfType<String>(json, r'redirectUri')!,
state: mapValueOfType<String>(json, r'state')!,
codeChallenge: mapValueOfType<String>(json, r'codeChallenge')!,
state: mapValueOfType<String>(json, r'state'),
);
}
return null;
}
static List<OAuthConfigDto> listFromJson(
dynamic json, {
bool growable = false,
}) {
static List<OAuthConfigDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <OAuthConfigDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
@ -95,19 +113,13 @@ class OAuthConfigDto {
}
// maps a json object with a list of OAuthConfigDto-objects as value to a dart map
static Map<String, List<OAuthConfigDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
static Map<String, List<OAuthConfigDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<OAuthConfigDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = OAuthConfigDto.listFromJson(
entry.value,
growable: growable,
);
map[entry.key] = OAuthConfigDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
@ -116,7 +128,6 @@ class OAuthConfigDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'redirectUri',
'state',
'codeChallenge',
};
}

View File

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.131.3+193
version: 1.132.1+195
environment:
sdk: '>=3.3.0 <4.0.0'

View File

@ -60,9 +60,6 @@ void main() {
final MockAlbumMediaRepository albumMediaRepository =
MockAlbumMediaRepository();
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
final MockAppSettingService appSettingService = MockAppSettingService();
final MockLocalFilesManagerRepository localFilesManagerRepository =
MockLocalFilesManagerRepository();
final MockPartnerApiRepository partnerApiRepository =
MockPartnerApiRepository();
final MockUserApiRepository userApiRepository = MockUserApiRepository();
@ -109,8 +106,6 @@ void main() {
userRepository,
userService,
eTagRepository,
appSettingService,
localFilesManagerRepository,
partnerApiRepository,
userApiRepository,
);

View File

@ -10,7 +10,6 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart';
import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:mocktail/mocktail.dart';
@ -42,9 +41,6 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
class MockAuthRepository extends Mock implements IAuthRepository {}
class MockPartnerRepository extends Mock implements IPartnerRepository {}
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
class MockLocalFilesManagerRepository extends Mock
implements ILocalFilesManager {}
class MockPartnerRepository extends Mock implements IPartnerRepository {}

View File

@ -1,6 +1,5 @@
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
@ -26,6 +25,4 @@ class MockNetworkService extends Mock implements NetworkService {}
class MockSearchApi extends Mock implements SearchApi {}
class MockAppSettingService extends Mock implements AppSettingsService {}
class MockBackgroundService extends Mock implements BackgroundService {}

View File

@ -7656,7 +7656,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.131.3",
"version": "1.132.1",
"contact": {}
},
"tags": [],

View File

@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.131.3",
"version": "1.132.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.131.3",
"version": "1.132.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.131.3",
"version": "1.132.1",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@ -1,6 +1,6 @@
/**
* Immich
* 1.131.3
* 1.132.1
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@ -687,17 +687,17 @@ export type TestEmailResponseDto = {
messageId: string;
};
export type OAuthConfigDto = {
codeChallenge?: string;
redirectUri: string;
state?: string;
codeChallenge?: string;
};
export type OAuthAuthorizeResponseDto = {
url: string;
};
export type OAuthCallbackDto = {
url: string;
state?: string;
codeVerifier?: string;
state?: string;
url: string;
};
export type PartnerResponseDto = {
avatarColor: UserAvatarColor;

View File

@ -6,14 +6,14 @@ WORKDIR /usr/src/app
COPY server/package.json server/package-lock.json ./
COPY server/patches ./patches
RUN npm ci && \
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# they're marked as optional dependencies, so we need to copy them manually after pruning
rm -rf node_modules/@img/sharp-libvips* && \
rm -rf node_modules/@img/sharp-linuxmusl-x64
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# they're marked as optional dependencies, so we need to copy them manually after pruning
rm -rf node_modules/@img/sharp-libvips* && \
rm -rf node_modules/@img/sharp-linuxmusl-x64
ENV PATH="${PATH}:/usr/src/app/bin" \
IMMICH_ENV=development \
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all
IMMICH_ENV=development \
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all
ENTRYPOINT ["tini", "--", "/bin/sh"]
@ -47,8 +47,8 @@ FROM ghcr.io/immich-app/base-server-prod:202504081114@sha256:8353bcbdb4e6579300a
WORKDIR /usr/src/app
ENV NODE_ENV=production \
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all
COPY --from=prod /usr/src/app/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist
COPY --from=prod /usr/src/app/bin ./bin

2524
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.131.3",
"version": "1.132.1",
"description": "",
"author": "",
"private": true,
@ -88,7 +88,7 @@
"sanitize-filename": "^1.6.3",
"sanitize-html": "^2.14.0",
"semver": "^7.6.2",
"sharp": "^0.33.5",
"sharp": "^0.34.0",
"sirv": "^3.0.0",
"tailwindcss-preset-email": "^1.3.2",
"thumbhash": "^0.1.1",
@ -132,7 +132,8 @@
"globals": "^16.0.0",
"jsdom": "^26.1.0",
"mock-fs": "^5.2.0",
"node-addon-api": "^8.3.0",
"node-addon-api": "^8.3.1",
"node-gyp": "^11.2.0",
"patch-package": "^8.0.0",
"pngjs": "^7.0.0",
"prettier": "^3.0.2",
@ -152,5 +153,8 @@
},
"volta": {
"node": "22.14.0"
},
"overrides": {
"sharp": "^0.34.0"
}
}

View File

@ -17,12 +17,12 @@ import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { repositories } from 'src/repositories';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { services } from 'src/services';
import { AuthService } from 'src/services/auth.service';
import { CliService } from 'src/services/cli.service';
import { JobService } from 'src/services/job.service';
import { getKyselyConfig } from 'src/utils/database';
const common = [...repositories, ...services, GlobalExceptionFilter];
@ -52,7 +52,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
@Inject(IWorker) private worker: ImmichWorker,
logger: LoggingRepository,
private eventRepository: EventRepository,
private jobRepository: JobRepository,
private jobService: JobService,
private telemetryRepository: TelemetryRepository,
private authService: AuthService,
) {
@ -62,10 +62,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
this.telemetryRepository.setup({ repositories });
this.jobRepository.setup({ services });
if (this.worker === ImmichWorker.MICROSERVICES) {
this.jobRepository.startWorkers();
}
this.jobService.setServices(services);
this.eventRepository.setAuthFn(async (client) =>
this.authService.authenticate({

View File

@ -407,6 +407,8 @@ export enum DatabaseExtension {
export enum BootstrapEventPriority {
// Database service should be initialized before anything else, most other services need database access
DatabaseService = -200,
// Other services may need to queue jobs on bootstrap.
JobService = -190,
// Initialise config after other bootstrap services, stop other services from using config on bootstrap
SystemConfig = 100,
}

View File

@ -33,7 +33,7 @@ export class JobRepository {
this.logger.setContext(JobRepository.name);
}
setup({ services }: { services: ClassConstructor<unknown>[] }) {
setup(services: ClassConstructor<unknown>[]) {
const reflector = this.moduleRef.get(Reflector, { strict: false });
// discovery

View File

@ -73,26 +73,54 @@ export class ServerInfoRepository {
}
}
buildVersions?: ServerBuildVersions;
private async retrieveVersionFallback(
command: string,
commandTransform?: (output: string) => string,
version?: string,
): Promise<string> {
if (!version) {
const output = await maybeFirstLine(command);
version = commandTransform ? commandTransform(output) : output;
}
return version;
}
async getBuildVersions(): Promise<ServerBuildVersions> {
const { nodeVersion, resourcePaths } = this.configRepository.getEnv();
if (!this.buildVersions) {
const { nodeVersion, resourcePaths } = this.configRepository.getEnv();
const [nodejsOutput, ffmpegOutput, magickOutput] = await Promise.all([
maybeFirstLine('node --version'),
maybeFirstLine('ffmpeg -version'),
maybeFirstLine('convert --version'),
]);
const lockfile: BuildLockfile | undefined = await readFile(resourcePaths.lockFile)
.then((buffer) => JSON.parse(buffer.toString()))
.catch(() => this.logger.warn(`Failed to read ${resourcePaths.lockFile}`));
const lockfile = await readFile(resourcePaths.lockFile)
.then((buffer) => JSON.parse(buffer.toString()))
.catch(() => this.logger.warn(`Failed to read ${resourcePaths.lockFile}`));
const [nodejsVersion, ffmpegVersion, magickVersion, exiftoolVersion] = await Promise.all([
this.retrieveVersionFallback('node --version', undefined, nodeVersion),
this.retrieveVersionFallback(
'ffmpeg -version',
(output) => output.replaceAll('ffmpeg version ', ''),
getLockfileVersion('ffmpeg', lockfile),
),
this.retrieveVersionFallback(
'magick --version',
(output) => output.replaceAll('Version: ImageMagick ', ''),
getLockfileVersion('imagemagick', lockfile),
),
exiftool.version(),
]);
return {
nodejs: nodejsOutput || nodeVersion || '',
exiftool: await exiftool.version(),
ffmpeg: getLockfileVersion('ffmpeg', lockfile) || ffmpegOutput.replaceAll('ffmpeg version', '') || '',
libvips: getLockfileVersion('libvips', lockfile) || sharp.versions.vips,
imagemagick:
getLockfileVersion('imagemagick', lockfile) || magickOutput.replaceAll('Version: ImageMagick ', '') || '',
};
const libvipsVersion = getLockfileVersion('libvips', lockfile) || sharp.versions.vips;
this.buildVersions = {
nodejs: nodejsVersion,
exiftool: exiftoolVersion,
ffmpeg: ffmpegVersion,
libvips: libvipsVersion,
imagemagick: magickVersion,
};
}
return this.buildVersions;
}
}

View File

@ -772,9 +772,13 @@ describe(AuthService.name, () => {
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse(user),
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
profileImagePath: `upload/profile/${user.id}/${fileId}.jpg`,
@ -796,9 +800,13 @@ describe(AuthService.name, () => {
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse(user),
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled();

View File

@ -1,10 +1,12 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ClassConstructor } from 'class-transformer';
import { snakeCase } from 'lodash';
import { OnEvent } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
import {
AssetType,
BootstrapEventPriority,
ImmichWorker,
JobCommand,
JobName,
@ -51,6 +53,8 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
@Injectable()
export class JobService extends BaseService {
private services: ClassConstructor<unknown>[] = [];
@OnEvent({ name: 'config.init', workers: [ImmichWorker.MICROSERVICES] })
onConfigInit({ newConfig: config }: ArgOf<'config.init'>) {
this.logger.debug(`Updating queue concurrency settings`);
@ -69,6 +73,18 @@ export class JobService extends BaseService {
this.onConfigInit({ newConfig: config });
}
@OnEvent({ name: 'app.bootstrap', priority: BootstrapEventPriority.JobService })
onBootstrap() {
this.jobRepository.setup(this.services);
if (this.worker === ImmichWorker.MICROSERVICES) {
this.jobRepository.startWorkers();
}
}
setServices(services: ClassConstructor<unknown>[]) {
this.services = services;
}
async create(dto: JobCreateDto): Promise<void> {
await this.jobRepository.queue(asJobItem(dto));
}

View File

@ -20,12 +20,6 @@ export default defineConfig({
'src/services/index.ts',
'src/sql-tools/from-database/index.ts',
],
thresholds: {
lines: 85,
statements: 85,
branches: 90,
functions: 85,
},
},
server: {
deps: {

6
web/package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.131.3",
"version": "1.132.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.131.3",
"version": "1.132.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
@ -82,7 +82,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.131.3",
"version": "1.132.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.131.3",
"version": "1.132.1",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {

View File

@ -76,7 +76,7 @@
<div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
{#each memoryStore.memories as memory (memory.id)}
<a
class="memory-card relative mr-8 last:mr-0 inline-block aspect-[3/4] md:aspect-[4/3] max-md:h-[150px] xl:aspect-video h-[215px] rounded-xl"
class="memory-card relative mr-2 md:mr-4 last:mr-0 inline-block aspect-[3/4] md:aspect-[4/3] max-md:h-[150px] xl:aspect-video h-[215px] rounded-xl"
href="{AppRoute.MEMORY}?{QueryParameter.ID}={memory.assets[0].id}"
>
<img