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: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
- machine-learning/app/** - machine-learning/app/**
changelog:translation:
- head-branch: ['^chore/translations$']

View File

@ -40,6 +40,57 @@ jobs:
id: should_force id: should_force
run: echo "should_force=${{ github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT" 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: build_and_push_ml:
name: Build and Push ML name: Build and Push ML
needs: pre-job needs: pre-job
@ -235,9 +286,22 @@ jobs:
BUILD_SOURCE_REF=${{ github.ref_name }} BUILD_SOURCE_REF=${{ github.ref_name }}
BUILD_SOURCE_COMMIT=${{ github.sha }} BUILD_SOURCE_COMMIT=${{ github.sha }}
success-check: success-check-server:
name: Docker Build & Push Success name: Docker Build & Push Server Success
needs: [build_and_push_ml, build_and_push_server] 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 runs-on: ubuntu-latest
if: always() if: always()
steps: 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 | | LivePhoto/MotionPhoto backup and playback | Yes | Yes |
| Support 360 degree image display | No | Yes | | Support 360 degree image display | No | Yes |
| User-defined storage structure | Yes | Yes | | User-defined storage structure | Yes | Yes |
| Public Sharing | No | Yes | | Public Sharing | Yes | Yes |
| Archive and Favorites | Yes | Yes | | Archive and Favorites | Yes | Yes |
| Global Map | Yes | Yes | | Global Map | Yes | Yes |
| Partner Sharing | Yes | Yes | | Partner Sharing | Yes | Yes |

6
cli/package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@ -48,7 +48,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e image: docker.io/redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always 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. 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", "label": "v1.113.1",
"url": "https://v1.113.1.archive.immich.app" "url": "https://v1.113.1.archive.immich.app"

View File

@ -33,7 +33,7 @@ services:
- 2285:3001 - 2285:3001
redis: redis:
image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
database: database:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 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", "name": "immich-e2e",
"version": "1.113.1", "version": "1.114.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.113.1", "version": "1.114.0",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
@ -45,7 +45,7 @@
}, },
"../cli": { "../cli": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.17", "version": "2.2.18",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@ -92,7 +92,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.113.1", "version": "1.114.0",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {

View File

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

View File

@ -44,7 +44,7 @@ test.describe('Shared Links', () => {
test('download from a shared link', async ({ page }) => { test('download from a shared link', async ({ page }) => {
await page.goto(`/share/${sharedLink.key}`); await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor(); 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.waitForSelector('#asset-group-by-date svg');
await page.getByRole('checkbox').click(); await page.getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download' }).click(); await page.getByRole('button', { name: 'Download' }).click();
@ -69,4 +69,15 @@ test.describe('Shared Links', () => {
await page.goto('/share/invalid'); await page.goto('/share/invalid');
await page.getByRole('heading', { name: 'Invalid share key' }).waitFor(); 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 && \ RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ 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.17384.11/intel-igc-core_1.0.17384.11_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/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.26.30049.6/intel-opencl-icd-dbgsym_24.26.30049.6_amd64.ddeb && \ 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.26.30049.6/intel-opencl-icd_24.26.30049.6_amd64.deb && \ wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \
wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/libigdgmm12_22.3.20_amd64.deb && \
dpkg -i *.deb && \ dpkg -i *.deb && \
rm *.deb && \ rm *.deb && \
apt-get remove wget -yqq && \ apt-get remove wget -yqq && \

View File

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

View File

@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 157, "android.injected.version.code" => 158,
"android.injected.version.name" => "1.113.1", "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') 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": "حذف الرابط المشترك", "delete_shared_link_dialog_title": "حذف الرابط المشترك",
"description_input_hint_text": "اضف وصفا...", "description_input_hint_text": "اضف وصفا...",
"description_input_submit_error": "خطأ تحديث الوصف ، تحقق من السجل لمزيد من التفاصيل", "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_date_time": "التاريخ و الوقت",
"edit_date_time_dialog_timezone": "وحدة زمنية", "edit_date_time_dialog_timezone": "وحدة زمنية",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -63,7 +63,7 @@
"assets_trashed_from_server": "{} položek vyhozeno do koše na Immich serveru", "assets_trashed_from_server": "{} položek vyhozeno do koše na Immich serveru",
"asset_viewer_settings_title": "Prohlížeč", "asset_viewer_settings_title": "Prohlížeč",
"backup_album_selection_page_albums_device": "Alba v zařízení ({})", "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_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_select_albums": "Vybraná alba",
"backup_album_selection_page_selection_info": "Informace o výběru", "backup_album_selection_page_selection_info": "Informace o výběru",
@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Odstranit sdílený odkaz", "delete_shared_link_dialog_title": "Odstranit sdílený odkaz",
"description_input_hint_text": "Přidat popis...", "description_input_hint_text": "Přidat popis...",
"description_input_submit_error": "Chyba aktualizace popisu, další podrobnosti najdete v logu", "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_date_time": "Datum a čas",
"edit_date_time_dialog_timezone": "Časové pásmo", "edit_date_time_dialog_timezone": "Časové pásmo",
"edit_image_title": "Upravit", "edit_image_title": "Upravit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Slet delt link", "delete_shared_link_dialog_title": "Slet delt link",
"description_input_hint_text": "Tilføj en beskrivelse...", "description_input_hint_text": "Tilføj en beskrivelse...",
"description_input_submit_error": "Fejl med at opdatere beskrivelsen. Tjek loggen for flere detaljer", "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_date_time": "Dato og klokkeslæt",
"edit_date_time_dialog_timezone": "Tidszone", "edit_date_time_dialog_timezone": "Tidszone",
"edit_image_title": "Rediger", "edit_image_title": "Rediger",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Geteilten Link löschen", "delete_shared_link_dialog_title": "Geteilten Link löschen",
"description_input_hint_text": "Beschreibung hinzufügen...", "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.", "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_date_time": "Datum und Uhrzeit",
"edit_date_time_dialog_timezone": "Zeitzone", "edit_date_time_dialog_timezone": "Zeitzone",
"edit_image_title": "Bearbeiten", "edit_image_title": "Bearbeiten",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Διαγραφή Κοινοποιημένου Συνδέσμου", "delete_shared_link_dialog_title": "Διαγραφή Κοινοποιημένου Συνδέσμου",
"description_input_hint_text": "Προσθήκη περιγραφής...", "description_input_hint_text": "Προσθήκη περιγραφής...",
"description_input_submit_error": "Σφάλμα κατά την ενημέρωση της περιγραφής, ελέγξτε το αρχείο καταγραφής για περισσότερες λεπτομέρειες", "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_date_time": "Ημερομηνία και Ώρα",
"edit_date_time_dialog_timezone": "Ζώνη ώρας", "edit_date_time_dialog_timezone": "Ζώνη ώρας",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

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

View File

@ -173,8 +173,8 @@
"control_bottom_app_bar_delete": "Eliminar", "control_bottom_app_bar_delete": "Eliminar",
"control_bottom_app_bar_delete_from_immich": "Borrar de Immich", "control_bottom_app_bar_delete_from_immich": "Borrar de Immich",
"control_bottom_app_bar_delete_from_local": "Borrar del dispositivo", "control_bottom_app_bar_delete_from_local": "Borrar del dispositivo",
"control_bottom_app_bar_download": "Download", "control_bottom_app_bar_download": "Descargar",
"control_bottom_app_bar_edit": "Edit", "control_bottom_app_bar_edit": "Editar",
"control_bottom_app_bar_edit_location": "Editar ubicación", "control_bottom_app_bar_edit_location": "Editar ubicación",
"control_bottom_app_bar_edit_time": "Editar fecha y hora", "control_bottom_app_bar_edit_time": "Editar fecha y hora",
"control_bottom_app_bar_favorite": "Favorito", "control_bottom_app_bar_favorite": "Favorito",
@ -190,7 +190,7 @@
"create_shared_album_page_share": "Compartir", "create_shared_album_page_share": "Compartir",
"create_shared_album_page_share_add_assets": "AGREGAR ELEMENTOS", "create_shared_album_page_share_add_assets": "AGREGAR ELEMENTOS",
"create_shared_album_page_share_select_photos": "Seleccionar Fotos", "create_shared_album_page_share_select_photos": "Seleccionar Fotos",
"crop": "Crop", "crop": "Recortar",
"curated_location_page_title": "Lugares", "curated_location_page_title": "Lugares",
"curated_object_page_title": "Objetos", "curated_object_page_title": "Objetos",
"daily_title_text_date": "E dd, MMM", "daily_title_text_date": "E dd, MMM",
@ -210,9 +210,13 @@
"delete_shared_link_dialog_title": "Eliminar enlace compartido", "delete_shared_link_dialog_title": "Eliminar enlace compartido",
"description_input_hint_text": "Agregar descripción...", "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", "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_date_time": "Fecha y Hora",
"edit_date_time_dialog_timezone": "Zona horaria", "edit_date_time_dialog_timezone": "Zona horaria",
"edit_image_title": "Edit", "edit_image_title": "Editar",
"edit_location_dialog_title": "Ubicación", "edit_location_dialog_title": "Ubicación",
"error_saving_image": "Error: {}", "error_saving_image": "Error: {}",
"exif_bottom_sheet_description": "Agregar Descripción...", "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_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_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", "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_error": "Error de descarga",
"image_viewer_page_state_provider_download_started": "Descarga Iniciada", "image_viewer_page_state_provider_download_started": "Descarga Iniciada",
"image_viewer_page_state_provider_download_success": "Descarga exitosa", "image_viewer_page_state_provider_download_success": "Descarga exitosa",
"image_viewer_page_state_provider_share_error": "Error al compartir", "image_viewer_page_state_provider_share_error": "Error al compartir",
"invalid_date": "Invalid date", "invalid_date": "Fecha incorrecta",
"invalid_date_format": "Invalid date format", "invalid_date_format": "Formato de fecha incorrecto",
"library_page_albums": "Álbumes", "library_page_albums": "Álbumes",
"library_page_archive": "Archivo", "library_page_archive": "Archivo",
"library_page_device_albums": "Álbumes en el dispositivo", "library_page_device_albums": "Álbumes en el dispositivo",
@ -380,27 +384,27 @@
"profile_drawer_sign_out": "Cerrar Sesión", "profile_drawer_sign_out": "Cerrar Sesión",
"profile_drawer_trash": "Papelera", "profile_drawer_trash": "Papelera",
"recently_added_page_title": "Recién Agregadas", "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", "scaffold_body_error_occurred": "Ha ocurrido un error",
"search_bar_hint": "Busca tus fotos", "search_bar_hint": "Busca tus fotos",
"search_filter_apply": "Aplicar filtros", "search_filter_apply": "Aplicar filtros",
"search_filter_camera": "Camera", "search_filter_camera": "Cámara",
"search_filter_camera_make": "Marca", "search_filter_camera_make": "Marca",
"search_filter_camera_model": "Modelo", "search_filter_camera_model": "Modelo",
"search_filter_camera_title": "Select camera type", "search_filter_camera_title": "Select camera type",
"search_filter_date": "Date", "search_filter_date": "Fecha",
"search_filter_date_interval": "{start} to {end}", "search_filter_date_interval": "{start} al {end}",
"search_filter_date_title": "Select a date range", "search_filter_date_title": "Selecciona un intervalo de fechas",
"search_filter_display_option_archive": "Archivado", "search_filter_display_option_archive": "Archivado",
"search_filter_display_option_favorite": "Favorito", "search_filter_display_option_favorite": "Favorito",
"search_filter_display_option_not_in_album": "No en álbum", "search_filter_display_option_not_in_album": "No en álbum",
"search_filter_display_options": "Display Options", "search_filter_display_options": "Display Options",
"search_filter_display_options_title": "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_city": "Ciudad",
"search_filter_location_country": "País", "search_filter_location_country": "País",
"search_filter_location_state": "Estado", "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": "Media Type",
"search_filter_media_type_all": "Todos", "search_filter_media_type_all": "Todos",
"search_filter_media_type_image": "Imagen", "search_filter_media_type_image": "Imagen",
@ -535,7 +539,7 @@
"sharing_silver_appbar_create_shared_album": "Crear un álbum compartido", "sharing_silver_appbar_create_shared_album": "Crear un álbum compartido",
"sharing_silver_appbar_shared_links": "Enlaces compartidos", "sharing_silver_appbar_shared_links": "Enlaces compartidos",
"sharing_silver_appbar_share_partner": "Compartir con el compañero", "sharing_silver_appbar_share_partner": "Compartir con el compañero",
"sync": "Sync", "sync": "Sincronizar",
"sync_albums": "Sync albums", "sync_albums": "Sync albums",
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup 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_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", "delete_shared_link_dialog_title": "Eliminar enlace compartido",
"description_input_hint_text": "Agregar descripción...", "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", "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_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone", "edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Eliminar enlace compartido", "delete_shared_link_dialog_title": "Eliminar enlace compartido",
"description_input_hint_text": "Agregar descripción...", "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", "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_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone", "edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Eliminar enlace compartido", "delete_shared_link_dialog_title": "Eliminar enlace compartido",
"description_input_hint_text": "Agregar descripción...", "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", "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_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone", "edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Poista jaettu linkki", "delete_shared_link_dialog_title": "Poista jaettu linkki",
"description_input_hint_text": "Lisää kuvaus...", "description_input_hint_text": "Lisää kuvaus...",
"description_input_submit_error": "Virhe kuvauksen päivittämisessä, tarkista lisätiedot lokista", "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_date_time": "Päivämäärä ja aika",
"edit_date_time_dialog_timezone": "Aikavyöhyke", "edit_date_time_dialog_timezone": "Aikavyöhyke",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Supprimer le lien partagé", "delete_shared_link_dialog_title": "Supprimer le lien partagé",
"description_input_hint_text": "Ajouter une description...", "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", "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_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone", "edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Supprimer le lien partagé", "delete_shared_link_dialog_title": "Supprimer le lien partagé",
"description_input_hint_text": "Ajouter une description…", "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", "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_date_time": "Date et heure",
"edit_date_time_dialog_timezone": "Fuseau horaire", "edit_date_time_dialog_timezone": "Fuseau horaire",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

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

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "साझा किए गए लिंक को हटाएं", "delete_shared_link_dialog_title": "साझा किए गए लिंक को हटाएं",
"description_input_hint_text": "Add description...", "description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details", "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_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone", "edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Megosztott Link Törlése", "delete_shared_link_dialog_title": "Megosztott Link Törlése",
"description_input_hint_text": "Leírás hozzáadása...", "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", "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_date_time": "Dátum és Idő",
"edit_date_time_dialog_timezone": "Időzóna", "edit_date_time_dialog_timezone": "Időzóna",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Elimina link condiviso", "delete_shared_link_dialog_title": "Elimina link condiviso",
"description_input_hint_text": "Aggiungi descrizione...", "description_input_hint_text": "Aggiungi descrizione...",
"description_input_submit_error": "Errore modificare descrizione, controlli I log per maggiori dettagli", "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_date_time": "Data e ora",
"edit_date_time_dialog_timezone": "Fuso orario", "edit_date_time_dialog_timezone": "Fuso orario",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "共有リンクを消す", "delete_shared_link_dialog_title": "共有リンクを消す",
"description_input_hint_text": "説明を追加", "description_input_hint_text": "説明を追加",
"description_input_submit_error": "説明の編集に失敗しました。詳細はログを確認してください。", "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_date_time": "日付と時間",
"edit_date_time_dialog_timezone": "タイムゾーン", "edit_date_time_dialog_timezone": "タイムゾーン",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

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

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link", "delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...", "description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details", "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_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone", "edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Dzēst Kopīgošanas saiti", "delete_shared_link_dialog_title": "Dzēst Kopīgošanas saiti",
"description_input_hint_text": "Pievienot aprakstu...", "description_input_hint_text": "Pievienot aprakstu...",
"description_input_submit_error": "Atjauninot aprakstu, radās kļūda; papildinformāciju skatiet žurnālā", "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_date_time": "Datums un Laiks",
"edit_date_time_dialog_timezone": "Laika zona", "edit_date_time_dialog_timezone": "Laika zona",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link", "delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...", "description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details", "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_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone", "edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Slett delt link", "delete_shared_link_dialog_title": "Slett delt link",
"description_input_hint_text": "Legg til beskrivelse ...", "description_input_hint_text": "Legg til beskrivelse ...",
"description_input_submit_error": "Feil ved oppdatering av beskrivelse, sjekk loggen for flere detaljer", "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_date_time": "Dato og tid",
"edit_date_time_dialog_timezone": "Tidssone", "edit_date_time_dialog_timezone": "Tidssone",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Verwijder gedeelde link", "delete_shared_link_dialog_title": "Verwijder gedeelde link",
"description_input_hint_text": "Beschrijving toevoegen...", "description_input_hint_text": "Beschrijving toevoegen...",
"description_input_submit_error": "Beschrijving bijwerken mislukt, controleer het logboek voor meer details", "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_date_time": "Datum en tijd",
"edit_date_time_dialog_timezone": "Tijdzone", "edit_date_time_dialog_timezone": "Tijdzone",
"edit_image_title": "Bewerken", "edit_image_title": "Bewerken",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Usuń udostępniony link", "delete_shared_link_dialog_title": "Usuń udostępniony link",
"description_input_hint_text": "Dodaj opis...", "description_input_hint_text": "Dodaj opis...",
"description_input_submit_error": "Błąd aktualizacji opisu, sprawdź dziennik, aby uzyskać więcej szczegółów", "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_date_time": "Data i godzina",
"edit_date_time_dialog_timezone": "Strefa czasowa", "edit_date_time_dialog_timezone": "Strefa czasowa",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Excluir link compartilhado", "delete_shared_link_dialog_title": "Excluir link compartilhado",
"description_input_hint_text": "Adicionar descrição...", "description_input_hint_text": "Adicionar descrição...",
"description_input_submit_error": "Erro ao atualizar a descrição, verifique o registo para obter mais detalhes", "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_date_time": "Data e Hora",
"edit_date_time_dialog_timezone": "Fuso horário", "edit_date_time_dialog_timezone": "Fuso horário",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Șterge link distribuire", "delete_shared_link_dialog_title": "Șterge link distribuire",
"description_input_hint_text": "Adaugă descriere...", "description_input_hint_text": "Adaugă descriere...",
"description_input_submit_error": "Eroare actualizare descriere, verifică log-urile pentru mai multe detalii", "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_date_time": "Dată și Oră",
"edit_date_time_dialog_timezone": "Fus orar", "edit_date_time_dialog_timezone": "Fus orar",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Удалить общую ссылку", "delete_shared_link_dialog_title": "Удалить общую ссылку",
"description_input_hint_text": "Добавить описание...", "description_input_hint_text": "Добавить описание...",
"description_input_submit_error": "Не удалось обновить описание, проверьте логи, чтобы узнать причину", "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_date_time": "Дата и время",
"edit_date_time_dialog_timezone": "Часовой пояс", "edit_date_time_dialog_timezone": "Часовой пояс",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Odstrániť zdieľaný odkaz", "delete_shared_link_dialog_title": "Odstrániť zdieľaný odkaz",
"description_input_hint_text": "Pridať popis...", "description_input_hint_text": "Pridať popis...",
"description_input_submit_error": "Chyba pri aktualizovaní popisu, zobrazte log pre viac detailov", "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_date_time": "Dátum a čas",
"edit_date_time_dialog_timezone": "Časové pásmo", "edit_date_time_dialog_timezone": "Časové pásmo",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Izbriši povezavo skupne rabe", "delete_shared_link_dialog_title": "Izbriši povezavo skupne rabe",
"description_input_hint_text": "Dodaj opis ...", "description_input_hint_text": "Dodaj opis ...",
"description_input_submit_error": "Napaka pri posodabljanju opisa, preverite dnevnik za več podrobnosti", "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_date_time": "Datum in ura",
"edit_date_time_dialog_timezone": "Časovni pas", "edit_date_time_dialog_timezone": "Časovni pas",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link", "delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...", "description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details", "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_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone", "edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link", "delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...", "description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details", "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_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone", "edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link", "delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...", "description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details", "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_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone", "edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Ta Bort Delad Länk", "delete_shared_link_dialog_title": "Ta Bort Delad Länk",
"description_input_hint_text": "Lägg till beskrivning...", "description_input_hint_text": "Lägg till beskrivning...",
"description_input_submit_error": "Fel vid uppdatering av beskrivning, se loggen för fler detaljer", "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_date_time": "Datum och Tid",
"edit_date_time_dialog_timezone": "Tidszon", "edit_date_time_dialog_timezone": "Tidszon",
"edit_image_title": "Redigera", "edit_image_title": "Redigera",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "ลบลิงก์ที่แชร์", "delete_shared_link_dialog_title": "ลบลิงก์ที่แชร์",
"description_input_hint_text": "เพื่มรายละเอียด...", "description_input_hint_text": "เพื่มรายละเอียด...",
"description_input_submit_error": "อัพเดตรายละเอียดผิดพลาด ตรวจสอบ log เพื่อรายละเอียดเพิ่มเติม", "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_date_time": "วันและเวลา",
"edit_date_time_dialog_timezone": "เขดเวลา", "edit_date_time_dialog_timezone": "เขดเวลา",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Видалити спільне посилання", "delete_shared_link_dialog_title": "Видалити спільне посилання",
"description_input_hint_text": "Додати опис...", "description_input_hint_text": "Додати опис...",
"description_input_submit_error": "Помилка оновлення опису, перевірте логи для подробиць", "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_date_time": "Дата і час",
"edit_date_time_dialog_timezone": "Часовий пояс", "edit_date_time_dialog_timezone": "Часовий пояс",
"edit_image_title": "Edit", "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_turn_on": "Bật dịch vụ nền",
"backup_controller_page_background_wifi": "Chỉ khi dùng Wi-Fi", "backup_controller_page_background_wifi": "Chỉ khi dùng Wi-Fi",
"backup_controller_page_backup": "Sao lưu", "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_backup_sub": "Ảnh và video đã sao lưu",
"backup_controller_page_cancel": "Từ chối", "backup_controller_page_cancel": "Từ chối",
"backup_controller_page_created": "Tạo vào: {}", "backup_controller_page_created": "Tạo vào: {}",
@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Xoá liên kết đã chia sẻ", "delete_shared_link_dialog_title": "Xoá liên kết đã chia sẻ",
"description_input_hint_text": "Thêm mô tả...", "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", "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_date_time": "Ngày và Giờ",
"edit_date_time_dialog_timezone": "Múi giờ", "edit_date_time_dialog_timezone": "Múi giờ",
"edit_image_title": "Sửa", "edit_image_title": "Sửa",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "删除共享链接", "delete_shared_link_dialog_title": "删除共享链接",
"description_input_hint_text": "添加描述...", "description_input_hint_text": "添加描述...",
"description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息", "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_date_time": "日期和时间",
"edit_date_time_dialog_timezone": "时区", "edit_date_time_dialog_timezone": "时区",
"edit_image_title": "编辑", "edit_image_title": "编辑",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "删除共享链接", "delete_shared_link_dialog_title": "删除共享链接",
"description_input_hint_text": "添加描述...", "description_input_hint_text": "添加描述...",
"description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息", "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_date_time": "日期和时间",
"edit_date_time_dialog_timezone": "时区", "edit_date_time_dialog_timezone": "时区",
"edit_image_title": "编辑", "edit_image_title": "编辑",

View File

@ -210,6 +210,10 @@
"delete_shared_link_dialog_title": "Delete Shared Link", "delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Add description...", "description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details", "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_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone", "edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit", "edit_image_title": "Edit",

View File

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

View File

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

View File

@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release" desc "iOS Release"
lane :release do lane :release do
increment_version_number( increment_version_number(
version_number: "1.113.1" version_number: "1.114.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, 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: 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 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - 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 description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.113.1+157 version: 1.114.0+158
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -75,6 +75,7 @@ export const supportedPresetTokens = [
'{{y}}/{{MM}}-{{dd}}/{{filename}}', '{{y}}/{{MM}}-{{dd}}/{{filename}}',
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}', '{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}/{{filename}}', '{{y}}/{{MM}}/{{filename}}',
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}',
'{{y}}/{{MMM}}/{{filename}}', '{{y}}/{{MMM}}/{{filename}}',
'{{y}}/{{MMMM}}/{{filename}}', '{{y}}/{{MMMM}}/{{filename}}',
'{{y}}/{{MM}}/{{dd}}/{{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 VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export type SystemFlags = { mountFiles: boolean };
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> { 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.ADMIN_ONBOARDING]: { isOnboarded: boolean };
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>; [SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string };
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
[SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; [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', FACIAL_RECOGNITION_STATE = 'facial-recognition-state',
ADMIN_ONBOARDING = 'admin-onboarding', ADMIN_ONBOARDING = 'admin-onboarding',
SYSTEM_CONFIG = 'system-config', SYSTEM_CONFIG = 'system-config',
SYSTEM_FLAGS = 'system-flags',
VERSION_CHECK_STATE = 'version-check-state', VERSION_CHECK_STATE = 'version-check-state',
LICENSE = 'license', LICENSE = 'license',
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { import {
OnGatewayConnection, OnGatewayConnection,
@ -37,7 +38,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
private server?: Server; private server?: Server;
constructor( constructor(
private authService: AuthService, private moduleRef: ModuleRef,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
@ -62,12 +63,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
async handleConnection(client: Socket) { async handleConnection(client: Socket) {
try { try {
this.logger.log(`Websocket Connect: ${client.id}`); 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, headers: client.request.headers,
queryParams: {}, queryParams: {},
metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' }, metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' },
}); });
await client.join(auth.user.id); await client.join(auth.user.id);
if (auth.session) {
await client.join(auth.session.id);
}
this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id }); this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id });
} catch (error: Error | any) { } catch (error: Error | any) {
this.logger.error(`Websocket connection error: ${error}`, error?.stack); 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]) { clientSend<E extends keyof ClientEventMap>(event: E, room: string, data: ClientEventMap[E]) {
this.server?.to(userId).emit(event, data); this.server?.to(room).emit(event, data);
} }
clientBroadcast<E extends keyof ClientEventMap>(event: E, data: ClientEventMap[E]) { 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}`); this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
const response = await this.geodataPlacesRepository 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`, `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)}`); 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(); await this.exiftool.end();
} }
readTags(path: string): Promise<ImmichTags | null> { readTags(path: string): Promise<ImmichTags> {
return this.exiftool.read(path).catch((error) => { return this.exiftool.read(path).catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack); this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
return null; return {};
}) as Promise<ImmichTags | null>; }) as Promise<ImmichTags>;
} }
extractBinaryTag(path: string, tagName: string): Promise<Buffer> { 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 { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
import { SourceType } from 'src/enum';
import { import {
AssetFaceId, AssetFaceId,
DeleteAllFacesOptions, DeleteAllFacesOptions,
@ -53,16 +54,20 @@ export class PersonRepository implements IPersonRepository {
} }
async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise<void> { async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise<void> {
if (sourceType) { if (!sourceType) {
await this.assetFaceRepository return this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
.createQueryBuilder('asset_faces')
.delete()
.andWhere('sourceType = :sourceType', { sourceType })
.execute();
return;
} }
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( getAllFaces(

View File

@ -6,6 +6,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.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 { userStub } from 'test/fixtures/user.stub';
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.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 { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
@ -56,6 +58,7 @@ const oauthUserWithDefaultQuota = {
describe('AuthService', () => { describe('AuthService', () => {
let sut: AuthService; let sut: AuthService;
let cryptoMock: Mocked<ICryptoRepository>; let cryptoMock: Mocked<ICryptoRepository>;
let eventMock: Mocked<IEventRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
@ -87,6 +90,7 @@ describe('AuthService', () => {
} as any); } as any);
cryptoMock = newCryptoRepositoryMock(); cryptoMock = newCryptoRepositoryMock();
eventMock = newEventRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
systemMock = newSystemMetadataRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
@ -94,7 +98,7 @@ describe('AuthService', () => {
shareMock = newSharedLinkRepositoryMock(); shareMock = newSharedLinkRepositoryMock();
keyMock = newKeyRepositoryMock(); 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', () => { it('should be defined', () => {
@ -208,6 +212,7 @@ describe('AuthService', () => {
}); });
expect(sessionMock.delete).toHaveBeenCalledWith('token123'); 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 () => { 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 { Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
@ -75,6 +76,7 @@ export class AuthService {
constructor( constructor(
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@ -114,6 +116,7 @@ export class AuthService {
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> { async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
if (auth.session) { if (auth.session) {
await this.sessionRepository.delete(auth.session.id); await this.sessionRepository.delete(auth.session.id);
await this.eventRepository.emit('session.delete', { sessionId: auth.session.id });
} }
return { return {

View File

@ -1247,6 +1247,7 @@ describe(MediaService.name, () => {
'-fps_mode passthrough', '-fps_mode passthrough',
'-map 0:0', '-map 0:0',
'-map 0:1', '-map 0:1',
'-strict unofficial',
'-v verbose', '-v verbose',
'-vf scale=-2:720,format=yuv420p', '-vf scale=-2:720,format=yuv420p',
'-preset 12', '-preset 12',
@ -1372,6 +1373,7 @@ describe(MediaService.name, () => {
'-fps_mode passthrough', '-fps_mode passthrough',
'-map 0:0', '-map 0:0',
'-map 0:1', '-map 0:1',
'-strict unofficial',
'-g 256', '-g 256',
'-v verbose', '-v verbose',
'-vf format=nv12,hwupload_cuda,scale_cuda=-2:720', '-vf format=nv12,hwupload_cuda,scale_cuda=-2:720',
@ -1532,6 +1534,7 @@ describe(MediaService.name, () => {
'-fps_mode passthrough', '-fps_mode passthrough',
'-map 0:0', '-map 0:0',
'-map 0:1', '-map 0:1',
'-strict unofficial',
'-bf 7', '-bf 7',
'-refs 5', '-refs 5',
'-g 256', '-g 256',
@ -1716,6 +1719,7 @@ describe(MediaService.name, () => {
'-fps_mode passthrough', '-fps_mode passthrough',
'-map 0:0', '-map 0:0',
'-map 0:1', '-map 0:1',
'-strict unofficial',
'-g 256', '-g 256',
'-v verbose', '-v verbose',
'-vf format=nv12,hwupload,scale_vaapi=-2:720', '-vf format=nv12,hwupload,scale_vaapi=-2:720',
@ -1913,6 +1917,7 @@ describe(MediaService.name, () => {
'-fps_mode passthrough', '-fps_mode passthrough',
'-map 0:0', '-map 0:0',
'-map 0:1', '-map 0:1',
'-strict unofficial',
'-g 256', '-g 256',
'-v verbose', '-v verbose',
'-vf scale_rkrga=-2:720:format=nv12:afbc=1', '-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 () => { it('should extract the correct video orientation', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
metadataMock.readTags.mockResolvedValue(null); metadataMock.readTags.mockResolvedValue({});
await sut.handleMetadataExtraction({ id: assetStub.video.id }); await sut.handleMetadataExtraction({ id: assetStub.video.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]);
expect(assetMock.upsertExif).toHaveBeenCalledWith( expect(assetMock.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ orientation: Orientation.Rotate270CW }), expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }),
); );
}); });
@ -814,6 +814,9 @@ describe(MetadataService.name, () => {
projectionType: 'EQUIRECTANGULAR', projectionType: 'EQUIRECTANGULAR',
timeZone: tags.tz, timeZone: tags.tz,
rating: tags.Rating, rating: tags.Rating,
country: null,
state: null,
city: null,
}); });
expect(assetMock.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; 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 { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import _ from 'lodash'; import _ from 'lodash';
import { Duration } from 'luxon'; import { Duration } from 'luxon';
@ -11,7 +11,6 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators'; import { OnEmit } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
import { AssetType, SourceType } from 'src/enum'; import { AssetType, SourceType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
@ -30,7 +29,7 @@ import {
QueueName, QueueName,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.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 { IMediaRepository } from 'src/interfaces/media.interface';
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
@ -56,23 +55,16 @@ const EXIF_DATE_TAGS: Array<keyof Tags> = [
]; ];
export enum Orientation { export enum Orientation {
Horizontal = '1', Horizontal = 1,
MirrorHorizontal = '2', MirrorHorizontal = 2,
Rotate180 = '3', Rotate180 = 3,
MirrorVertical = '4', MirrorVertical = 4,
MirrorHorizontalRotate270CW = '5', MirrorHorizontalRotate270CW = 5,
Rotate90CW = '6', Rotate90CW = 6,
MirrorHorizontalRotate90CW = '7', MirrorHorizontalRotate90CW = 7,
Rotate270CW = '8', 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 => { const validate = <T>(value: T): NonNullable<T> | null => {
// handle lists of numbers // handle lists of numbers
if (Array.isArray(value)) { if (Array.isArray(value)) {
@ -218,36 +210,73 @@ export class MetadataService {
} }
async handleMetadataExtraction({ id }: IEntityJob): Promise<JobStatus> { 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]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
const { exifData, exifTags } = await this.exifData(asset); const stats = await this.storageRepository.stat(asset.originalPath);
if (asset.type === AssetType.VIDEO) { const exifTags = await this.getExifTags(asset);
await this.applyVideoMetadata(asset, exifData);
} 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.applyTagList(asset, exifTags);
await this.applyMotionPhotos(asset, exifTags);
await this.assetRepository.upsertExif(exifData); 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({ await this.assetRepository.update({
id: asset.id, id: asset.id,
duration: asset.duration, duration: exifTags.Duration?.toString() ?? null,
localDateTime, localDateTime,
fileCreatedAt: exifData.dateTimeOriginal ?? undefined, fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
}); });
@ -338,25 +367,20 @@ export class MetadataService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { private async getExifTags(asset: AssetEntity): Promise<ImmichTags> {
const { latitude, longitude } = exifData; const mediaTags = await this.repository.readTags(asset.originalPath);
const { reverseGeocoding } = await this.configCore.getConfig({ withCache: true }); const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : {};
if (!reverseGeocoding.enabled || !longitude || !latitude) { const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {};
return;
// 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 { return { ...mediaTags, ...videoTags, ...sidecarTags };
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,
);
}
} }
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
@ -576,66 +600,65 @@ export class MetadataService {
); );
} }
private async exifData( private getDates(asset: AssetEntity, exifTags: ImmichTags) {
asset: AssetEntity, const dateTime = firstDateTime(exifTags as Maybe<Tags>, EXIF_DATE_TAGS);
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> { this.logger.debug(`Asset ${asset.id} date time is ${dateTime}`);
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;
// ensure date from sidecar is used if present // created
const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags); let dateTimeOriginal = dateTime?.toDate();
if (mediaTags && hasDateOverride) { if (!dateTimeOriginal) {
for (const tag of EXIF_DATE_TAGS) { this.logger.warn(`Asset ${asset.id} has no valid date (${dateTime}), falling back to asset.fileCreatedAt`);
delete mediaTags[tag]; 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); // offset minutes
const dateTimeOriginal = dateTimeOriginalWithRawValue.exifDate ?? asset.fileCreatedAt; const offsetMinutes = dateTime?.tzoffsetMinutes || 0;
const timeZone = this.getTimeZone(exifTags, dateTimeOriginalWithRawValue.rawValue); 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 = { return {
// altitude: tags.GPSAltitude ?? null,
assetId: asset.id,
bitsPerSample: this.getBitsPerSample(exifTags),
colorspace: exifTags.ColorSpace ?? null,
dateTimeOriginal, 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, timeZone,
rating: exifTags.Rating ?? null, localDateTime,
modifyDate: (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? asset.fileModifiedAt,
}; };
}
if (exifData.latitude === 0 && exifData.longitude === 0) { private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) {
this.logger.warn('Exif data has latitude and longitude of 0, setting to null'); let latitude = validate(tags.GPSLatitude);
exifData.latitude = null; let longitude = validate(tags.GPSLongitude);
exifData.longitude = null;
// 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 { private getAutoStackId(tags: ImmichTags | null): string | null {
@ -645,28 +668,6 @@ export class MetadataService {
return tags.BurstID ?? tags.BurstUUID ?? tags.CameraBurstID ?? tags.MediaUniqueID ?? null; 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 { private getBitsPerSample(tags: ImmichTags): number | null {
const bitDepthTags = [ const bitDepthTags = [
tags.BitsPerSample, tags.BitsPerSample,
@ -685,33 +686,37 @@ export class MetadataService {
return bitsPerSample; return bitsPerSample;
} }
private async applyVideoMetadata(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { private async getVideoTags(originalPath: string) {
const { videoStreams, format } = await this.mediaRepository.probe(asset.originalPath); const { videoStreams, format } = await this.mediaRepository.probe(originalPath);
const tags: Pick<ImmichTags, 'Duration' | 'Orientation'> = {};
if (videoStreams[0]) { if (videoStreams[0]) {
switch (videoStreams[0].rotation) { switch (videoStreams[0].rotation) {
case -90: { case -90: {
exifData.orientation = Orientation.Rotate90CW; tags.Orientation = Orientation.Rotate90CW;
break; break;
} }
case 0: { case 0: {
exifData.orientation = Orientation.Horizontal; tags.Orientation = Orientation.Horizontal;
break; break;
} }
case 90: { case 90: {
exifData.orientation = Orientation.Rotate270CW; tags.Orientation = Orientation.Rotate270CW;
break; break;
} }
case 180: { case 180: {
exifData.orientation = Orientation.Rotate180; tags.Orientation = Orientation.Rotate180;
break; break;
} }
} }
} }
if (format.duration) { 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> { 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 { AssetFileType, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.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 { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.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 { userStub } from 'test/fixtures/user.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.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 { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock'; import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock';
@ -64,6 +66,7 @@ const configs = {
describe(NotificationService.name, () => { describe(NotificationService.name, () => {
let albumMock: Mocked<IAlbumRepository>; let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
let notificationMock: Mocked<INotificationRepository>; let notificationMock: Mocked<INotificationRepository>;
@ -74,13 +77,23 @@ describe(NotificationService.name, () => {
beforeEach(() => { beforeEach(() => {
albumMock = newAlbumRepositoryMock(); albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
notificationMock = newNotificationRepositoryMock(); notificationMock = newNotificationRepositoryMock();
systemMock = newSystemMetadataRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock(); 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', () => { 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 { AlbumEntity } from 'src/entities/album.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.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 { import {
IEmailJob, IEmailJob,
IJobRepository, IJobRepository,
@ -30,6 +30,7 @@ export class NotificationService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
constructor( constructor(
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(INotificationRepository) private notificationRepository: INotificationRepository, @Inject(INotificationRepository) private notificationRepository: INotificationRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @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 } }); 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) { async sendTestEmail(id: string, dto: SystemConfigSmtpDto) {
const user = await this.userRepository.get(id, { withDeleted: false }); const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) { 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 { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { StorageService } from 'src/services/storage.service'; import { StorageService } from 'src/services/storage.service';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(StorageService.name, () => { describe(StorageService.name, () => {
let sut: StorageService; let sut: StorageService;
let databaseMock: Mocked<IDatabaseRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => { beforeEach(() => {
databaseMock = newDatabaseRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
sut = new StorageService(storageMock, loggerMock); systemMock = newSystemMetadataRepositoryMock();
sut = new StorageService(databaseMock, storageMock, loggerMock, systemMock);
}); });
it('should work', () => { it('should work', () => {
@ -21,9 +31,35 @@ describe(StorageService.name, () => {
}); });
describe('onBootstrap', () => { describe('onBootstrap', () => {
it('should create the library folder on initialization', () => { it('should enable mount folder checking', async () => {
sut.onBootstrap(); 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/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 { Inject, Injectable } from '@nestjs/common';
import { join } from 'node:path';
import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { OnEmit } from 'src/decorators'; 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 { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ImmichStartupError } from 'src/utils/events';
@Injectable() @Injectable()
export class StorageService { export class StorageService {
constructor( constructor(
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(ISystemMetadataRepository) private systemMetadata: ISystemMetadataRepository,
) { ) {
this.logger.setContext(StorageService.name); this.logger.setContext(StorageService.name);
} }
@OnEmit({ event: 'app.bootstrap' }) @OnEmit({ event: 'app.bootstrap' })
onBootstrap() { async onBootstrap() {
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
this.storageRepository.mkdirSync(libraryBase); 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) { async handleDeleteFiles(job: IDeleteFilesJob) {
@ -38,4 +67,38 @@ export class StorageService {
return JobStatus.SUCCESS; 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}}/{{MM}}-{{dd}}/{{filename}}',
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}', '{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}/{{filename}}', '{{y}}/{{MM}}/{{filename}}',
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}',
'{{y}}/{{MMM}}/{{filename}}', '{{y}}/{{MMM}}/{{filename}}',
'{{y}}/{{MMMM}}/{{filename}}', '{{y}}/{{MMMM}}/{{filename}}',
'{{y}}/{{MM}}/{{dd}}/{{filename}}', '{{y}}/{{MM}}/{{dd}}/{{filename}}',

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import { envName, excludePaths, isDev, resourcePaths, serverVersion } from 'src/
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ApiService } from 'src/services/api.service'; import { ApiService } from 'src/services/api.service';
import { isStartUpError } from 'src/utils/events';
import { otelStart } from 'src/utils/instrumentation'; import { otelStart } from 'src/utils/instrumentation';
import { useSwagger } from 'src/utils/misc'; import { useSwagger } from 'src/utils/misc';
@ -73,6 +74,9 @@ async function bootstrap() {
} }
bootstrap().catch((error) => { bootstrap().catch((error) => {
console.error(error); if (!isStartUpError(error)) {
throw 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 { envName, serverVersion } from 'src/constants';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { isStartUpError } from 'src/utils/events';
import { otelStart } from 'src/utils/instrumentation'; import { otelStart } from 'src/utils/instrumentation';
export async function bootstrap() { export async function bootstrap() {
@ -25,7 +26,9 @@ export async function bootstrap() {
if (!isMainThread) { if (!isMainThread) {
bootstrap().catch((error) => { bootstrap().catch((error) => {
console.error(error); if (!isStartUpError(error)) {
process.exit(1); console.error(error);
}
throw error;
}); });
} }

12
web/package-lock.json generated
View File

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

View File

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

View File

@ -19,7 +19,7 @@ describe('AlbumCover component', () => {
const img = component.getByTestId('album-image') as HTMLImageElement; const img = component.getByTestId('album-image') as HTMLImageElement;
expect(img.alt).toBe('someName'); expect(img.alt).toBe('someName');
expect(img.getAttribute('loading')).toBe('lazy'); 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(img.getAttribute('src')).toBe('/asdf');
expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' }); expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' });
}); });
@ -36,7 +36,7 @@ describe('AlbumCover component', () => {
const img = component.getByTestId('album-image') as HTMLImageElement; const img = component.getByTestId('album-image') as HTMLImageElement;
expect(img.alt).toBe('unnamed_album'); expect(img.alt).toBe('unnamed_album');
expect(img.getAttribute('loading')).toBe('eager'); 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)); expect(img.getAttribute('src')).toStrictEqual(expect.any(String));
}); });
}); });

View File

@ -44,7 +44,7 @@
</div> </div>
{/if} {/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"> <div class="mt-4">
<p <p

View File

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

View File

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