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

View File

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

View File

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

View File

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

View File

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

View File

@ -47,7 +47,10 @@ jobs:
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Bump version - 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 - name: Commit and tag
id: push-tag id: push-tag
@ -61,6 +64,8 @@ jobs:
build_mobile: build_mobile:
uses: ./.github/workflows/build-mobile.yml uses: ./.github/workflows/build-mobile.yml
needs: bump_version needs: bump_version
permissions:
contents: read
secrets: secrets:
KEY_JKS: ${{ secrets.KEY_JKS }} KEY_JKS: ${{ secrets.KEY_JKS }}
ALIAS: ${{ secrets.ALIAS }} ALIAS: ${{ secrets.ALIAS }}

View File

@ -95,3 +95,30 @@ jobs:
- name: Run dart custom_lint - name: Run dart custom_lint
run: dart run custom_lint run: dart run custom_lint
working-directory: ./mobile 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 run: exit 1
- name: All jobs passed or skipped - name: All jobs passed or skipped
if: ${{ !(contains(needs.*.result, 'failure')) }} if: ${{ !(contains(needs.*.result, 'failure')) }}
# zizmor: ignore[template-injection]
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}" 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", "name": "@immich/cli",
"version": "2.2.61", "version": "2.2.63",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.61", "version": "2.2.63",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
@ -54,7 +54,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.131.3", "version": "1.132.1",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.61", "version": "2.2.63",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "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", "label": "v1.131.3",
"url": "https://v1.131.3.archive.immich.app" "url": "https://v1.131.3.archive.immich.app"

8
e2e/package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.131.3", "version": "1.132.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "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_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>.", "authentication_settings_reenable": "Para reactivarlo, utiliza un <link>Comando del servidor</link>.",
"background_task_job": "Tareas en segundo plano", "background_task_job": "Tareas en segundo plano",
"backup_database": "Respaldar base de datos", "backup_database": "Crear volcado de base de datos",
"backup_database_enable_description": "Activar respaldo de base de datos", "backup_database_enable_description": "Activar volcado de base de datos",
"backup_keep_last_amount": "Cantidad de respaldos previos a mantener", "backup_keep_last_amount": "Cantidad de volcados previos a mantener",
"backup_settings": "Ajustes de respaldo", "backup_settings": "Ajustes de volcado de base de datos",
"backup_settings_description": "Administrar configuración de respaldo 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", "check_all": "Verificar todo",
"cleanup": "Limpieza", "cleanup": "Limpieza",
"cleared_jobs": "Trabajos borrados para: {job}", "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_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", "image_thumbnail_title": "Ajustes de las miniaturas",
"job_concurrency": "{job}: Procesos simultáneos", "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_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_settings_description": "Administrar tareas simultáneas",
"job_status": "Estado de la tarea", "job_status": "Estado de la tarea",
"jobs_delayed": "{jobCount, plural, one {# retrasado} other {# retrasados}}", "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", "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_paths_added": "No se han añadido carpetas",
"no_pattern_added": "No se han añadido patrones", "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!", "note_cannot_be_changed_later": "NOTA: ¡No se puede cambiar posteriormente!",
"notification_email_from_address": "Desde", "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>\"", "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": "Migración de plantillas de almacenamiento",
"storage_template_migration_description": "Aplicar la <link>{template}</link> actual a los elementos subidos previamente", "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_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_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_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_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": "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", "storage_template_user_label": "<code>{label}</code> es la etiqueta de almacenamiento del usuario",
"system_settings": "Ajustes del Sistema", "system_settings": "Ajustes del Sistema",
"tag_cleanup_job": "Limpieza de etiquetas", "tag_cleanup_job": "Limpieza de etiquetas",
@ -345,7 +345,7 @@
"trash_settings": "Configuración papelera", "trash_settings": "Configuración papelera",
"trash_settings_description": "Administrar la configuración de la papelera", "trash_settings_description": "Administrar la configuración de la papelera",
"untracked_files": "Archivos sin seguimiento", "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_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": "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", "user_delete_delay_settings": "Eliminar retardo",
@ -429,7 +429,7 @@
"allow_dark_mode": "Permitir modo oscuro", "allow_dark_mode": "Permitir modo oscuro",
"allow_edits": "Permitir edición", "allow_edits": "Permitir edición",
"allow_public_user_to_download": "Permitir descargar al usuario público", "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", "alt_text_qr_code": "Código QR",
"anti_clockwise": "En sentido antihorario", "anti_clockwise": "En sentido antihorario",
"api_key": "Clave API", "api_key": "Clave API",
@ -473,7 +473,7 @@
"asset_skipped": "Omitido", "asset_skipped": "Omitido",
"asset_skipped_in_trash": "En la papelera", "asset_skipped_in_trash": "En la papelera",
"asset_uploaded": "Subido", "asset_uploaded": "Subido",
"asset_uploading": "Cargando…", "asset_uploading": "Subiendo…",
"asset_viewer_settings_subtitle": "Administra las configuracioens de tu visor de fotos", "asset_viewer_settings_subtitle": "Administra las configuracioens de tu visor de fotos",
"asset_viewer_settings_title": "Visor de Archivos", "asset_viewer_settings_title": "Visor de Archivos",
"assets": "elementos", "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_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_count": "{count, plural, one {# activo} other {# activos}}",
"assets_deleted_permanently": "{} elementos(s) eliminado(s) permanentemente", "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_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_permanently_deleted_count": "Eliminado permanentemente {count, plural, one {# elemento} other {# elementos}}",
"assets_removed_count": "Eliminado {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_restored_successfully": "{} elemento(s) restaurado(s) exitosamente",
"assets_trashed": "{} elemento(s) eliminado(s)", "assets_trashed": "{} elemento(s) eliminado(s)",
"assets_trashed_count": "Borrado {count, plural, one {# elemento} other {# elementos}}", "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", "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} ya forma parte del álbum",
"authorized_devices": "Dispositivos Autorizados", "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", "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_all": "Todos",
"backup_background_service_backup_failed_message": "Error al copiar elementos. Reintentando…", "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_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_default_notification": "Comprobando nuevos elementos…",
"backup_background_service_error_title": "Error de copia de seguridad", "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_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_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_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", "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_selected": "Seleccionado: ",
"backup_controller_page_backup_sub": "Fotos y videos respaldados", "backup_controller_page_backup_sub": "Fotos y videos respaldados",
"backup_controller_page_created": "Creado el: {}", "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_excluded": "Excluido: ",
"backup_controller_page_failed": "Fallidos ({})", "backup_controller_page_failed": "Fallidos ({})",
"backup_controller_page_filename": "Nombre del archivo: {} [{}]", "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_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_off": "Apagar la copia de seguridad",
"backup_controller_page_turn_on": "Activar 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_err_only_album": "No se puede eliminar el único álbum",
"backup_info_card_assets": "elementos", "backup_info_card_assets": "elementos",
"backup_manual_cancelled": "Cancelado", "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_success": "Éxito",
"backup_manual_title": "Estado de la subida", "backup_manual_title": "Estado de la subida",
"backup_options_page_title": "Opciones de Copia de Seguridad", "backup_options_page_title": "Opciones de Copia de Seguridad",
@ -767,7 +767,7 @@
"download_enqueue": "Descarga en cola", "download_enqueue": "Descarga en cola",
"download_error": "Error al descargar", "download_error": "Error al descargar",
"download_failed": "Descarga fallida", "download_failed": "Descarga fallida",
"download_filename": "Archivo: {}", "download_filename": "archivo: {}",
"download_finished": "Descarga completada", "download_finished": "Descarga completada",
"download_include_embedded_motion_videos": "Vídeos incrustados", "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", "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": "Externo",
"external_libraries": "Bibliotecas Externas", "external_libraries": "Bibliotecas Externas",
"external_network": "Red externa", "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", "face_unassigned": "Sin asignar",
"failed": "Fallido", "failed": "Fallido",
"failed_to_load_assets": "Error al cargar los activos", "failed_to_load_assets": "Error al cargar los activos",
@ -1125,7 +1125,7 @@
"local_network": "Local network", "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", "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": "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_choose_on_map": "Elegir en el mapa",
"location_picker_latitude_error": "Introduce una latitud válida", "location_picker_latitude_error": "Introduce una latitud válida",
"location_picker_latitude_hint": "Introduce tu latitud aquí", "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", "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_in_any_album": "Sin álbum",
"not_selected": "No seleccionado", "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", "notes": "Notas",
"notification_permission_dialog_content": "Para activar las notificaciones, ve a Configuración y selecciona permitir.", "notification_permission_dialog_content": "Para activar las notificaciones, ve a Configuración y selecciona permitir.",
"notification_permission_list_tile_content": "Concede permiso para habilitar las notificaciones.", "notification_permission_list_tile_content": "Concede permiso para habilitar las notificaciones.",
@ -1432,6 +1432,8 @@
"recent_searches": "Búsquedas recientes", "recent_searches": "Búsquedas recientes",
"recently_added": "Añadidos recientemente", "recently_added": "Añadidos recientemente",
"recently_added_page_title": "Recién Agregadas", "recently_added_page_title": "Recién Agregadas",
"recently_taken": "Recientemente tomado",
"recently_taken_page_title": "Recientemente Tomado",
"refresh": "Actualizar", "refresh": "Actualizar",
"refresh_encoded_videos": "Recargar los vídeos codificados", "refresh_encoded_videos": "Recargar los vídeos codificados",
"refresh_faces": "Actualizar caras", "refresh_faces": "Actualizar caras",
@ -1615,7 +1617,7 @@
"settings_saved": "Ajustes guardados", "settings_saved": "Ajustes guardados",
"share": "Compartir", "share": "Compartir",
"share_add_photos": "Agregar fotos", "share_add_photos": "Agregar fotos",
"share_assets_selected": "{} seleccionados", "share_assets_selected": "{} seleccionado(s)",
"share_dialog_preparing": "Preparando...", "share_dialog_preparing": "Preparando...",
"shared": "Compartido", "shared": "Compartido",
"shared_album_activities_input_disable": "Los comentarios están deshabilitados", "shared_album_activities_input_disable": "Los comentarios están deshabilitados",
@ -1629,7 +1631,7 @@
"shared_by_user": "Compartido por {user}", "shared_by_user": "Compartido por {user}",
"shared_by_you": "Compartido por ti", "shared_by_you": "Compartido por ti",
"shared_from_partner": "Fotos de {partner}", "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_app_bar_title": "Enlaces compartidos",
"shared_link_clipboard_copied_massage": "Copiado al portapapeles", "shared_link_clipboard_copied_massage": "Copiado al portapapeles",
"shared_link_clipboard_text": "Enlace: {}\nContraseña: {}", "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_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_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_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_no_assets": "No hay elementos en la papelera",
"trash_page_restore_all": "Restaurar todos", "trash_page_restore_all": "Restaurar todos",
"trash_page_select_assets_btn": "Seleccionar elementos", "trash_page_select_assets_btn": "Seleccionar elementos",
@ -1818,22 +1820,22 @@
"unstack": "Desapilar", "unstack": "Desapilar",
"unstacked_assets_count": "Desapilado(s) {count, plural, one {# elemento} other {# elementos}}", "unstacked_assets_count": "Desapilado(s) {count, plural, one {# elemento} other {# elementos}}",
"untracked_files": "Archivos no monitorizados", "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", "up_next": "A continuación",
"updated_password": "Contraseña actualizada", "updated_password": "Contraseña actualizada",
"upload": "Subir", "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_info": "¿Quieres hacer una copia de seguridad al servidor de los elementos seleccionados?",
"upload_dialog_title": "Subir elementos", "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_progress": "Restante {remaining, number} - Procesado {processed, number}/{total, number}",
"upload_skipped_duplicates": "Saltado {count, plural, one {# duplicate asset} other {# duplicate assets}}", "upload_skipped_duplicates": "Saltado {count, plural, one {# duplicate asset} other {# duplicate assets}}",
"upload_status_duplicates": "Duplicados", "upload_status_duplicates": "Duplicados",
"upload_status_errors": "Errores", "upload_status_errors": "Errores",
"upload_status_uploaded": "Subido", "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 ({})", "upload_to_immich": "Subir a Immich ({})",
"uploading": "Cargando", "uploading": "Subiendo",
"url": "URL", "url": "URL",
"usage": "Uso", "usage": "Uso",
"use_current_connection": "Usar conexión actual", "use_current_connection": "Usar conexión actual",

View File

@ -915,6 +915,8 @@
"hide_unnamed_people": "Sakrij neimenovane osobe", "hide_unnamed_people": "Sakrij neimenovane osobe",
"host": "Domaćin", "host": "Domaćin",
"hour": "Sat", "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": "Slika",
"image_alt_text_date": "{isVideo, select, true {Video} other {Image}} snimljeno {date}", "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}", "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_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_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_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_logo": "Immich Logo",
"immich_web_interface": "Immich Web Sučelje", "immich_web_interface": "Immich Web Sučelje",
"import_from_json": "Uvoz iz JSON-a", "import_from_json": "Uvoz iz JSON-a",

View File

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

View File

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

View File

@ -85,7 +85,7 @@
"image_quality": "Kvalitet", "image_quality": "Kvalitet",
"image_resolution": "Oppløsning", "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_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_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_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.", "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", "admin_password": "Administrator Passord",
"administration": "Administrasjon", "administration": "Administrasjon",
"advanced": "Avansert", "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_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_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", "advanced_settings_prefer_remote_title": "Foretrekk eksterne bilder",
@ -378,6 +380,8 @@
"advanced_settings_proxy_headers_title": "Proxy headere", "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_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_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_tile_subtitle": "Avanserte brukerinnstillinger",
"advanced_settings_troubleshooting_subtitle": "Aktiver ekstra funksjoner for feilsøking", "advanced_settings_troubleshooting_subtitle": "Aktiver ekstra funksjoner for feilsøking",
"advanced_settings_troubleshooting_title": "Feilsøking", "advanced_settings_troubleshooting_title": "Feilsøking",
@ -992,6 +996,7 @@
"filetype": "Filtype", "filetype": "Filtype",
"filter": "Filter", "filter": "Filter",
"filter_people": "Filtrer personer", "filter_people": "Filtrer personer",
"filter_places": "Filtrer steder",
"find_them_fast": "Finn dem raskt ved søking av navn", "find_them_fast": "Finn dem raskt ved søking av navn",
"fix_incorrect_match": "Fiks feilaktig match", "fix_incorrect_match": "Fiks feilaktig match",
"folder": "Folder", "folder": "Folder",
@ -1282,6 +1287,7 @@
"onboarding_welcome_user": "Velkommen, {user}", "onboarding_welcome_user": "Velkommen, {user}",
"online": "Tilkoblet", "online": "Tilkoblet",
"only_favorites": "Bare favoritter", "only_favorites": "Bare favoritter",
"open": "Åpne",
"open_in_map_view": "Åpne i kartvisning", "open_in_map_view": "Åpne i kartvisning",
"open_in_openstreetmap": "Åpne i OpenStreetMap", "open_in_openstreetmap": "Åpne i OpenStreetMap",
"open_the_search_filters": "Åpne søkefiltrene", "open_the_search_filters": "Åpne søkefiltrene",
@ -1426,6 +1432,8 @@
"recent_searches": "Nylige søk", "recent_searches": "Nylige søk",
"recently_added": "Nylig lagt til", "recently_added": "Nylig lagt til",
"recently_added_page_title": "Nylig lagt til", "recently_added_page_title": "Nylig lagt til",
"recently_taken": "Nylig tatt",
"recently_taken_page_title": "Nylig tatt",
"refresh": "Oppdater", "refresh": "Oppdater",
"refresh_encoded_videos": "Oppdater kodete videoer", "refresh_encoded_videos": "Oppdater kodete videoer",
"refresh_faces": "Oppdater ansikter", "refresh_faces": "Oppdater ansikter",

View File

@ -978,7 +978,7 @@
"external": "Zunanji", "external": "Zunanji",
"external_libraries": "Zunanje knjižnice", "external_libraries": "Zunanje knjižnice",
"external_network": "Zunanje omrežje", "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", "face_unassigned": "Nedodeljen",
"failed": "Ni uspelo", "failed": "Ni uspelo",
"failed_to_load_assets": "Sredstev ni bilo mogoče naložiti", "failed_to_load_assets": "Sredstev ni bilo mogoče naložiti",
@ -1125,7 +1125,7 @@
"local_network": "Lokalno omrežje", "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", "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": "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_choose_on_map": "Izberi na zemljevidu",
"location_picker_latitude_error": "Vnesi veljavno zemljepisno širino", "location_picker_latitude_error": "Vnesi veljavno zemljepisno širino",
"location_picker_latitude_hint": "Tukaj vnesi svojo zemljepisno širino", "location_picker_latitude_hint": "Tukaj vnesi svojo zemljepisno širino",
@ -1432,6 +1432,8 @@
"recent_searches": "Nedavna iskanja", "recent_searches": "Nedavna iskanja",
"recently_added": "Nedavno dodano", "recently_added": "Nedavno dodano",
"recently_added_page_title": "Nedavno dodano", "recently_added_page_title": "Nedavno dodano",
"recently_taken": "Nedavno uporabljen",
"recently_taken_page_title": "Nedavno Uporabljen",
"refresh": "Osveži", "refresh": "Osveži",
"refresh_encoded_videos": "Osveži kodirane videoposnetke", "refresh_encoded_videos": "Osveži kodirane videoposnetke",
"refresh_faces": "Osveži obraze", "refresh_faces": "Osveži obraze",

View File

@ -378,6 +378,7 @@
"advanced_settings_proxy_headers_title": "Proxy-headers", "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_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_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_tile_subtitle": "Avancerade användarinställningar",
"advanced_settings_troubleshooting_subtitle": "Aktivera funktioner för felsökning", "advanced_settings_troubleshooting_subtitle": "Aktivera funktioner för felsökning",
"advanced_settings_troubleshooting_title": "Felsökning", "advanced_settings_troubleshooting_title": "Felsökning",
@ -992,6 +993,7 @@
"filetype": "Filtyp", "filetype": "Filtyp",
"filter": "Filter", "filter": "Filter",
"filter_people": "Filtrera personer", "filter_people": "Filtrera personer",
"filter_places": "Filtrera platser",
"find_them_fast": "Hitta dem snabbt efter namn med sök", "find_them_fast": "Hitta dem snabbt efter namn med sök",
"fix_incorrect_match": "Fixa inkorrekt matchning", "fix_incorrect_match": "Fixa inkorrekt matchning",
"folder": "Mapp", "folder": "Mapp",

View File

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

View File

@ -1,40 +1,25 @@
package app.alextran.immich package app.alextran.immich
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context 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 android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin 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.BinaryMessenger
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel 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.security.MessageDigest
import java.io.FileInputStream import java.io.FileInputStream
import kotlinx.coroutines.* 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 methodChannel: MethodChannel? = null
private var fileTrashChannel: MethodChannel? = null
private var context: Context? = 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) { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
@ -44,10 +29,6 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
context = ctx context = ctx
methodChannel = MethodChannel(messenger, "immich/foregroundChannel") methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
methodChannel?.setMethodCallHandler(this) methodChannel?.setMethodCallHandler(this)
// Add file trash channel
fileTrashChannel = MethodChannel(messenger, "file_trash")
fileTrashChannel?.setMethodCallHandler(this)
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@ -57,14 +38,11 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
private fun onDetachedFromEngine() { private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null) methodChannel?.setMethodCallHandler(null)
methodChannel = 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!! val ctx = context!!
when (call.method) { when (call.method) {
// Existing BackgroundService methods
"enable" -> { "enable" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) 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() 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 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.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import androidx.annotation.NonNull import android.os.Bundle
import android.content.Intent
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin()) 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', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 193, "android.injected.version.code" => 195,
"android.injected.version.name" => "1.131.3", "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') 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_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201; CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@ -685,7 +685,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201; CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201; CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@ -748,7 +748,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201; CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -791,7 +791,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201; CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -831,7 +831,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201; CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

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

View File

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

View File

@ -65,7 +65,6 @@ enum StoreKey<T> {
// Video settings // Video settings
loadOriginalVideo<bool>._(136), loadOriginalVideo<bool>._(136),
manageLocalMediaAndroid<bool>._(137),
// Experimental stuff // Experimental stuff
photoManagerCustomFilter<bool>._(1000); 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 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart'; 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/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize; import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
import 'package:logging/logging.dart';
/// The local image provider for an asset /// The local image provider for an asset
/// Only viable /// Only viable
@ -15,11 +18,16 @@ class ImmichLocalThumbnailProvider
final Asset asset; final Asset asset;
final int height; final int height;
final int width; final int width;
final CacheManager? cacheManager;
final Logger log = Logger("ImmichLocalThumbnailProvider");
final String? userId;
ImmichLocalThumbnailProvider({ ImmichLocalThumbnailProvider({
required this.asset, required this.asset,
this.height = 256, this.height = 256,
this.width = 256, this.width = 256,
this.cacheManager,
this.userId,
}) : assert(asset.local != null, 'Only usable when asset.local is set'); }) : assert(asset.local != null, 'Only usable when asset.local is set');
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
@ -36,11 +44,10 @@ class ImmichLocalThumbnailProvider
ImmichLocalThumbnailProvider key, ImmichLocalThumbnailProvider key,
ImageDecoderCallback decode, ImageDecoderCallback decode,
) { ) {
final chunkEvents = StreamController<ImageChunkEvent>(); final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiImageStreamCompleter( return MultiImageStreamCompleter(
codec: _codec(key.asset, decode, chunkEvents), codec: _codec(key.asset, cache, decode),
scale: 1.0, scale: 1.0,
chunkEvents: chunkEvents.stream,
informationCollector: () sync* { informationCollector: () sync* {
yield ErrorDescription(key.asset.fileName); yield ErrorDescription(key.asset.fileName);
}, },
@ -50,25 +57,38 @@ class ImmichLocalThumbnailProvider
// Streams in each stage of the image as we ask for it // Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec( Stream<ui.Codec> _codec(
Asset assetData, Asset assetData,
CacheManager cache,
ImageDecoderCallback decode, ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* { ) async* {
final thumbBytes = await assetData.local final cacheKey =
?.thumbnailDataWithSize(ThumbnailSize(width, height)); '$userId${assetData.localId}${assetData.checksum}$width$height';
if (thumbBytes == null) { final fileFromCache = await cache.getFileFromCache(cacheKey);
chunkEvents.close(); 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( 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(thumbnailBytes);
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); final codec = await decode(buffer);
final codec = await decode(buffer); yield codec;
yield codec; await cache.putFile(cacheKey, thumbnailBytes);
} finally {
chunkEvents.close();
}
} }
@override @override

View File

@ -23,7 +23,6 @@ enum PendingAction {
assetDelete, assetDelete,
assetUploaded, assetUploaded,
assetHidden, assetHidden,
assetTrash,
} }
class PendingChange { class PendingChange {
@ -161,7 +160,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_upload_success', _handleOnUploadSuccess); socket.on('on_upload_success', _handleOnUploadSuccess);
socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_asset_delete', _handleOnAssetDelete); 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_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates); socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_stack_update', _handleServerUpdates); socket.on('on_asset_stack_update', _handleServerUpdates);
@ -208,26 +207,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_debounce.run(handlePendingChanges); _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 { Future<void> _handlePendingDeletes() async {
final deleteChanges = state.pendingChanges final deleteChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetDelete) .where((c) => c.action == PendingAction.assetDelete)
@ -288,7 +267,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
await _handlePendingUploaded(); await _handlePendingUploaded();
await _handlePendingDeletes(); await _handlePendingDeletes();
await _handlingPendingHidden(); await _handlingPendingHidden();
await _handlePendingTrashes();
} }
void _handleOnConfigUpdate(dynamic _) { void _handleOnConfigUpdate(dynamic _) {
@ -307,10 +285,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
void _handleOnAssetDelete(dynamic data) => void _handleOnAssetDelete(dynamic data) =>
addPendingChange(PendingAction.assetDelete, data); addPendingChange(PendingAction.assetDelete, data);
void _handleOnAssetTrash(dynamic data) {
addPendingChange(PendingAction.assetTrash, data);
}
void _handleOnAssetHidden(dynamic data) => void _handleOnAssetHidden(dynamic data) =>
addPendingChange(PendingAction.assetHidden, 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, 0,
), ),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false), advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5 logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false), preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true), loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),

View File

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/etag.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.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.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/exif.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/repositories/album.repository.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/album_media.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/etag.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.repository.dart';
import 'package:immich_mobile/repositories/partner_api.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/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/async_mutex.dart';
@ -53,8 +48,6 @@ final syncServiceProvider = Provider(
ref.watch(userRepositoryProvider), ref.watch(userRepositoryProvider),
ref.watch(userServiceProvider), ref.watch(userServiceProvider),
ref.watch(etagRepositoryProvider), ref.watch(etagRepositoryProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(localFilesManagerRepositoryProvider),
ref.watch(partnerApiRepositoryProvider), ref.watch(partnerApiRepositoryProvider),
ref.watch(userApiRepositoryProvider), ref.watch(userApiRepositoryProvider),
), ),
@ -76,8 +69,6 @@ class SyncService {
final IUserApiRepository _userApiRepository; final IUserApiRepository _userApiRepository;
final AsyncMutex _lock = AsyncMutex(); final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService'); final Logger _log = Logger('SyncService');
final AppSettingsService _appSettingsService;
final ILocalFilesManager _localFilesManager;
SyncService( SyncService(
this._hashService, this._hashService,
@ -91,8 +82,6 @@ class SyncService {
this._userRepository, this._userRepository,
this._userService, this._userService,
this._eTagRepository, this._eTagRepository,
this._appSettingsService,
this._localFilesManager,
this._partnerApiRepository, this._partnerApiRepository,
this._userApiRepository, this._userApiRepository,
); );
@ -249,19 +238,8 @@ class SyncService {
return null; 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 /// 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 { return _assetRepository.transaction(() async {
await _assetRepository.deleteAllByRemoteId( await _assetRepository.deleteAllByRemoteId(
idsToDelete, idsToDelete,
@ -271,12 +249,6 @@ class SyncService {
idsToDelete, idsToDelete,
state: AssetState.merged, state: AssetState.merged,
); );
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
await _moveToTrashMatchedAssets(idsToDelete);
}
if (merged.isEmpty) return; if (merged.isEmpty) return;
for (final Asset asset in merged) { for (final Asset asset in merged) {
asset.remoteId = null; asset.remoteId = null;
@ -818,27 +790,10 @@ class SyncService {
return (existing, toUpsert); 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) /// Inserts or updates the assets in the database with their ExifInfo (if any)
Future<void> upsertAssetsWithExif(List<Asset> assets) async { Future<void> upsertAssetsWithExif(List<Asset> assets) async {
if (assets.isEmpty) return; if (assets.isEmpty) return;
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
_toggleTrashStatusForAssets(assets);
}
try { try {
await _assetRepository.transaction(() async { await _assetRepository.transaction(() async {
await _assetRepository.updateAll(assets); 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 'dart:typed_data';
import 'package:flutter/material.dart'; 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_local_thumbnail_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/entities/asset.entity.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/immich_image.dart';
import 'package:immich_mobile/widgets/common/thumbhash_placeholder.dart'; import 'package:immich_mobile/widgets/common/thumbhash_placeholder.dart';
import 'package:octo_image/octo_image.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({ const ImmichThumbnail({
this.asset, this.asset,
this.width = 250, this.width = 250,
@ -31,6 +32,7 @@ class ImmichThumbnail extends HookWidget {
static ImageProvider imageProvider({ static ImageProvider imageProvider({
Asset? asset, Asset? asset,
String? assetId, String? assetId,
String? userId,
int thumbnailSize = 256, int thumbnailSize = 256,
}) { }) {
if (asset == null && assetId == null) { if (asset == null && assetId == null) {
@ -48,6 +50,7 @@ class ImmichThumbnail extends HookWidget {
asset: asset, asset: asset,
height: thumbnailSize, height: thumbnailSize,
width: thumbnailSize, width: thumbnailSize,
userId: userId,
); );
} else { } else {
return ImmichRemoteThumbnailProvider( return ImmichRemoteThumbnailProvider(
@ -59,8 +62,10 @@ class ImmichThumbnail extends HookWidget {
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
Uint8List? blurhash = useBlurHashRef(asset).value; Uint8List? blurhash = useBlurHashRef(asset).value;
final userId = ref.watch(currentUserProvider)?.id;
if (asset == null) { if (asset == null) {
return Container( return Container(
color: Colors.grey, color: Colors.grey,
@ -79,6 +84,7 @@ class ImmichThumbnail extends HookWidget {
octoSet: blurHashOrPlaceholder(blurhash), octoSet: blurHashOrPlaceholder(blurhash),
image: ImmichThumbnail.imageProvider( image: ImmichThumbnail.imageProvider(
asset: asset, asset: asset,
userId: userId,
), ),
width: width, width: width,
height: height, height: height,

View File

@ -1,13 +1,11 @@
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/providers/user.provider.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/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
@ -27,8 +25,6 @@ class AdvancedSettings extends HookConsumerWidget {
final advancedTroubleshooting = final advancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid =
useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel); final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert = final allowSelfSignedSSLCert =
@ -44,16 +40,6 @@ class AdvancedSettings extends HookConsumerWidget {
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()), 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 = [ final advancedSettings = [
SettingsSwitchListTile( SettingsSwitchListTile(
enabled: true, enabled: true,
@ -61,29 +47,6 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(), title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".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( SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(args: [logLevel]), text: "advanced_settings_log_level_title".tr(args: [logLevel]),
valueNotifier: levelId, 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: 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 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@ -13,37 +13,58 @@ part of openapi.api;
class OAuthCallbackDto { class OAuthCallbackDto {
/// Returns a new [OAuthCallbackDto] instance. /// Returns a new [OAuthCallbackDto] instance.
OAuthCallbackDto({ OAuthCallbackDto({
this.codeVerifier,
this.state,
required this.url, 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 url;
String state;
String codeVerifier;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) => identical(this, other) || other is OAuthCallbackDto &&
identical(this, other) || other.codeVerifier == codeVerifier &&
other is OAuthCallbackDto && other.state == state &&
other.url == url && other.url == url;
other.state == state &&
other.codeVerifier == codeVerifier;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(url.hashCode) + (state.hashCode) + (codeVerifier.hashCode); (codeVerifier == null ? 0 : codeVerifier!.hashCode) +
(state == null ? 0 : state!.hashCode) +
(url.hashCode);
@override @override
String toString() => String toString() => 'OAuthCallbackDto[codeVerifier=$codeVerifier, state=$state, url=$url]';
'OAuthCallbackDto[url=$url, state=$state, codeVerifier=$codeVerifier]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'url'] = this.url; if (this.codeVerifier != null) {
json[r'state'] = this.state; json[r'codeVerifier'] = this.codeVerifier;
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; return json;
} }
@ -56,18 +77,15 @@ class OAuthCallbackDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return OAuthCallbackDto( return OAuthCallbackDto(
codeVerifier: mapValueOfType<String>(json, r'codeVerifier'),
state: mapValueOfType<String>(json, r'state'),
url: mapValueOfType<String>(json, r'url')!, url: mapValueOfType<String>(json, r'url')!,
state: mapValueOfType<String>(json, r'state')!,
codeVerifier: mapValueOfType<String>(json, r'codeVerifier')!,
); );
} }
return null; return null;
} }
static List<OAuthCallbackDto> listFromJson( static List<OAuthCallbackDto> listFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final result = <OAuthCallbackDto>[]; final result = <OAuthCallbackDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { 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 // maps a json object with a list of OAuthCallbackDto-objects as value to a dart map
static Map<String, List<OAuthCallbackDto>> mapListFromJson( static Map<String, List<OAuthCallbackDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final map = <String, List<OAuthCallbackDto>>{}; final map = <String, List<OAuthCallbackDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments // ignore: parameter_assignments
json = json.cast<String, dynamic>(); json = json.cast<String, dynamic>();
for (final entry in json.entries) { for (final entry in json.entries) {
map[entry.key] = OAuthCallbackDto.listFromJson( map[entry.key] = OAuthCallbackDto.listFromJson(entry.value, growable: growable,);
entry.value,
growable: growable,
);
} }
} }
return map; return map;
@ -116,7 +128,6 @@ class OAuthCallbackDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'url', 'url',
'state',
'codeVerifier',
}; };
} }

View File

@ -13,37 +13,58 @@ part of openapi.api;
class OAuthConfigDto { class OAuthConfigDto {
/// Returns a new [OAuthConfigDto] instance. /// Returns a new [OAuthConfigDto] instance.
OAuthConfigDto({ OAuthConfigDto({
this.codeChallenge,
required this.redirectUri, required this.redirectUri,
required this.state, this.state,
required this.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? codeChallenge;
String redirectUri; 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 @override
bool operator ==(Object other) => bool operator ==(Object other) => identical(this, other) || other is OAuthConfigDto &&
identical(this, other) || other.codeChallenge == codeChallenge &&
other is OAuthConfigDto && other.redirectUri == redirectUri &&
other.redirectUri == redirectUri && other.state == state;
other.state == state &&
other.codeChallenge == codeChallenge;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(redirectUri.hashCode) + (state.hashCode) + (codeChallenge.hashCode); (codeChallenge == null ? 0 : codeChallenge!.hashCode) +
(redirectUri.hashCode) +
(state == null ? 0 : state!.hashCode);
@override @override
String toString() => String toString() => 'OAuthConfigDto[codeChallenge=$codeChallenge, redirectUri=$redirectUri, state=$state]';
'OAuthConfigDto[redirectUri=$redirectUri, state=$state, codeChallenge=$codeChallenge]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'redirectUri'] = this.redirectUri; if (this.codeChallenge != null) {
json[r'state'] = this.state; json[r'codeChallenge'] = this.codeChallenge;
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; return json;
} }
@ -56,18 +77,15 @@ class OAuthConfigDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return OAuthConfigDto( return OAuthConfigDto(
codeChallenge: mapValueOfType<String>(json, r'codeChallenge'),
redirectUri: mapValueOfType<String>(json, r'redirectUri')!, redirectUri: mapValueOfType<String>(json, r'redirectUri')!,
state: mapValueOfType<String>(json, r'state')!, state: mapValueOfType<String>(json, r'state'),
codeChallenge: mapValueOfType<String>(json, r'codeChallenge')!,
); );
} }
return null; return null;
} }
static List<OAuthConfigDto> listFromJson( static List<OAuthConfigDto> listFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final result = <OAuthConfigDto>[]; final result = <OAuthConfigDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { 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 // maps a json object with a list of OAuthConfigDto-objects as value to a dart map
static Map<String, List<OAuthConfigDto>> mapListFromJson( static Map<String, List<OAuthConfigDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final map = <String, List<OAuthConfigDto>>{}; final map = <String, List<OAuthConfigDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments // ignore: parameter_assignments
json = json.cast<String, dynamic>(); json = json.cast<String, dynamic>();
for (final entry in json.entries) { for (final entry in json.entries) {
map[entry.key] = OAuthConfigDto.listFromJson( map[entry.key] = OAuthConfigDto.listFromJson(entry.value, growable: growable,);
entry.value,
growable: growable,
);
} }
} }
return map; return map;
@ -116,7 +128,6 @@ class OAuthConfigDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'redirectUri', 'redirectUri',
'state',
'codeChallenge',
}; };
} }

View File

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

View File

@ -60,9 +60,6 @@ void main() {
final MockAlbumMediaRepository albumMediaRepository = final MockAlbumMediaRepository albumMediaRepository =
MockAlbumMediaRepository(); MockAlbumMediaRepository();
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
final MockAppSettingService appSettingService = MockAppSettingService();
final MockLocalFilesManagerRepository localFilesManagerRepository =
MockLocalFilesManagerRepository();
final MockPartnerApiRepository partnerApiRepository = final MockPartnerApiRepository partnerApiRepository =
MockPartnerApiRepository(); MockPartnerApiRepository();
final MockUserApiRepository userApiRepository = MockUserApiRepository(); final MockUserApiRepository userApiRepository = MockUserApiRepository();
@ -109,8 +106,6 @@ void main() {
userRepository, userRepository,
userService, userService,
eTagRepository, eTagRepository,
appSettingService,
localFilesManagerRepository,
partnerApiRepository, partnerApiRepository,
userApiRepository, 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/backup_album.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/file_media.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.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
@ -42,9 +41,6 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
class MockAuthRepository extends Mock implements IAuthRepository {} class MockAuthRepository extends Mock implements IAuthRepository {}
class MockPartnerRepository extends Mock implements IPartnerRepository {}
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {} class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
class MockLocalFilesManagerRepository extends Mock class MockPartnerRepository extends Mock implements IPartnerRepository {}
implements ILocalFilesManager {}

View File

@ -1,6 +1,5 @@
import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.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/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/entity.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 MockSearchApi extends Mock implements SearchApi {}
class MockAppSettingService extends Mock implements AppSettingsService {}
class MockBackgroundService extends Mock implements BackgroundService {} class MockBackgroundService extends Mock implements BackgroundService {}

View File

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

View File

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

View File

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

View File

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

View File

@ -6,14 +6,14 @@ WORKDIR /usr/src/app
COPY server/package.json server/package-lock.json ./ COPY server/package.json server/package-lock.json ./
COPY server/patches ./patches COPY server/patches ./patches
RUN npm ci && \ RUN npm ci && \
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need # 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 # 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-libvips* && \
rm -rf node_modules/@img/sharp-linuxmusl-x64 rm -rf node_modules/@img/sharp-linuxmusl-x64
ENV PATH="${PATH}:/usr/src/app/bin" \ ENV PATH="${PATH}:/usr/src/app/bin" \
IMMICH_ENV=development \ IMMICH_ENV=development \
NVIDIA_DRIVER_CAPABILITIES=all \ NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all NVIDIA_VISIBLE_DEVICES=all
ENTRYPOINT ["tini", "--", "/bin/sh"] ENTRYPOINT ["tini", "--", "/bin/sh"]
@ -47,8 +47,8 @@ FROM ghcr.io/immich-app/base-server-prod:202504081114@sha256:8353bcbdb4e6579300a
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
NVIDIA_DRIVER_CAPABILITIES=all \ NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all NVIDIA_VISIBLE_DEVICES=all
COPY --from=prod /usr/src/app/node_modules ./node_modules COPY --from=prod /usr/src/app/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist COPY --from=prod /usr/src/app/dist ./dist
COPY --from=prod /usr/src/app/bin ./bin 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", "name": "immich",
"version": "1.131.3", "version": "1.132.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@ -88,7 +88,7 @@
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sanitize-html": "^2.14.0", "sanitize-html": "^2.14.0",
"semver": "^7.6.2", "semver": "^7.6.2",
"sharp": "^0.33.5", "sharp": "^0.34.0",
"sirv": "^3.0.0", "sirv": "^3.0.0",
"tailwindcss-preset-email": "^1.3.2", "tailwindcss-preset-email": "^1.3.2",
"thumbhash": "^0.1.1", "thumbhash": "^0.1.1",
@ -132,7 +132,8 @@
"globals": "^16.0.0", "globals": "^16.0.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"mock-fs": "^5.2.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", "patch-package": "^8.0.0",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"prettier": "^3.0.2", "prettier": "^3.0.2",
@ -152,5 +153,8 @@
}, },
"volta": { "volta": {
"node": "22.14.0" "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 { repositories } from 'src/repositories';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository'; import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository'; import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { services } from 'src/services'; import { services } from 'src/services';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { CliService } from 'src/services/cli.service'; import { CliService } from 'src/services/cli.service';
import { JobService } from 'src/services/job.service';
import { getKyselyConfig } from 'src/utils/database'; import { getKyselyConfig } from 'src/utils/database';
const common = [...repositories, ...services, GlobalExceptionFilter]; const common = [...repositories, ...services, GlobalExceptionFilter];
@ -52,7 +52,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
@Inject(IWorker) private worker: ImmichWorker, @Inject(IWorker) private worker: ImmichWorker,
logger: LoggingRepository, logger: LoggingRepository,
private eventRepository: EventRepository, private eventRepository: EventRepository,
private jobRepository: JobRepository, private jobService: JobService,
private telemetryRepository: TelemetryRepository, private telemetryRepository: TelemetryRepository,
private authService: AuthService, private authService: AuthService,
) { ) {
@ -62,10 +62,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { async onModuleInit() {
this.telemetryRepository.setup({ repositories }); this.telemetryRepository.setup({ repositories });
this.jobRepository.setup({ services }); this.jobService.setServices(services);
if (this.worker === ImmichWorker.MICROSERVICES) {
this.jobRepository.startWorkers();
}
this.eventRepository.setAuthFn(async (client) => this.eventRepository.setAuthFn(async (client) =>
this.authService.authenticate({ this.authService.authenticate({

View File

@ -407,6 +407,8 @@ export enum DatabaseExtension {
export enum BootstrapEventPriority { export enum BootstrapEventPriority {
// Database service should be initialized before anything else, most other services need database access // Database service should be initialized before anything else, most other services need database access
DatabaseService = -200, 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 // Initialise config after other bootstrap services, stop other services from using config on bootstrap
SystemConfig = 100, SystemConfig = 100,
} }

View File

@ -33,7 +33,7 @@ export class JobRepository {
this.logger.setContext(JobRepository.name); this.logger.setContext(JobRepository.name);
} }
setup({ services }: { services: ClassConstructor<unknown>[] }) { setup(services: ClassConstructor<unknown>[]) {
const reflector = this.moduleRef.get(Reflector, { strict: false }); const reflector = this.moduleRef.get(Reflector, { strict: false });
// discovery // 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> { 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([ const lockfile: BuildLockfile | undefined = await readFile(resourcePaths.lockFile)
maybeFirstLine('node --version'), .then((buffer) => JSON.parse(buffer.toString()))
maybeFirstLine('ffmpeg -version'), .catch(() => this.logger.warn(`Failed to read ${resourcePaths.lockFile}`));
maybeFirstLine('convert --version'),
]);
const lockfile = await readFile(resourcePaths.lockFile) const [nodejsVersion, ffmpegVersion, magickVersion, exiftoolVersion] = await Promise.all([
.then((buffer) => JSON.parse(buffer.toString())) this.retrieveVersionFallback('node --version', undefined, nodeVersion),
.catch(() => this.logger.warn(`Failed to read ${resourcePaths.lockFile}`)); 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 { const libvipsVersion = getLockfileVersion('libvips', lockfile) || sharp.versions.vips;
nodejs: nodejsOutput || nodeVersion || '',
exiftool: await exiftool.version(), this.buildVersions = {
ffmpeg: getLockfileVersion('ffmpeg', lockfile) || ffmpegOutput.replaceAll('ffmpeg version', '') || '', nodejs: nodejsVersion,
libvips: getLockfileVersion('libvips', lockfile) || sharp.versions.vips, exiftool: exiftoolVersion,
imagemagick: ffmpeg: ffmpegVersion,
getLockfileVersion('imagemagick', lockfile) || magickOutput.replaceAll('Version: ImageMagick ', '') || '', libvips: libvipsVersion,
}; imagemagick: magickVersion,
};
}
return this.buildVersions;
} }
} }

View File

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

View File

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

View File

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

6
web/package-lock.json generated
View File

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

View File

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

View File

@ -76,7 +76,7 @@
<div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}> <div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
{#each memoryStore.memories as memory (memory.id)} {#each memoryStore.memories as memory (memory.id)}
<a <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}" href="{AppRoute.MEMORY}?{QueryParameter.ID}={memory.assets[0].id}"
> >
<img <img