Merge branch 'main' of github.com:immich-app/immich into mobile/collections

This commit is contained in:
Alex 2024-09-08 09:53:57 -05:00
commit f73deae77c
No known key found for this signature in database
GPG Key ID: 53CD082B3A5E1082
133 changed files with 1060 additions and 487 deletions

3
.github/labeler.yml vendored
View File

@ -33,3 +33,6 @@ documentation:
- changed-files:
- any-glob-to-any-file:
- machine-learning/app/**
changelog:translation:
- head-branch: ['^chore/translations$']

View File

@ -40,6 +40,57 @@ jobs:
id: should_force
run: echo "should_force=${{ github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
retag_ml:
name: Re-Tag ML
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_ml == 'false' }}
runs-on: ubuntu-latest
strategy:
matrix:
suffix: ["", "-cuda", "-openvino", "-armnn"]
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
# Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Re-tag image
run: |
REGISTRY_NAME="ghcr.io"
REPOSITORY=${{ github.repository_owner }}/immich-machine-learning
TAG_OLD=main${{ matrix.suffix }}
TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }}
docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD
retag_server:
name: Re-Tag Server
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_server == 'false' }}
runs-on: ubuntu-latest
strategy:
matrix:
suffix: [""]
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
# Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Re-tag image
run: |
REGISTRY_NAME="ghcr.io"
REPOSITORY=${{ github.repository_owner }}/immich-server
TAG_OLD=main${{ matrix.suffix }}
TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }}
docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD
build_and_push_ml:
name: Build and Push ML
needs: pre-job
@ -235,9 +286,22 @@ jobs:
BUILD_SOURCE_REF=${{ github.ref_name }}
BUILD_SOURCE_COMMIT=${{ github.sha }}
success-check:
name: Docker Build & Push Success
needs: [build_and_push_ml, build_and_push_server]
success-check-server:
name: Docker Build & Push Server Success
needs: [build_and_push_server, retag_server]
runs-on: ubuntu-latest
if: always()
steps:
- name: Any jobs failed?
if: ${{ contains(needs.*.result, 'failure') }}
run: exit 1
- name: All jobs passed or skipped
if: ${{ !(contains(needs.*.result, 'failure')) }}
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}"
success-check-ml:
name: Docker Build & Push ML Success
needs: [build_and_push_ml, retag_ml]
runs-on: ubuntu-latest
if: always()
steps:

View File

@ -92,7 +92,7 @@ For the mobile app, you can use `https://demo.immich.app/api` for the `Server En
| LivePhoto/MotionPhoto backup and playback | Yes | Yes |
| Support 360 degree image display | No | Yes |
| User-defined storage structure | Yes | Yes |
| Public Sharing | No | Yes |
| Public Sharing | Yes | Yes |
| Archive and Favorites | Yes | Yes |
| Global Map | Yes | Yes |
| Partner Sharing | Yes | Yes |

6
cli/package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.17",
"version": "2.2.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.17",
"version": "2.2.18",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"fast-glob": "^3.3.2",
@ -52,7 +52,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.113.1",
"version": "1.114.0",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

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

View File

@ -98,7 +98,7 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
healthcheck:
test: redis-cli ping || exit 1

View File

@ -47,7 +47,7 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@ -48,7 +48,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
image: docker.io/redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@ -27,3 +27,9 @@ If an asset is in multiple albums, `{{album}}` will be set to the name of the al
:::
Immich also provides a mechanism to migrate between templates so that if the template you set now doesn't work in the future, you can always migrate all the existing files to the new template. The mechanism is run as a job on the Job page.
If you want to store assets in album folders, but you also have assets that do not belong to any album, you can use `{{#if album}}`, `{{else}}` and `{{/if}}` to create a conditional statement. For example, the following template will store assets in album folders if they belong to an album, and in a folder named "Other/Month" if they do not belong to an album:
```
{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}
```

View File

@ -1,4 +1,8 @@
[
{
"label": "v1.114.0",
"url": "https://v1.114.0.archive.immich.app"
},
{
"label": "v1.113.1",
"url": "https://v1.113.1.archive.immich.app"

View File

@ -33,7 +33,7 @@ services:
- 2285:3001
redis:
image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
database:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0

8
e2e/package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.113.1",
"version": "1.114.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.113.1",
"version": "1.114.0",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@ -45,7 +45,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.17",
"version": "2.2.18",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@ -92,7 +92,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.113.1",
"version": "1.114.0",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

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

View File

@ -44,7 +44,7 @@ test.describe('Shared Links', () => {
test('download from a shared link', async ({ page }) => {
await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.locator('.group').first().hover();
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
await page.waitForSelector('#asset-group-by-date svg');
await page.getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download' }).click();
@ -69,4 +69,15 @@ test.describe('Shared Links', () => {
await page.goto('/share/invalid');
await page.getByRole('heading', { name: 'Invalid share key' }).waitFor();
});
test('auth on navigation from shared link to timeline', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.locator('a[href="/"]').click();
await page.waitForURL('/photos');
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
});
});

View File

@ -40,11 +40,10 @@ FROM prod-cpu AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-core_1.0.17193.4_amd64.deb && \
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-opencl_1.0.17193.4_amd64.deb && \
wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd-dbgsym_24.26.30049.6_amd64.ddeb && \
wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd_24.26.30049.6_amd64.deb && \
wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/libigdgmm12_22.3.20_amd64.deb && \
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \
wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \
wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.113.1"
version = "1.114.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View File

@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 157,
"android.injected.version.name" => "1.113.1",
"android.injected.version.code" => 158,
"android.injected.version.name" => "1.114.0",
}
)
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

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "حذف الرابط المشترك",
"description_input_hint_text": "اضف وصفا...",
"description_input_submit_error": "خطأ تحديث الوصف ، تحقق من السجل لمزيد من التفاصيل",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "التاريخ و الوقت",
"edit_date_time_dialog_timezone": "وحدة زمنية",
"edit_image_title": "Edit",

View File

@ -63,7 +63,7 @@
"assets_trashed_from_server": "{} položek vyhozeno do koše na Immich serveru",
"asset_viewer_settings_title": "Prohlížeč",
"backup_album_selection_page_albums_device": "Alba v zařízení ({})",
"backup_album_selection_page_albums_tap": "Klepnutím na položku ji zahrnete, dvojím klepnutím ji vyloučíte",
"backup_album_selection_page_albums_tap": "Klepnutím na položku ji zahrnete, opětovným klepnutím ji vyloučíte",
"backup_album_selection_page_assets_scatter": "Položky mohou být roztroušeny ve více albech. To umožňuje zahrnout nebo vyloučit alba během procesu zálohování.",
"backup_album_selection_page_select_albums": "Vybraná alba",
"backup_album_selection_page_selection_info": "Informace o výběru",
@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Odstranit sdílený odkaz",
"description_input_hint_text": "Přidat popis...",
"description_input_submit_error": "Chyba aktualizace popisu, další podrobnosti najdete v logu",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Datum a čas",
"edit_date_time_dialog_timezone": "Časové pásmo",
"edit_image_title": "Upravit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Slet delt link",
"description_input_hint_text": "Tilføj en beskrivelse...",
"description_input_submit_error": "Fejl med at opdatere beskrivelsen. Tjek loggen for flere detaljer",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Dato og klokkeslæt",
"edit_date_time_dialog_timezone": "Tidszone",
"edit_image_title": "Rediger",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Geteilten Link löschen",
"description_input_hint_text": "Beschreibung hinzufügen...",
"description_input_submit_error": "Beschreibung konnte nicht geändert werden, bitte im Log für mehr Details nachsehen.",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Datum und Uhrzeit",
"edit_date_time_dialog_timezone": "Zeitzone",
"edit_image_title": "Bearbeiten",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Διαγραφή Κοινοποιημένου Συνδέσμου",
"description_input_hint_text": "Προσθήκη περιγραφής...",
"description_input_submit_error": "Σφάλμα κατά την ενημέρωση της περιγραφής, ελέγξτε το αρχείο καταγραφής για περισσότερες λεπτομέρειες",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Ημερομηνία και Ώρα",
"edit_date_time_dialog_timezone": "Ζώνη ώρας",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",
@ -252,10 +256,9 @@
"home_page_share_err_local": "Can not share local assets via link, skipping",
"home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
"image_saved_successfully": "Image saved",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"image_viewer_page_state_provider_download_error": "Download Error",
"image_viewer_page_state_provider_download_started": "Download Started",
"image_viewer_page_state_provider_download_success": "Download Success",
"image_viewer_page_state_provider_share_error": "Share Error",
"invalid_date": "Invalid date",
"invalid_date_format": "Invalid date format",
@ -586,4 +589,4 @@
"viewer_remove_from_stack": "Remove from Stack",
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack"
}
}

View File

@ -173,8 +173,8 @@
"control_bottom_app_bar_delete": "Eliminar",
"control_bottom_app_bar_delete_from_immich": "Borrar de Immich",
"control_bottom_app_bar_delete_from_local": "Borrar del dispositivo",
"control_bottom_app_bar_download": "Download",
"control_bottom_app_bar_edit": "Edit",
"control_bottom_app_bar_download": "Descargar",
"control_bottom_app_bar_edit": "Editar",
"control_bottom_app_bar_edit_location": "Editar ubicación",
"control_bottom_app_bar_edit_time": "Editar fecha y hora",
"control_bottom_app_bar_favorite": "Favorito",
@ -190,7 +190,7 @@
"create_shared_album_page_share": "Compartir",
"create_shared_album_page_share_add_assets": "AGREGAR ELEMENTOS",
"create_shared_album_page_share_select_photos": "Seleccionar Fotos",
"crop": "Crop",
"crop": "Recortar",
"curated_location_page_title": "Lugares",
"curated_object_page_title": "Objetos",
"daily_title_text_date": "E dd, MMM",
@ -210,9 +210,13 @@
"delete_shared_link_dialog_title": "Eliminar enlace compartido",
"description_input_hint_text": "Agregar descripción...",
"description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Fecha y Hora",
"edit_date_time_dialog_timezone": "Zona horaria",
"edit_image_title": "Edit",
"edit_image_title": "Editar",
"edit_location_dialog_title": "Ubicación",
"error_saving_image": "Error: {}",
"exif_bottom_sheet_description": "Agregar Descripción...",
@ -251,13 +255,13 @@
"home_page_first_time_notice": "Si esta es la primera vez que usas la app, por favor, asegúrate de elegir un álbum de respaldo para que la línea de tiempo pueda cargar fotos y videos en los álbumes.",
"home_page_share_err_local": "No se pueden compartir elementos locales a través de un enlace, omitiendo",
"home_page_upload_err_limit": "Solo se pueden subir 30 elementos simultáneamente, omitiendo",
"image_saved_successfully": "Image saved",
"image_saved_successfully": "Imágenes guardas",
"image_viewer_page_state_provider_download_error": "Error de descarga",
"image_viewer_page_state_provider_download_started": "Descarga Iniciada",
"image_viewer_page_state_provider_download_success": "Descarga exitosa",
"image_viewer_page_state_provider_share_error": "Error al compartir",
"invalid_date": "Invalid date",
"invalid_date_format": "Invalid date format",
"invalid_date": "Fecha incorrecta",
"invalid_date_format": "Formato de fecha incorrecto",
"library_page_albums": "Álbumes",
"library_page_archive": "Archivo",
"library_page_device_albums": "Álbumes en el dispositivo",
@ -380,27 +384,27 @@
"profile_drawer_sign_out": "Cerrar Sesión",
"profile_drawer_trash": "Papelera",
"recently_added_page_title": "Recién Agregadas",
"save_to_gallery": "Save to gallery",
"save_to_gallery": "Guardado en la galería",
"scaffold_body_error_occurred": "Ha ocurrido un error",
"search_bar_hint": "Busca tus fotos",
"search_filter_apply": "Aplicar filtros",
"search_filter_camera": "Camera",
"search_filter_camera": "Cámara",
"search_filter_camera_make": "Marca",
"search_filter_camera_model": "Modelo",
"search_filter_camera_title": "Select camera type",
"search_filter_date": "Date",
"search_filter_date_interval": "{start} to {end}",
"search_filter_date_title": "Select a date range",
"search_filter_date": "Fecha",
"search_filter_date_interval": "{start} al {end}",
"search_filter_date_title": "Selecciona un intervalo de fechas",
"search_filter_display_option_archive": "Archivado",
"search_filter_display_option_favorite": "Favorito",
"search_filter_display_option_not_in_album": "No en álbum",
"search_filter_display_options": "Display Options",
"search_filter_display_options_title": "Display options",
"search_filter_location": "Location",
"search_filter_location": "Ubicación",
"search_filter_location_city": "Ciudad",
"search_filter_location_country": "País",
"search_filter_location_state": "Estado",
"search_filter_location_title": "Select location",
"search_filter_location_title": "Seleccionar una ubicación",
"search_filter_media_type": "Media Type",
"search_filter_media_type_all": "Todos",
"search_filter_media_type_image": "Imagen",
@ -535,7 +539,7 @@
"sharing_silver_appbar_create_shared_album": "Crear un álbum compartido",
"sharing_silver_appbar_shared_links": "Enlaces compartidos",
"sharing_silver_appbar_share_partner": "Compartir con el compañero",
"sync": "Sync",
"sync": "Sincronizar",
"sync_albums": "Sync albums",
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
"sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Eliminar enlace compartido",
"description_input_hint_text": "Agregar descripción...",
"description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Eliminar enlace compartido",
"description_input_hint_text": "Agregar descripción...",
"description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Eliminar enlace compartido",
"description_input_hint_text": "Agregar descripción...",
"description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Poista jaettu linkki",
"description_input_hint_text": "Lisää kuvaus...",
"description_input_submit_error": "Virhe kuvauksen päivittämisessä, tarkista lisätiedot lokista",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Päivämäärä ja aika",
"edit_date_time_dialog_timezone": "Aikavyöhyke",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Supprimer le lien partagé",
"description_input_hint_text": "Ajouter une description...",
"description_input_submit_error": "Erreur de mise à jour de la description, vérifier le journal pour plus de détails",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Supprimer le lien partagé",
"description_input_hint_text": "Ajouter une description…",
"description_input_submit_error": "Erreur de mise à jour de la description, vérifier le journal pour plus de détails",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date et heure",
"edit_date_time_dialog_timezone": "Fuseau horaire",
"edit_image_title": "Edit",

View File

@ -190,7 +190,7 @@
"create_shared_album_page_share": "שתף",
"create_shared_album_page_share_add_assets": "הוסף נכסים",
"create_shared_album_page_share_select_photos": "בחירת תמונות",
"crop": "Crop",
"crop": "חתוך",
"curated_location_page_title": "מקומות",
"curated_object_page_title": "דברים",
"daily_title_text_date": "E, MMM dd",
@ -210,11 +210,15 @@
"delete_shared_link_dialog_title": "מחק קישור משותף",
"description_input_hint_text": "הוסף תיאור...",
"description_input_submit_error": "שגיאה בעדכון תיאור, בדוק את היומן לפרטים נוספים",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "תאריך וזמן",
"edit_date_time_dialog_timezone": "אזור זמן",
"edit_image_title": "Edit",
"edit_image_title": "ערוך",
"edit_location_dialog_title": "מיקום",
"error_saving_image": "Error: {}",
"error_saving_image": "שגיאה: {}",
"exif_bottom_sheet_description": "הוסף תיאור...",
"exif_bottom_sheet_details": "פרטים",
"exif_bottom_sheet_location": "מיקום",
@ -251,7 +255,7 @@
"home_page_first_time_notice": "אם זאת הפעם הראשונה שאת/ה משתמש/ת ביישום, נא להקפיד לבחור אלבומ(ים) לגיבוי כך שציר הזמן יוכל לאכלס תמונות וסרטונים באלבומ(ים)",
"home_page_share_err_local": "לא ניתן לשתף נכסים מקומיים על ידי קישור, מדלג",
"home_page_upload_err_limit": "ניתן להעלות רק מקסימום של 30 נכסים בכל פעם, מדלג",
"image_saved_successfully": "Image saved",
"image_saved_successfully": "תמונה נשמרה",
"image_viewer_page_state_provider_download_error": "שגיאת הורדה",
"image_viewer_page_state_provider_download_started": "ההורדה החלה",
"image_viewer_page_state_provider_download_success": "הצלחת הורדה",
@ -380,7 +384,7 @@
"profile_drawer_sign_out": "יציאה",
"profile_drawer_trash": "אשפה",
"recently_added_page_title": "נוסף לאחרונה",
"save_to_gallery": "Save to gallery",
"save_to_gallery": "שמור לגלריה",
"scaffold_body_error_occurred": "אירעה שגיאה",
"search_bar_hint": "חפש/י בתמונות שלך",
"search_filter_apply": "החל סינון",
@ -535,10 +539,10 @@
"sharing_silver_appbar_create_shared_album": "אלבום משותף חדש",
"sharing_silver_appbar_shared_links": "קישורים משותפים",
"sharing_silver_appbar_share_partner": "שיתוף עם שותף",
"sync": "Sync",
"sync_albums": "Sync albums",
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
"sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich",
"sync": "סנכרן",
"sync_albums": "סנכרן אלבומים",
"sync_albums_manual_subtitle": "סנכרן את כל הסרטונים והתמונות שהועלו לאלבומי הגיבוי שנבחרו",
"sync_upload_album_setting_subtitle": "צור והעלה תמונות וסרטונים שלך לאלבומים שנבחרו ביישום",
"tab_controller_nav_library": "ספרייה",
"tab_controller_nav_photos": "תמונות",
"tab_controller_nav_search": "חיפוש",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "साझा किए गए लिंक को हटाएं",
"description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Megosztott Link Törlése",
"description_input_hint_text": "Leírás hozzáadása...",
"description_input_submit_error": "Nem sikerült frissíteni a leírást. További információért kérjük, nézd meg az eseménynaplót",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Dátum és Idő",
"edit_date_time_dialog_timezone": "Időzóna",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Elimina link condiviso",
"description_input_hint_text": "Aggiungi descrizione...",
"description_input_submit_error": "Errore modificare descrizione, controlli I log per maggiori dettagli",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Data e ora",
"edit_date_time_dialog_timezone": "Fuso orario",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "共有リンクを消す",
"description_input_hint_text": "説明を追加",
"description_input_submit_error": "説明の編集に失敗しました。詳細はログを確認してください。",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "日付と時間",
"edit_date_time_dialog_timezone": "タイムゾーン",
"edit_image_title": "Edit",

View File

@ -31,7 +31,7 @@
"album_viewer_appbar_share_err_delete": "앨범을 삭제하지 못했습니다.",
"album_viewer_appbar_share_err_leave": "앨범에서 나가지 못했습니다.",
"album_viewer_appbar_share_err_remove": "앨범에서 항목을 제거하지 못했습니다.",
"album_viewer_appbar_share_err_title": "앨범 이름을 변경하지 못했습니다.",
"album_viewer_appbar_share_err_title": "앨범을 변경하지 못했습니다.",
"album_viewer_appbar_share_leave": "앨범 나가기",
"album_viewer_appbar_share_remove": "앨범에서 제거",
"album_viewer_appbar_share_to": "공유 대상",
@ -55,12 +55,12 @@
"asset_list_settings_subtitle": "사진 배열 레이아웃 설정",
"asset_list_settings_title": "사진 배열",
"asset_restored_successfully": "항목이 성공적으로 복원되었습니다.",
"assets_deleted_permanently": "{} 미디어가 영구 삭제됨",
"assets_deleted_permanently_from_server": "Immich 서버에서 {} 미디어가 영구 삭제되었습니다.",
"assets_removed_permanently_from_device": "장치에서 {} 미디어가 영구적으로 제거되었습니다.",
"assets_restored_successfully": "{} 미디어가 성공적으로 복원되었습니다.",
"assets_trashed": "{} 미디어가 휴지통에 버려졌습니다.",
"assets_trashed_from_server": "Immich 서버에서 {} 미디어를 삭제했습니다.",
"assets_deleted_permanently": "{}개 항목이 영구적으로 삭제됨",
"assets_deleted_permanently_from_server": "{}개 항목이 Immich 서버에서 영구적으로 삭제됨",
"assets_removed_permanently_from_device": "{}개 항목이 기기에서 영구적으로 삭제됨",
"assets_restored_successfully": "항목 {}개를 복원했습니다.",
"assets_trashed": "휴지통으로 {}개 항목이 이동되었습니다.",
"assets_trashed_from_server": "휴지통으로 Immich 서버의 {}개 항목이 이동되었습니다.",
"asset_viewer_settings_title": "보기 옵션",
"backup_album_selection_page_albums_device": "기기의 앨범 ({})",
"backup_album_selection_page_albums_tap": "한 번 눌러 선택, 두 번 눌러 제외하세요.",
@ -69,55 +69,55 @@
"backup_album_selection_page_selection_info": "선택한 앨범",
"backup_album_selection_page_total_assets": "전체 항목",
"backup_all": "모두",
"backup_background_service_backup_failed_message": "백업하지 못했습니다. 다시 시도하는 중...",
"backup_background_service_backup_failed_message": "항목을 백업하지 못했습니다. 다시 시도하는 중...",
"backup_background_service_connection_failed_message": "서버에 연결하지 못했습니다. 다시 시도하는 중...",
"backup_background_service_current_upload_notification": "{} 업로드 중",
"backup_background_service_default_notification": "백업할 항목을 확인하는 중...",
"backup_background_service_error_title": "백업 오류",
"backup_background_service_in_progress_notification": "선택한 항목을 백업하는 중...",
"backup_background_service_upload_failure_notification": "{} 업로드 실패",
"backup_controller_page_albums": "백업 대상 앨범",
"backup_controller_page_albums": "백업 앨범",
"backup_controller_page_background_app_refresh_disabled_content": "백그라운드 백업을 사용하려면 설정 > 일반 > 백그라운드 앱 새로 고침에서 백그라운드 앱 새로 고침을 활성화하세요.",
"backup_controller_page_background_app_refresh_disabled_title": "백그라운드 새로 고침 비활성화됨",
"backup_controller_page_background_app_refresh_enable_button_text": "설정으로 이동",
"backup_controller_page_background_battery_info_link": "설정 방법",
"backup_controller_page_background_battery_info_message": "최상의 백그라운드 백업 환경을 위해, Immich 백그라운드 활동을 제한하는 배터리 최적화를 비활성화하세요.\n\n설정 방법은 기기마다 다르므로, 제조 업체에서 관련 정보를 찾아보세요.",
"backup_controller_page_background_battery_info_message": "최상의 백그라운드 백업 환경을 위해 Immich 백그라운드 활동을 제한하는 배터리 최적화 기능을 비활성화하세요.\n\n기기마다 설정 방법에 차이가 있어 제조 업체에서 관련 정보를 찾아보세요.",
"backup_controller_page_background_battery_info_ok": "확인",
"backup_controller_page_background_battery_info_title": "배터리 최적화",
"backup_controller_page_background_charging": "충전 중에만",
"backup_controller_page_background_configure_error": "백그라운드 서비스 구성 실패",
"backup_controller_page_background_delay": "새 콘텐츠 백업 간격: {}",
"backup_controller_page_background_description": "백그라운드 서비스를 활성화하면 앱을 열지 않고도 새 콘텐츠를 자동으로 백업할 수 있습니다.",
"backup_controller_page_background_is_off": "자동 백그라운드 백업이 비활성화되었습니다.",
"backup_controller_page_background_is_on": "자동 백그라운드 백업이 활성화되었습니다.",
"backup_controller_page_background_description": "백그라운드 서비스를 활성화하여 앱을 실행하지 않고 새 항목을 자동으로 백업하세요.",
"backup_controller_page_background_is_off": "백그라운드 백업이 비활성화되었습니다.",
"backup_controller_page_background_is_on": "백그라운드 백업이 활성화되었습니다.",
"backup_controller_page_background_turn_off": "백그라운드 서비스 비활성화",
"backup_controller_page_background_turn_on": "백그라운드 서비스 활성화",
"backup_controller_page_background_wifi": "Wi-Fi에서만",
"backup_controller_page_backup": "백업",
"backup_controller_page_backup_selected": "선택: ",
"backup_controller_page_backup_selected": "선택:",
"backup_controller_page_backup_sub": "백업된 사진 및 동영상",
"backup_controller_page_cancel": "취소",
"backup_controller_page_created": "생성일: {}",
"backup_controller_page_desc_backup": "앱을 열 때 새 항목을 서버에 자동으로 업로드하려면 포그라운드 백업을 활성화하세요.",
"backup_controller_page_excluded": "제외: ",
"backup_controller_page_desc_backup": "포그라운드 백업을 활성화하여 앱을 시작할 때 새 항목을 서버에 자동으로 업로드하세요.",
"backup_controller_page_excluded": "제외:",
"backup_controller_page_failed": "실패 ({})",
"backup_controller_page_filename": "파일명: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "백업 정보",
"backup_controller_page_none_selected": "선택한 항목이 없습니다.",
"backup_controller_page_remainder": "남은 항목",
"backup_controller_page_remainder_sub": "백업 사진 및 동영상",
"backup_controller_page_remainder_sub": "백업 대기 중인 사진 및 동영상",
"backup_controller_page_select": "선택",
"backup_controller_page_server_storage": "저장 공간",
"backup_controller_page_start_backup": "백업 시작",
"backup_controller_page_status_off": "자동 백업이 비활성화되었습니다.",
"backup_controller_page_status_on": "자동 백업이 활성화되었습니다.",
"backup_controller_page_status_off": "포그라운드 백업이 비활성화되었습니다.",
"backup_controller_page_status_on": "포그라운드 백업이 활성화되었습니다.",
"backup_controller_page_storage_format": "{} 사용 중, 전체 {}",
"backup_controller_page_to_backup": "백업 대상 앨범 목록",
"backup_controller_page_to_backup": "백업 앨범 목록",
"backup_controller_page_total": "전체",
"backup_controller_page_total_sub": "선택한 앨범의 모든 사진 및 동영상",
"backup_controller_page_turn_off": "백업 비활성화",
"backup_controller_page_turn_on": "백업 활성화",
"backup_controller_page_total_sub": "선택한 앨범의 고유한 사진 및 동영상",
"backup_controller_page_turn_off": "비활성화",
"backup_controller_page_turn_on": "활성화",
"backup_controller_page_uploading_file_info": "파일 정보 업로드 중",
"backup_err_only_album": "유일한 앨범은 제거할 수 없습니다.",
"backup_info_card_assets": "항목",
@ -154,7 +154,7 @@
"client_cert_enter_password": "비밀번호 입력",
"client_cert_import": "가져오기",
"client_cert_import_success_msg": "클라이언트 인증서를 가져왔습니다.",
"client_cert_invalid_msg": "유효하지 않은 인증서이거나 비밀번호가 일치하지 않습니다.",
"client_cert_invalid_msg": "유효하지 않은 인증서 또는 패스프레이즈가 일치하지 않습니다.",
"client_cert_remove": "제거",
"client_cert_remove_msg": "클라이언트 인증서가 제거되었습니다.",
"client_cert_subtitle": "인증서 가져오기/제거는 로그인 전에만 가능합니다. PKCS12 (.p12, .pfx) 형식을 지원합니다.",
@ -210,11 +210,15 @@
"delete_shared_link_dialog_title": "공유 링크 삭제",
"description_input_hint_text": "설명 추가...",
"description_input_submit_error": "설명을 변경하는 중 문제가 발생했습니다. 자세한 내용은 로그를 참조하세요.",
"download_error": "다운로드 중 문제가 발생했습니다.",
"download_started": "다운로드가 시작되었습니다.",
"download_sucess": "다운로드가 완료되었습니다.",
"download_sucess_android": "미디어가 DCIM/Immich에 저장되었습니다.",
"edit_date_time_dialog_date_time": "날짜 및 시간",
"edit_date_time_dialog_timezone": "시간대",
"edit_image_title": "편집",
"edit_location_dialog_title": "위치",
"error_saving_image": "오류입니다: {}",
"error_saving_image": "오류: {}",
"exif_bottom_sheet_description": "설명 추가...",
"exif_bottom_sheet_details": "상세 정보",
"exif_bottom_sheet_location": "위치",
@ -251,7 +255,7 @@
"home_page_first_time_notice": "앱을 처음 사용하는 경우 타임라인에 앨범의 사진과 동영상을 채울 수 있도록 백업할 앨범을 선택하세요.",
"home_page_share_err_local": "기기의 항목은 링크로 공유할 수 없습니다. 건너뜁니다.",
"home_page_upload_err_limit": "한 번에 최대 30개의 항목만 업로드할 수 있습니다.",
"image_saved_successfully": "이미지 저장",
"image_saved_successfully": "이미지 저장되었습니다.",
"image_viewer_page_state_provider_download_error": "다운로드 오류",
"image_viewer_page_state_provider_download_started": "다운로드가 시작되었습니다.",
"image_viewer_page_state_provider_download_success": "다운로드 완료",
@ -282,14 +286,14 @@
"login_form_back_button_text": "뒤로",
"login_form_button_text": "로그인",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "https://your-server-ip:port/api",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
"login_form_endpoint_url": "서버 엔드포인트 URL",
"login_form_err_http": "http:// 또는 https://로 시작해야 합니다.",
"login_form_err_invalid_email": "유효하지 않은 이메일",
"login_form_err_invalid_url": "잘못된 URL입니다.",
"login_form_err_leading_whitespace": "앞에 공백 문자가 있습니다.",
"login_form_err_trailing_whitespace": "뒤에 공백 문자가 있습니다.",
"login_form_failed_get_oauth_server_config": "OAuth 로그인 중 문제 발생, 서버 URL을 확인해주세요.",
"login_form_err_leading_whitespace": "시작 부분에 공백이 있습니다.",
"login_form_err_trailing_whitespace": "끝 부분에 공백이 있습니다.",
"login_form_failed_get_oauth_server_config": "OAuth 로그인 중 문제 발생, 서버 URL을 확인세요.",
"login_form_failed_get_oauth_server_disable": "이 서버는 OAuth 기능을 지원하지 않습니다.",
"login_form_failed_login": "로그인 오류. 서버 URL, 이메일 및 비밀번호를 확인하세요.",
"login_form_handshake_exception": "서버와 통신 중 인증서 예외가 발생했습니다. 자체 서명된 인증서를 사용 중이라면, 설정에서 자체 서명된 인증서 허용을 활성화하세요.",
@ -343,7 +347,7 @@
"notification_permission_dialog_cancel": "취소",
"notification_permission_dialog_content": "알림을 활성화하려면 설정에서 알림 권한을 허용하세요.",
"notification_permission_dialog_settings": "설정",
"notification_permission_list_tile_content": "알림을 활성화하기 위해 권한을 부여하세요.",
"notification_permission_list_tile_content": "알림을 활성화하려면 권한을 부여하세요.",
"notification_permission_list_tile_enable_button": "알림 활성화",
"notification_permission_list_tile_title": "알림 권한",
"partner_list_user_photos": "{user}님의 사진",
@ -371,7 +375,7 @@
"profile_drawer_app_logs": "로그",
"profile_drawer_client_out_of_date_major": "모바일 앱이 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요.",
"profile_drawer_client_out_of_date_minor": "모바일 앱이 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요.",
"profile_drawer_client_server_up_to_date": "모바일 앱과 서버가 최신 버전입니다.",
"profile_drawer_client_server_up_to_date": "클라이언트와 서버가 최신입니다.",
"profile_drawer_documentation": "문서",
"profile_drawer_github": "Github",
"profile_drawer_server_out_of_date_major": "서버 버전이 최신이 아닙니다. 최신 버전으로 업데이트하세요.",
@ -389,7 +393,7 @@
"search_filter_camera_model": "모델명",
"search_filter_camera_title": "카메라 종류 선택",
"search_filter_date": "날짜",
"search_filter_date_interval": "{start}에서 {end} 까지",
"search_filter_date_interval": "{start} - {end}",
"search_filter_date_title": "날짜 범위 선택",
"search_filter_display_option_archive": "보관함",
"search_filter_display_option_favorite": "즐겨찾기",
@ -411,8 +415,8 @@
"search_page_categories": "분류",
"search_page_favorites": "즐겨찾기",
"search_page_motion_photos": "모션 포토",
"search_page_no_objects": "사물 정보가 없습니다.",
"search_page_no_places": "장소 정보가 없습니다.",
"search_page_no_objects": "사용 가능한 사물 정보 없음",
"search_page_no_places": "사용 가능한 위치 정보 없음",
"search_page_people": "인물",
"search_page_person_add_name_dialog_cancel": "취소",
"search_page_person_add_name_dialog_hint": "이름",
@ -435,7 +439,7 @@
"search_suggestion_list_smart_search_hint_2": "m:your-search-term",
"select_additional_user_for_sharing_page_suggestions": "추천",
"select_user_for_sharing_page_err_album": "앨범을 생성하지 못했습니다.",
"select_user_for_sharing_page_share_suggestions": "추천",
"select_user_for_sharing_page_share_suggestions": "제안",
"server_info_box_app_version": "앱 버전",
"server_info_box_latest_release": "최신 버전",
"server_info_box_server_url": "서버 URL",
@ -454,12 +458,12 @@
"setting_notifications_notify_minutes": "{}분 후",
"setting_notifications_notify_never": "알리지 않음",
"setting_notifications_notify_seconds": "{}초",
"setting_notifications_single_progress_subtitle": "각 항목의 세부 업로드 정보 표시",
"setting_notifications_single_progress_title": "백그라운드 작업의 세부 진행률 표시",
"setting_notifications_single_progress_subtitle": "개별 항목의 상세 업로드 정보 표시",
"setting_notifications_single_progress_title": "백그라운드 백업 상세 진행률 표시",
"setting_notifications_subtitle": "알림 기본 설정 조정",
"setting_notifications_title": "알림",
"setting_notifications_total_progress_subtitle": "전체 업로드 진행률 (완료/전체)",
"setting_notifications_total_progress_title": "백그라운드 작업의 전체 진행률 표시",
"setting_notifications_total_progress_title": "백그라운드 백업 전체 진행률 표시",
"setting_pages_app_bar_settings": "설정",
"settings_require_restart": "설정을 적용하려면 Immich를 다시 시작하세요.",
"setting_video_viewer_looping_subtitle": "상세 보기에서 동영상을 자동으로 반복합니다.",
@ -467,7 +471,7 @@
"setting_video_viewer_title": "동영상",
"share_add": "추가",
"share_add_photos": "사진 추가",
"share_add_title": "앨범 제목 입력",
"share_add_title": "앨범명 추가",
"share_assets_selected": "{}개 항목 선택됨",
"share_create_album": "앨범 생성",
"shared_album_activities_input_disable": "댓글이 비활성화되었습니다",
@ -528,7 +532,7 @@
"shared_link_manage_links": "공유 링크 관리",
"shared_link_public_album": "공개 앨범",
"share_done": "완료",
"share_invite": "앨범 초대",
"share_invite": "앨범으로 초대",
"sharing_page_album": "공유 앨범",
"sharing_page_description": "공유 앨범을 만들어 주변 사람들과 사진 및 동영상을 공유하세요.",
"sharing_page_empty_list": "공유 앨범 없음",
@ -537,8 +541,8 @@
"sharing_silver_appbar_share_partner": "파트너와 공유",
"sync": "동기화",
"sync_albums": "앨범 동기화",
"sync_albums_manual_subtitle": "업로드한 모든 동영상과 사진을 선택한 백업 앨범에 동기화합니다.",
"sync_upload_album_setting_subtitle": "Immich에서 선택한 앨범에 사진 및 동영상을 만들고 업로드하세요.",
"sync_albums_manual_subtitle": "업로드한 모든 동영상과 사진을 선택한 백업 앨범에 동기화",
"sync_upload_album_setting_subtitle": "선택한 앨범을 Immich에 생성하고 사진 및 동영상을 업로드하세요.",
"tab_controller_nav_library": "라이브러리",
"tab_controller_nav_photos": "사진",
"tab_controller_nav_search": "검색",
@ -546,7 +550,7 @@
"theme_setting_asset_list_storage_indicator_title": "항목에 스토리지 동기화 여부 표시",
"theme_setting_asset_list_tiles_per_row_title": "한 줄에 표시할 항목 수 ({})",
"theme_setting_colorful_interface_subtitle": "배경에 대표 색상을 적용합니다.",
"theme_setting_colorful_interface_title": "컬러풀 인터페이스",
"theme_setting_colorful_interface_title": "미려한 인터페이스",
"theme_setting_dark_mode_switch": "다크 모드",
"theme_setting_image_viewer_quality_subtitle": "상세 보기 이미지 품질 조정",
"theme_setting_image_viewer_quality_title": "이미지 보기 품질",
@ -559,7 +563,7 @@
"theme_setting_three_stage_loading_subtitle": "이 기능은 앱의 로드 성능을 향상시킬 수 있지만 더 많은 데이터를 사용합니다.",
"theme_setting_three_stage_loading_title": "3단계 로드 활성화",
"translated_text_options": "옵션",
"trash_emptied": "휴지통 비우기",
"trash_emptied": "휴지통을 비움",
"trash_page_delete": "삭제",
"trash_page_delete_all": "모두 삭제",
"trash_page_empty_trash_btn": "휴지통 비우기",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Dzēst Kopīgošanas saiti",
"description_input_hint_text": "Pievienot aprakstu...",
"description_input_submit_error": "Atjauninot aprakstu, radās kļūda; papildinformāciju skatiet žurnālā",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Datums un Laiks",
"edit_date_time_dialog_timezone": "Laika zona",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Slett delt link",
"description_input_hint_text": "Legg til beskrivelse ...",
"description_input_submit_error": "Feil ved oppdatering av beskrivelse, sjekk loggen for flere detaljer",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Dato og tid",
"edit_date_time_dialog_timezone": "Tidssone",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Verwijder gedeelde link",
"description_input_hint_text": "Beschrijving toevoegen...",
"description_input_submit_error": "Beschrijving bijwerken mislukt, controleer het logboek voor meer details",
"download_error": "Fout bij downloaden",
"download_started": "Download gestart",
"download_sucess": "Succesvol gedownload",
"download_sucess_android": "Het bestand is gedownload naar DCIM/Immich",
"edit_date_time_dialog_date_time": "Datum en tijd",
"edit_date_time_dialog_timezone": "Tijdzone",
"edit_image_title": "Bewerken",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Usuń udostępniony link",
"description_input_hint_text": "Dodaj opis...",
"description_input_submit_error": "Błąd aktualizacji opisu, sprawdź dziennik, aby uzyskać więcej szczegółów",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Data i godzina",
"edit_date_time_dialog_timezone": "Strefa czasowa",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Excluir link compartilhado",
"description_input_hint_text": "Adicionar descrição...",
"description_input_submit_error": "Erro ao atualizar a descrição, verifique o registo para obter mais detalhes",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Data e Hora",
"edit_date_time_dialog_timezone": "Fuso horário",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Șterge link distribuire",
"description_input_hint_text": "Adaugă descriere...",
"description_input_submit_error": "Eroare actualizare descriere, verifică log-urile pentru mai multe detalii",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Dată și Oră",
"edit_date_time_dialog_timezone": "Fus orar",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Удалить общую ссылку",
"description_input_hint_text": "Добавить описание...",
"description_input_submit_error": "Не удалось обновить описание, проверьте логи, чтобы узнать причину",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Дата и время",
"edit_date_time_dialog_timezone": "Часовой пояс",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Odstrániť zdieľaný odkaz",
"description_input_hint_text": "Pridať popis...",
"description_input_submit_error": "Chyba pri aktualizovaní popisu, zobrazte log pre viac detailov",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Dátum a čas",
"edit_date_time_dialog_timezone": "Časové pásmo",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Izbriši povezavo skupne rabe",
"description_input_hint_text": "Dodaj opis ...",
"description_input_submit_error": "Napaka pri posodabljanju opisa, preverite dnevnik za več podrobnosti",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Datum in ura",
"edit_date_time_dialog_timezone": "Časovni pas",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Ta Bort Delad Länk",
"description_input_hint_text": "Lägg till beskrivning...",
"description_input_submit_error": "Fel vid uppdatering av beskrivning, se loggen för fler detaljer",
"download_error": "Fel vid nedladdning",
"download_started": "Nedladdning påbörjad",
"download_sucess": "Nedladdning lyckades",
"download_sucess_android": "Media har laddats ner till DCIM/Immich",
"edit_date_time_dialog_date_time": "Datum och Tid",
"edit_date_time_dialog_timezone": "Tidszon",
"edit_image_title": "Redigera",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "ลบลิงก์ที่แชร์",
"description_input_hint_text": "เพื่มรายละเอียด...",
"description_input_submit_error": "อัพเดตรายละเอียดผิดพลาด ตรวจสอบ log เพื่อรายละเอียดเพิ่มเติม",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "วันและเวลา",
"edit_date_time_dialog_timezone": "เขดเวลา",
"edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Видалити спільне посилання",
"description_input_hint_text": "Додати опис...",
"description_input_submit_error": "Помилка оновлення опису, перевірте логи для подробиць",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Дата і час",
"edit_date_time_dialog_timezone": "Часовий пояс",
"edit_image_title": "Edit",

View File

@ -94,7 +94,7 @@
"backup_controller_page_background_turn_on": "Bật dịch vụ nền",
"backup_controller_page_background_wifi": "Chỉ khi dùng Wi-Fi",
"backup_controller_page_backup": "Sao lưu",
"backup_controller_page_backup_selected": "Đã chọn:",
"backup_controller_page_backup_selected": "Đã chọn: ",
"backup_controller_page_backup_sub": "Ảnh và video đã sao lưu",
"backup_controller_page_cancel": "Từ chối",
"backup_controller_page_created": "Tạo vào: {}",
@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Xoá liên kết đã chia sẻ",
"description_input_hint_text": "Thêm mô tả...",
"description_input_submit_error": "Cập nhật mô tả không thành công, vui lòng kiểm tra nhật ký để biết thêm chi tiết",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Ngày và Giờ",
"edit_date_time_dialog_timezone": "Múi giờ",
"edit_image_title": "Sửa",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "删除共享链接",
"description_input_hint_text": "添加描述...",
"description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息",
"download_error": "下载出错",
"download_started": "开始下载",
"download_sucess": "下载成功",
"download_sucess_android": "媒体已下载至 DCIM/Immich",
"edit_date_time_dialog_date_time": "日期和时间",
"edit_date_time_dialog_timezone": "时区",
"edit_image_title": "编辑",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "删除共享链接",
"description_input_hint_text": "添加描述...",
"description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息",
"download_error": "下载出错",
"download_started": "开始下载",
"download_sucess": "下载成功",
"download_sucess_android": "媒体已下载至 DCIM/Immich",
"edit_date_time_dialog_date_time": "日期和时间",
"edit_date_time_dialog_timezone": "时区",
"edit_image_title": "编辑",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details",
"download_error": "Download Error",
"download_started": "Download started",
"download_sucess": "Download success",
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",

View File

@ -401,7 +401,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 172;
CURRENT_PROJECT_VERSION = 173;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 172;
CURRENT_PROJECT_VERSION = 173;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -571,7 +571,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 172;
CURRENT_PROJECT_VERSION = 173;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@ -58,11 +58,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.113.1</string>
<string>1.114.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>172</string>
<string>173</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

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

View File

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

View File

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.113.1+157
version: 1.114.0+158
environment:
sdk: '>=3.3.0 <4.0.0'

View File

@ -7394,7 +7394,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.113.1",
"version": "1.114.0",
"contact": {}
},
"tags": [],

View File

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

View File

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

View File

@ -1,6 +1,6 @@
/**
* Immich
* 1.113.1
* 1.114.0
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.113.1",
"version": "1.114.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.113.1",
"version": "1.114.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^10.0.1",

View File

@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.113.1",
"version": "1.114.0",
"description": "",
"author": "",
"private": true,

View File

@ -75,6 +75,7 @@ export const supportedPresetTokens = [
'{{y}}/{{MM}}-{{dd}}/{{filename}}',
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}/{{filename}}',
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}',
'{{y}}/{{MMM}}/{{filename}}',
'{{y}}/{{MMMM}}/{{filename}}',
'{{y}}/{{MM}}/{{dd}}/{{filename}}',

View File

@ -12,12 +12,14 @@ export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadat
}
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export type SystemFlags = { mountFiles: boolean };
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
[SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string };
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
[SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string };
[SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date };
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
[SystemMetadataKey.SYSTEM_FLAGS]: SystemFlags;
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
}

View File

@ -153,6 +153,7 @@ export enum SystemMetadataKey {
FACIAL_RECOGNITION_STATE = 'facial-recognition-state',
ADMIN_ONBOARDING = 'admin-onboarding',
SYSTEM_CONFIG = 'system-config',
SYSTEM_FLAGS = 'system-flags',
VERSION_CHECK_STATE = 'version-check-state',
LICENSE = 'license',
}

View File

@ -15,6 +15,7 @@ export enum VectorIndex {
export enum DatabaseLock {
GeodataImport = 100,
Migrations = 200,
SystemFileMounts = 300,
StorageTemplateMigration = 420,
CLIPDimSize = 512,
LibraryWatch = 1337,

View File

@ -21,6 +21,9 @@ type EmitEventMap = {
'asset.tag': [{ assetId: string }];
'asset.untag': [{ assetId: string }];
// session events
'session.delete': [{ sessionId: string }];
// user events
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
};
@ -43,6 +46,7 @@ export enum ClientEvent {
SERVER_VERSION = 'on_server_version',
CONFIG_UPDATE = 'on_config_update',
NEW_RELEASE = 'on_new_release',
SESSION_DELETE = 'on_session_delete',
}
export interface ClientEventMap {
@ -58,6 +62,7 @@ export interface ClientEventMap {
[ClientEvent.SERVER_VERSION]: ServerVersionResponseDto;
[ClientEvent.CONFIG_UPDATE]: Record<string, never>;
[ClientEvent.NEW_RELEASE]: ReleaseNotification;
[ClientEvent.SESSION_DELETE]: string;
}
export enum ServerEvent {
@ -77,7 +82,7 @@ export interface IEventRepository {
/**
* Send to connected clients for a specific user
*/
clientSend<E extends keyof ClientEventMap>(event: E, userId: string, data: ClientEventMap[E]): void;
clientSend<E extends keyof ClientEventMap>(event: E, room: string, data: ClientEventMap[E]): void;
/**
* Send to all connected clients
*/

View File

@ -26,7 +26,7 @@ export interface MapMarker extends ReverseGeocodeResult {
export interface IMapRepository {
init(): Promise<void>;
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null>;
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
fetchStyle(url: string): Promise<any>;
}

View File

@ -50,7 +50,7 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
export interface IMetadataRepository {
teardown(): Promise<void>;
readTags(path: string): Promise<ImmichTags | null>;
readTags(path: string): Promise<ImmichTags>;
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
getCountries(userIds: string[]): Promise<Array<string | null>>;

View File

@ -17,7 +17,13 @@ async function bootstrapImmichAdmin() {
function bootstrapWorker(name: string) {
console.log(`Starting ${name} worker`);
const worker = name === 'api' ? fork(`./dist/workers/${name}.js`) : new Worker(`./dist/workers/${name}.js`);
worker.on('error', (error) => {
console.error(`${name} worker error: ${error}`);
});
worker.on('exit', (exitCode) => {
if (exitCode !== 0) {
console.error(`${name} worker exited with code ${exitCode}`);

View File

@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
OnGatewayConnection,
@ -37,7 +38,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
private server?: Server;
constructor(
private authService: AuthService,
private moduleRef: ModuleRef,
private eventEmitter: EventEmitter2,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
@ -62,12 +63,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
async handleConnection(client: Socket) {
try {
this.logger.log(`Websocket Connect: ${client.id}`);
const auth = await this.authService.authenticate({
const auth = await this.moduleRef.get(AuthService).authenticate({
headers: client.request.headers,
queryParams: {},
metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' },
});
await client.join(auth.user.id);
if (auth.session) {
await client.join(auth.session.id);
}
this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id });
} catch (error: Error | any) {
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
@ -96,8 +100,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
}
}
clientSend<E extends keyof ClientEventMap>(event: E, userId: string, data: ClientEventMap[E]) {
this.server?.to(userId).emit(event, data);
clientSend<E extends keyof ClientEventMap>(event: E, room: string, data: ClientEventMap[E]) {
this.server?.to(room).emit(event, data);
}
clientBroadcast<E extends keyof ClientEventMap>(event: E, data: ClientEventMap[E]) {

View File

@ -124,7 +124,7 @@ export class MapRepository implements IMapRepository {
}
}
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null> {
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
const response = await this.geodataPlacesRepository
@ -159,7 +159,7 @@ export class MapRepository implements IMapRepository {
`Response from database for natural earth reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`,
);
return null;
return { country: null, state: null, city: null };
}
this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`);

View File

@ -36,11 +36,11 @@ export class MetadataRepository implements IMetadataRepository {
await this.exiftool.end();
}
readTags(path: string): Promise<ImmichTags | null> {
readTags(path: string): Promise<ImmichTags> {
return this.exiftool.read(path).catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
return null;
}) as Promise<ImmichTags | null>;
return {};
}) as Promise<ImmichTags>;
}
extractBinaryTag(path: string, tagName: string): Promise<Buffer> {

View File

@ -6,6 +6,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SourceType } from 'src/enum';
import {
AssetFaceId,
DeleteAllFacesOptions,
@ -53,16 +54,20 @@ export class PersonRepository implements IPersonRepository {
}
async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise<void> {
if (sourceType) {
await this.assetFaceRepository
.createQueryBuilder('asset_faces')
.delete()
.andWhere('sourceType = :sourceType', { sourceType })
.execute();
return;
if (!sourceType) {
return this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
}
await this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
await this.assetFaceRepository
.createQueryBuilder('asset_faces')
.delete()
.andWhere('sourceType = :sourceType', { sourceType })
.execute();
await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search');
if (sourceType === SourceType.MACHINE_LEARNING) {
await this.assetFaceRepository.query('REINDEX INDEX face_index');
}
}
getAllFaces(

View File

@ -6,6 +6,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
@ -20,6 +21,7 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
@ -56,6 +58,7 @@ const oauthUserWithDefaultQuota = {
describe('AuthService', () => {
let sut: AuthService;
let cryptoMock: Mocked<ICryptoRepository>;
let eventMock: Mocked<IEventRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
@ -87,6 +90,7 @@ describe('AuthService', () => {
} as any);
cryptoMock = newCryptoRepositoryMock();
eventMock = newEventRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
@ -94,7 +98,7 @@ describe('AuthService', () => {
shareMock = newSharedLinkRepositoryMock();
keyMock = newKeyRepositoryMock();
sut = new AuthService(cryptoMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock);
sut = new AuthService(cryptoMock, eventMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock);
});
it('should be defined', () => {
@ -208,6 +212,7 @@ describe('AuthService', () => {
});
expect(sessionMock.delete).toHaveBeenCalledWith('token123');
expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' });
});
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {

View File

@ -34,6 +34,7 @@ import { UserEntity } from 'src/entities/user.entity';
import { Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
@ -75,6 +76,7 @@ export class AuthService {
constructor(
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@ -114,6 +116,7 @@ export class AuthService {
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
if (auth.session) {
await this.sessionRepository.delete(auth.session.id);
await this.eventRepository.emit('session.delete', { sessionId: auth.session.id });
}
return {

View File

@ -1247,6 +1247,7 @@ describe(MediaService.name, () => {
'-fps_mode passthrough',
'-map 0:0',
'-map 0:1',
'-strict unofficial',
'-v verbose',
'-vf scale=-2:720,format=yuv420p',
'-preset 12',
@ -1372,6 +1373,7 @@ describe(MediaService.name, () => {
'-fps_mode passthrough',
'-map 0:0',
'-map 0:1',
'-strict unofficial',
'-g 256',
'-v verbose',
'-vf format=nv12,hwupload_cuda,scale_cuda=-2:720',
@ -1532,6 +1534,7 @@ describe(MediaService.name, () => {
'-fps_mode passthrough',
'-map 0:0',
'-map 0:1',
'-strict unofficial',
'-bf 7',
'-refs 5',
'-g 256',
@ -1716,6 +1719,7 @@ describe(MediaService.name, () => {
'-fps_mode passthrough',
'-map 0:0',
'-map 0:1',
'-strict unofficial',
'-g 256',
'-v verbose',
'-vf format=nv12,hwupload,scale_vaapi=-2:720',
@ -1913,6 +1917,7 @@ describe(MediaService.name, () => {
'-fps_mode passthrough',
'-map 0:0',
'-map 0:1',
'-strict unofficial',
'-g 256',
'-v verbose',
'-vf scale_rkrga=-2:720:format=nv12:afbc=1',

View File

@ -522,13 +522,13 @@ describe(MetadataService.name, () => {
it('should extract the correct video orientation', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.video]);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
metadataMock.readTags.mockResolvedValue(null);
metadataMock.readTags.mockResolvedValue({});
await sut.handleMetadataExtraction({ id: assetStub.video.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]);
expect(assetMock.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ orientation: Orientation.Rotate270CW }),
expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }),
);
});
@ -814,6 +814,9 @@ describe(MetadataService.name, () => {
projectionType: 'EQUIRECTANGULAR',
timeZone: tags.tz,
rating: tags.Rating,
country: null,
state: null,
city: null,
});
expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.image.id,

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored';
import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import _ from 'lodash';
import { Duration } from 'luxon';
@ -11,7 +11,6 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { AssetType, SourceType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
@ -30,7 +29,7 @@ import {
QueueName,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMapRepository } from 'src/interfaces/map.interface';
import { IMapRepository, ReverseGeocodeResult } from 'src/interfaces/map.interface';
import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
@ -56,23 +55,16 @@ const EXIF_DATE_TAGS: Array<keyof Tags> = [
];
export enum Orientation {
Horizontal = '1',
MirrorHorizontal = '2',
Rotate180 = '3',
MirrorVertical = '4',
MirrorHorizontalRotate270CW = '5',
Rotate90CW = '6',
MirrorHorizontalRotate90CW = '7',
Rotate270CW = '8',
Horizontal = 1,
MirrorHorizontal = 2,
Rotate180 = 3,
MirrorVertical = 4,
MirrorHorizontalRotate270CW = 5,
Rotate90CW = 6,
MirrorHorizontalRotate90CW = 7,
Rotate270CW = 8,
}
type ExifEntityWithoutGeocodeAndTypeOrm = Omit<ExifEntity, 'city' | 'state' | 'country' | 'description'> & {
dateTimeOriginal: Date;
};
const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null);
const tzOffset = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.tzoffsetMinutes : null);
const validate = <T>(value: T): NonNullable<T> | null => {
// handle lists of numbers
if (Array.isArray(value)) {
@ -218,36 +210,73 @@ export class MetadataService {
}
async handleMetadataExtraction({ id }: IEntityJob): Promise<JobStatus> {
const { metadata } = await this.configCore.getConfig({ withCache: true });
const { metadata, reverseGeocoding } = await this.configCore.getConfig({ withCache: true });
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) {
return JobStatus.FAILED;
}
const { exifData, exifTags } = await this.exifData(asset);
const stats = await this.storageRepository.stat(asset.originalPath);
if (asset.type === AssetType.VIDEO) {
await this.applyVideoMetadata(asset, exifData);
}
const exifTags = await this.getExifTags(asset);
this.logger.verbose('Exif Tags', exifTags);
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
const exifData = {
assetId: asset.id,
// dates
dateTimeOriginal,
modifyDate,
timeZone,
// gps
latitude,
longitude,
country,
state,
city,
// image/file
fileSizeInByte: stats.size,
exifImageHeight: validate(exifTags.ImageHeight),
exifImageWidth: validate(exifTags.ImageWidth),
orientation: validate(exifTags.Orientation)?.toString() ?? null,
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
bitsPerSample: this.getBitsPerSample(exifTags),
colorspace: exifTags.ColorSpace ?? null,
// camera
make: exifTags.Make ?? null,
model: exifTags.Model ?? null,
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
iso: validate(exifTags.ISO),
exposureTime: exifTags.ExposureTime ?? null,
lensModel: exifTags.LensModel ?? null,
fNumber: validate(exifTags.FNumber),
focalLength: validate(exifTags.FocalLength),
// comments
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
profileDescription: exifTags.ProfileDescription || null,
rating: exifTags.Rating ?? null,
// grouping
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
autoStackId: this.getAutoStackId(exifTags),
};
await this.applyMotionPhotos(asset, exifTags);
await this.applyReverseGeocoding(asset, exifData);
await this.applyTagList(asset, exifTags);
await this.applyMotionPhotos(asset, exifTags);
await this.assetRepository.upsertExif(exifData);
const dateTimeOriginal = exifData.dateTimeOriginal;
let localDateTime = dateTimeOriginal ?? undefined;
const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0;
if (dateTimeOriginal && timeZoneOffset) {
localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
}
await this.assetRepository.update({
id: asset.id,
duration: asset.duration,
duration: exifTags.Duration?.toString() ?? null,
localDateTime,
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
});
@ -338,25 +367,20 @@ export class MetadataService {
return JobStatus.SUCCESS;
}
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
const { latitude, longitude } = exifData;
const { reverseGeocoding } = await this.configCore.getConfig({ withCache: true });
if (!reverseGeocoding.enabled || !longitude || !latitude) {
return;
private async getExifTags(asset: AssetEntity): Promise<ImmichTags> {
const mediaTags = await this.repository.readTags(asset.originalPath);
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : {};
const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {};
// make sure dates comes from sidecar
const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS);
if (sidecarDate) {
for (const tag of EXIF_DATE_TAGS) {
delete mediaTags[tag];
}
}
try {
const reverseGeocode = await this.mapRepository.reverseGeocode({ latitude, longitude });
if (!reverseGeocode) {
return;
}
Object.assign(exifData, reverseGeocode);
} catch (error: Error | any) {
this.logger.warn(
`Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
error?.stack,
);
}
return { ...mediaTags, ...videoTags, ...sidecarTags };
}
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
@ -576,66 +600,65 @@ export class MetadataService {
);
}
private async exifData(
asset: AssetEntity,
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> {
const stats = await this.storageRepository.stat(asset.originalPath);
const mediaTags = await this.repository.readTags(asset.originalPath);
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null;
private getDates(asset: AssetEntity, exifTags: ImmichTags) {
const dateTime = firstDateTime(exifTags as Maybe<Tags>, EXIF_DATE_TAGS);
this.logger.debug(`Asset ${asset.id} date time is ${dateTime}`);
// ensure date from sidecar is used if present
const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags);
if (mediaTags && hasDateOverride) {
for (const tag of EXIF_DATE_TAGS) {
delete mediaTags[tag];
}
// created
let dateTimeOriginal = dateTime?.toDate();
if (!dateTimeOriginal) {
this.logger.warn(`Asset ${asset.id} has no valid date (${dateTime}), falling back to asset.fileCreatedAt`);
dateTimeOriginal = asset.fileCreatedAt;
}
const exifTags = { ...mediaTags, ...sidecarTags };
// timezone
let timeZone = exifTags.tz ?? null;
if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) {
// exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly
// https://github.com/photostructure/exiftool-vendored.js/issues/203
timeZone = 'UTC+0';
}
this.logger.verbose('Exif Tags', exifTags);
if (timeZone) {
this.logger.debug(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`);
} else {
this.logger.warn(`Asset ${asset.id} has no time zone information`);
}
const dateTimeOriginalWithRawValue = this.getDateTimeOriginalWithRawValue(exifTags);
const dateTimeOriginal = dateTimeOriginalWithRawValue.exifDate ?? asset.fileCreatedAt;
const timeZone = this.getTimeZone(exifTags, dateTimeOriginalWithRawValue.rawValue);
// offset minutes
const offsetMinutes = dateTime?.tzoffsetMinutes || 0;
let localDateTime = dateTimeOriginal;
if (offsetMinutes) {
localDateTime = new Date(dateTimeOriginal.getTime() + offsetMinutes * 60_000);
this.logger.debug(`Asset ${asset.id} local time is offset by ${offsetMinutes} minutes`);
}
const exifData = {
// altitude: tags.GPSAltitude ?? null,
assetId: asset.id,
bitsPerSample: this.getBitsPerSample(exifTags),
colorspace: exifTags.ColorSpace ?? null,
return {
dateTimeOriginal,
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
exifImageHeight: validate(exifTags.ImageHeight),
exifImageWidth: validate(exifTags.ImageWidth),
exposureTime: exifTags.ExposureTime ?? null,
fileSizeInByte: stats.size,
fNumber: validate(exifTags.FNumber),
focalLength: validate(exifTags.FocalLength),
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
iso: validate(exifTags.ISO),
latitude: validate(exifTags.GPSLatitude),
lensModel: exifTags.LensModel ?? null,
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
autoStackId: this.getAutoStackId(exifTags),
longitude: validate(exifTags.GPSLongitude),
make: exifTags.Make ?? null,
model: exifTags.Model ?? null,
modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt,
orientation: validate(exifTags.Orientation)?.toString() ?? null,
profileDescription: exifTags.ProfileDescription || null,
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
timeZone,
rating: exifTags.Rating ?? null,
localDateTime,
modifyDate: (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? asset.fileModifiedAt,
};
}
if (exifData.latitude === 0 && exifData.longitude === 0) {
this.logger.warn('Exif data has latitude and longitude of 0, setting to null');
exifData.latitude = null;
exifData.longitude = null;
private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) {
let latitude = validate(tags.GPSLatitude);
let longitude = validate(tags.GPSLongitude);
// TODO take ref into account
if (latitude === 0 && longitude === 0) {
this.logger.warn('Latitude and longitude of 0, setting to null');
latitude = null;
longitude = null;
}
return { exifData, exifTags };
let result: ReverseGeocodeResult = { country: null, state: null, city: null };
if (reverseGeocoding.enabled && longitude && latitude) {
result = await this.mapRepository.reverseGeocode({ latitude, longitude });
}
return { ...result, latitude, longitude };
}
private getAutoStackId(tags: ImmichTags | null): string | null {
@ -645,28 +668,6 @@ export class MetadataService {
return tags.BurstID ?? tags.BurstUUID ?? tags.CameraBurstID ?? tags.MediaUniqueID ?? null;
}
private getDateTimeOriginal(tags: ImmichTags | Tags | null) {
return this.getDateTimeOriginalWithRawValue(tags).exifDate;
}
private getDateTimeOriginalWithRawValue(tags: ImmichTags | Tags | null): { exifDate: Date | null; rawValue: string } {
if (!tags) {
return { exifDate: null, rawValue: '' };
}
const first = firstDateTime(tags as Tags, EXIF_DATE_TAGS);
return { exifDate: exifDate(first), rawValue: first?.rawValue ?? '' };
}
private getTimeZone(exifTags: ImmichTags, rawValue: string) {
const timeZone = exifTags.tz ?? null;
if (timeZone == null && rawValue.endsWith('+00:00')) {
// exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly
// https://github.com/photostructure/exiftool-vendored.js/issues/203
return 'UTC+0';
}
return timeZone;
}
private getBitsPerSample(tags: ImmichTags): number | null {
const bitDepthTags = [
tags.BitsPerSample,
@ -685,33 +686,37 @@ export class MetadataService {
return bitsPerSample;
}
private async applyVideoMetadata(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
const { videoStreams, format } = await this.mediaRepository.probe(asset.originalPath);
private async getVideoTags(originalPath: string) {
const { videoStreams, format } = await this.mediaRepository.probe(originalPath);
const tags: Pick<ImmichTags, 'Duration' | 'Orientation'> = {};
if (videoStreams[0]) {
switch (videoStreams[0].rotation) {
case -90: {
exifData.orientation = Orientation.Rotate90CW;
tags.Orientation = Orientation.Rotate90CW;
break;
}
case 0: {
exifData.orientation = Orientation.Horizontal;
tags.Orientation = Orientation.Horizontal;
break;
}
case 90: {
exifData.orientation = Orientation.Rotate270CW;
tags.Orientation = Orientation.Rotate270CW;
break;
}
case 180: {
exifData.orientation = Orientation.Rotate180;
tags.Orientation = Orientation.Rotate180;
break;
}
}
}
if (format.duration) {
asset.duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS');
tags.Duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS');
}
return tags;
}
private async processSidecar(id: string, isSync: boolean): Promise<JobStatus> {

View File

@ -6,6 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
@ -17,6 +18,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock';
@ -64,6 +66,7 @@ const configs = {
describe(NotificationService.name, () => {
let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let notificationMock: Mocked<INotificationRepository>;
@ -74,13 +77,23 @@ describe(NotificationService.name, () => {
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
loggerMock = newLoggerRepositoryMock();
notificationMock = newNotificationRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock();
sut = new NotificationService(systemMock, notificationMock, userMock, jobMock, loggerMock, assetMock, albumMock);
sut = new NotificationService(
eventMock,
systemMock,
notificationMock,
userMock,
jobMock,
loggerMock,
assetMock,
albumMock,
);
});
it('should work', () => {

View File

@ -6,7 +6,7 @@ import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
import {
IEmailJob,
IJobRepository,
@ -30,6 +30,7 @@ export class NotificationService {
private configCore: SystemConfigCore;
constructor(
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(INotificationRepository) private notificationRepository: INotificationRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@ -74,6 +75,12 @@ export class NotificationService {
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } });
}
@OnEmit({ event: 'session.delete' })
onSessionDelete({ sessionId }: ArgOf<'session.delete'>) {
// after the response is sent
setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500);
}
async sendTestEmail(id: string, dto: SystemConfigSmtpDto) {
const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) {

View File

@ -1,19 +1,29 @@
import { SystemMetadataKey } from 'src/enum';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { StorageService } from 'src/services/storage.service';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest';
describe(StorageService.name, () => {
let sut: StorageService;
let databaseMock: Mocked<IDatabaseRepository>;
let storageMock: Mocked<IStorageRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => {
databaseMock = newDatabaseRepositoryMock();
storageMock = newStorageRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new StorageService(storageMock, loggerMock);
systemMock = newSystemMetadataRepositoryMock();
sut = new StorageService(databaseMock, storageMock, loggerMock, systemMock);
});
it('should work', () => {
@ -21,9 +31,35 @@ describe(StorageService.name, () => {
});
describe('onBootstrap', () => {
it('should create the library folder on initialization', () => {
sut.onBootstrap();
it('should enable mount folder checking', async () => {
systemMock.get.mockResolvedValue(null);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountFiles: true });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/encoded-video');
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library');
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile');
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs');
});
it('should throw an error if .immich is missing', async () => {
systemMock.get.mockResolvedValue({ mountFiles: true });
storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
expect(storageMock.writeFile).not.toHaveBeenCalled();
expect(systemMock.set).not.toHaveBeenCalled();
});
it('should throw an error if .immich is present but read-only', async () => {
systemMock.get.mockResolvedValue({ mountFiles: true });
storageMock.writeFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
expect(systemMock.set).not.toHaveBeenCalled();
});
});

View File

@ -1,23 +1,52 @@
import { Inject, Injectable } from '@nestjs/common';
import { join } from 'node:path';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { OnEmit } from 'src/decorators';
import { SystemMetadataKey } from 'src/enum';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ImmichStartupError } from 'src/utils/events';
@Injectable()
export class StorageService {
constructor(
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(ISystemMetadataRepository) private systemMetadata: ISystemMetadataRepository,
) {
this.logger.setContext(StorageService.name);
}
@OnEmit({ event: 'app.bootstrap' })
onBootstrap() {
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
this.storageRepository.mkdirSync(libraryBase);
async onBootstrap() {
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
this.logger.log('Verifying system mount folder checks');
// check each folder exists and is writable
for (const folder of Object.values(StorageFolder)) {
if (!flags.mountFiles) {
this.logger.log(`Writing initial mount file for the ${folder} folder`);
await this.verifyWriteAccess(folder);
}
await this.verifyReadAccess(folder);
await this.verifyWriteAccess(folder);
}
if (!flags.mountFiles) {
flags.mountFiles = true;
await this.systemMetadata.set(SystemMetadataKey.SYSTEM_FLAGS, flags);
this.logger.log('Successfully enabled system mount folders checks');
}
this.logger.log('Successfully verified system mount folder checks');
});
}
async handleDeleteFiles(job: IDeleteFilesJob) {
@ -38,4 +67,38 @@ export class StorageService {
return JobStatus.SUCCESS;
}
private async verifyReadAccess(folder: StorageFolder) {
const { filePath } = this.getMountFilePaths(folder);
try {
await this.storageRepository.readFile(filePath);
} catch (error) {
this.logger.error(`Failed to read ${filePath}: ${error}`);
this.logger.error(
`The "${folder}" folder appears to be offline/missing, please make sure the volume is mounted with the correct permissions`,
);
throw new ImmichStartupError(`Failed to validate folder mount (read from "<MEDIA_LOCATION>/${folder}")`);
}
}
private async verifyWriteAccess(folder: StorageFolder) {
const { folderPath, filePath } = this.getMountFilePaths(folder);
try {
this.storageRepository.mkdirSync(folderPath);
await this.storageRepository.writeFile(filePath, Buffer.from(`${Date.now()}`));
} catch (error) {
this.logger.error(`Failed to write ${filePath}: ${error}`);
this.logger.error(
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`,
);
throw new ImmichStartupError(`Failed to validate folder mount (write to "<MEDIA_LOCATION>/${folder}")`);
}
}
private getMountFilePaths(folder: StorageFolder) {
const folderPath = StorageCore.getBaseFolder(folder);
const filePath = join(folderPath, '.immich');
return { folderPath, filePath };
}
}

View File

@ -336,6 +336,7 @@ describe(SystemConfigService.name, () => {
'{{y}}/{{MM}}-{{dd}}/{{filename}}',
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}/{{filename}}',
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}',
'{{y}}/{{MMM}}/{{filename}}',
'{{y}}/{{MMMM}}/{{filename}}',
'{{y}}/{{MM}}/{{dd}}/{{filename}}',

View File

@ -12,6 +12,9 @@ type Item<T extends EmitEvent> = {
label: string;
};
export class ImmichStartupError extends Error {}
export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError;
export const setupEventHandlers = (moduleRef: ModuleRef) => {
const reflector = moduleRef.get(Reflector, { strict: false });
const repository = moduleRef.get<IEventRepository>(IEventRepository);

View File

@ -115,6 +115,7 @@ export class BaseConfig implements VideoCodecSWConfig {
'-fps_mode passthrough',
// explicitly selects the video stream instead of leaving it up to FFmpeg
`-map 0:${videoStream.index}`,
'-strict unofficial',
];
if (audioStream) {

View File

@ -9,6 +9,7 @@ import { envName, excludePaths, isDev, resourcePaths, serverVersion } from 'src/
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ApiService } from 'src/services/api.service';
import { isStartUpError } from 'src/utils/events';
import { otelStart } from 'src/utils/instrumentation';
import { useSwagger } from 'src/utils/misc';
@ -73,6 +74,9 @@ async function bootstrap() {
}
bootstrap().catch((error) => {
console.error(error);
throw error;
if (!isStartUpError(error)) {
console.error(error);
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});

View File

@ -4,6 +4,7 @@ import { MicroservicesModule } from 'src/app.module';
import { envName, serverVersion } from 'src/constants';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { isStartUpError } from 'src/utils/events';
import { otelStart } from 'src/utils/instrumentation';
export async function bootstrap() {
@ -25,7 +26,9 @@ export async function bootstrap() {
if (!isMainThread) {
bootstrap().catch((error) => {
console.error(error);
process.exit(1);
if (!isStartUpError(error)) {
console.error(error);
}
throw error;
});
}

12
web/package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.113.1",
"version": "1.114.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.113.1",
"version": "1.114.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.7.8",
@ -17,7 +17,6 @@
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2",
"@photo-sphere-viewer/video-plugin": "^5.7.2",
"@zoom-image/svelte": "^0.2.6",
"copy-image-clipboard": "^2.1.2",
"dom-to-image": "^2.6.0",
"handlebars": "^4.7.8",
"intl-messageformat": "^10.5.14",
@ -75,7 +74,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.113.1",
"version": "1.114.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
@ -3284,11 +3283,6 @@
"node": ">= 0.6"
}
},
"node_modules/copy-image-clipboard": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/copy-image-clipboard/-/copy-image-clipboard-2.1.2.tgz",
"integrity": "sha512-3VCXVl2IpFfOyD8drv9DozcNlwmqBqxOlsgkEGyVAzadjlPk1go8YNZyy8QmTnwHPxSFpeCR9OdsStEdVK7qDA=="
},
"node_modules/core-js-compat": {
"version": "3.37.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.113.1",
"version": "1.114.0",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",
@ -73,7 +73,6 @@
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2",
"@photo-sphere-viewer/video-plugin": "^5.7.2",
"@zoom-image/svelte": "^0.2.6",
"copy-image-clipboard": "^2.1.2",
"dom-to-image": "^2.6.0",
"handlebars": "^4.7.8",
"intl-messageformat": "^10.5.14",

View File

@ -19,7 +19,7 @@ describe('AlbumCover component', () => {
const img = component.getByTestId('album-image') as HTMLImageElement;
expect(img.alt).toBe('someName');
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square text');
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text');
expect(img.getAttribute('src')).toBe('/asdf');
expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' });
});
@ -36,7 +36,7 @@ describe('AlbumCover component', () => {
const img = component.getByTestId('album-image') as HTMLImageElement;
expect(img.alt).toBe('unnamed_album');
expect(img.getAttribute('loading')).toBe('eager');
expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square asdf');
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square asdf');
expect(img.getAttribute('src')).toStrictEqual(expect.any(String));
});
});

View File

@ -44,7 +44,7 @@
</div>
{/if}
<AlbumCover {album} {preload} class="h-full w-full transition-all duration-300 hover:shadow-lg" />
<AlbumCover {album} {preload} class="transition-all duration-300 hover:shadow-lg" />
<div class="mt-4">
<p

View File

@ -41,7 +41,7 @@
mdiPresentationPlay,
mdiUpload,
} from '@mdi/js';
import { canCopyImagesToClipboard } from 'copy-image-clipboard';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
@ -101,7 +101,7 @@
on:click={onZoomImage}
/>
{/if}
{#if canCopyImagesToClipboard() && asset.type === AssetTypeEnum.Image}
{#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image}
<CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} on:click={onCopyImage} />
{/if}

View File

@ -8,17 +8,17 @@
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { isWebCompatibleImage, canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { type SwipeCustomEvent, swipe } from 'svelte-gestures';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
export let asset: AssetResponseDto;
export let preloadAssets: AssetResponseDto[] | undefined = undefined;
@ -81,23 +81,19 @@
};
copyImage = async () => {
if (!canCopyImagesToClipboard()) {
if (!canCopyImageToClipboard()) {
return;
}
try {
await copyImageToClipboard(assetFileUrl);
await copyImageToClipboard($photoViewer ?? assetFileUrl);
notificationController.show({
type: NotificationType.Info,
message: $t('copied_image_to_clipboard'),
timeout: 3000,
});
} catch (error) {
console.error('Error [photo-viewer]:', error);
notificationController.show({
type: NotificationType.Error,
message: 'Copying image to clipboard failed.',
});
handleError(error, $t('copy_error'));
}
};
@ -152,7 +148,7 @@
]}
/>
{#if imageError}
<BrokenAsset square />
<BrokenAsset class="text-xl" />
{/if}
<!-- svelte-ignore a11y-missing-attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />

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