mirror of
https://github.com/immich-app/immich.git
synced 2025-07-08 02:36:40 -04:00
Merge branch 'main' of github.com:immich-app/immich into mobile/collections
This commit is contained in:
commit
f73deae77c
3
.github/labeler.yml
vendored
3
.github/labeler.yml
vendored
@ -33,3 +33,6 @@ documentation:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- machine-learning/app/**
|
||||
|
||||
changelog:translation:
|
||||
- head-branch: ['^chore/translations$']
|
||||
|
70
.github/workflows/docker.yml
vendored
70
.github/workflows/docker.yml
vendored
@ -40,6 +40,57 @@ jobs:
|
||||
id: should_force
|
||||
run: echo "should_force=${{ github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
retag_ml:
|
||||
name: Re-Tag ML
|
||||
needs: pre-job
|
||||
if: ${{ needs.pre-job.outputs.should_run_ml == 'false' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
suffix: ["", "-cuda", "-openvino", "-armnn"]
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
# Skip when PR from a fork
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Re-tag image
|
||||
run: |
|
||||
REGISTRY_NAME="ghcr.io"
|
||||
REPOSITORY=${{ github.repository_owner }}/immich-machine-learning
|
||||
TAG_OLD=main${{ matrix.suffix }}
|
||||
TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }}
|
||||
docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD
|
||||
|
||||
retag_server:
|
||||
name: Re-Tag Server
|
||||
needs: pre-job
|
||||
if: ${{ needs.pre-job.outputs.should_run_server == 'false' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
suffix: [""]
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
# Skip when PR from a fork
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Re-tag image
|
||||
run: |
|
||||
REGISTRY_NAME="ghcr.io"
|
||||
REPOSITORY=${{ github.repository_owner }}/immich-server
|
||||
TAG_OLD=main${{ matrix.suffix }}
|
||||
TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }}
|
||||
docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD
|
||||
|
||||
|
||||
build_and_push_ml:
|
||||
name: Build and Push ML
|
||||
needs: pre-job
|
||||
@ -235,9 +286,22 @@ jobs:
|
||||
BUILD_SOURCE_REF=${{ github.ref_name }}
|
||||
BUILD_SOURCE_COMMIT=${{ github.sha }}
|
||||
|
||||
success-check:
|
||||
name: Docker Build & Push Success
|
||||
needs: [build_and_push_ml, build_and_push_server]
|
||||
success-check-server:
|
||||
name: Docker Build & Push Server Success
|
||||
needs: [build_and_push_server, retag_server]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- name: Any jobs failed?
|
||||
if: ${{ contains(needs.*.result, 'failure') }}
|
||||
run: exit 1
|
||||
- name: All jobs passed or skipped
|
||||
if: ${{ !(contains(needs.*.result, 'failure')) }}
|
||||
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}"
|
||||
|
||||
success-check-ml:
|
||||
name: Docker Build & Push ML Success
|
||||
needs: [build_and_push_ml, retag_ml]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
|
@ -92,7 +92,7 @@ For the mobile app, you can use `https://demo.immich.app/api` for the `Server En
|
||||
| LivePhoto/MotionPhoto backup and playback | Yes | Yes |
|
||||
| Support 360 degree image display | No | Yes |
|
||||
| User-defined storage structure | Yes | Yes |
|
||||
| Public Sharing | No | Yes |
|
||||
| Public Sharing | Yes | Yes |
|
||||
| Archive and Favorites | Yes | Yes |
|
||||
| Global Map | Yes | Yes |
|
||||
| Partner Sharing | Yes | Yes |
|
||||
|
6
cli/package-lock.json
generated
6
cli/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.17",
|
||||
"version": "2.2.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.17",
|
||||
"version": "2.2.18",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"fast-glob": "^3.3.2",
|
||||
@ -52,7 +52,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.17",
|
||||
"version": "2.2.18",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
@ -98,7 +98,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
|
||||
image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
@ -47,7 +47,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
|
||||
image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
@ -48,7 +48,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
|
||||
image: docker.io/redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
@ -27,3 +27,9 @@ If an asset is in multiple albums, `{{album}}` will be set to the name of the al
|
||||
:::
|
||||
|
||||
Immich also provides a mechanism to migrate between templates so that if the template you set now doesn't work in the future, you can always migrate all the existing files to the new template. The mechanism is run as a job on the Job page.
|
||||
|
||||
If you want to store assets in album folders, but you also have assets that do not belong to any album, you can use `{{#if album}}`, `{{else}}` and `{{/if}}` to create a conditional statement. For example, the following template will store assets in album folders if they belong to an album, and in a folder named "Other/Month" if they do not belong to an album:
|
||||
|
||||
```
|
||||
{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}
|
||||
```
|
||||
|
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"label": "v1.114.0",
|
||||
"url": "https://v1.114.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.113.1",
|
||||
"url": "https://v1.113.1.archive.immich.app"
|
||||
|
@ -33,7 +33,7 @@ services:
|
||||
- 2285:3001
|
||||
|
||||
redis:
|
||||
image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
|
||||
image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
|
||||
|
||||
database:
|
||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
||||
|
8
e2e/package-lock.json
generated
8
e2e/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-e2e",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
@ -45,7 +45,7 @@
|
||||
},
|
||||
"../cli": {
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.17",
|
||||
"version": "2.2.18",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
@ -92,7 +92,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
@ -44,7 +44,7 @@ test.describe('Shared Links', () => {
|
||||
test('download from a shared link', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLink.key}`);
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
await page.locator('.group').first().hover();
|
||||
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
|
||||
await page.waitForSelector('#asset-group-by-date svg');
|
||||
await page.getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
@ -69,4 +69,15 @@ test.describe('Shared Links', () => {
|
||||
await page.goto('/share/invalid');
|
||||
await page.getByRole('heading', { name: 'Invalid share key' }).waitFor();
|
||||
});
|
||||
|
||||
test('auth on navigation from shared link to timeline', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto(`/share/${sharedLink.key}`);
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
|
||||
await page.locator('a[href="/"]').click();
|
||||
await page.waitForURL('/photos');
|
||||
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
|
||||
});
|
||||
});
|
||||
|
@ -40,11 +40,10 @@ FROM prod-cpu AS prod-openvino
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-core_1.0.17193.4_amd64.deb && \
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-opencl_1.0.17193.4_amd64.deb && \
|
||||
wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd-dbgsym_24.26.30049.6_amd64.ddeb && \
|
||||
wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd_24.26.30049.6_amd64.deb && \
|
||||
wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/libigdgmm12_22.3.20_amd64.deb && \
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \
|
||||
wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \
|
||||
wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \
|
||||
dpkg -i *.deb && \
|
||||
rm *.deb && \
|
||||
apt-get remove wget -yqq && \
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.113.1"
|
||||
version = "1.114.0"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
|
@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 157,
|
||||
"android.injected.version.name" => "1.113.1",
|
||||
"android.injected.version.code" => 158,
|
||||
"android.injected.version.name" => "1.114.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "حذف الرابط المشترك",
|
||||
"description_input_hint_text": "اضف وصفا...",
|
||||
"description_input_submit_error": "خطأ تحديث الوصف ، تحقق من السجل لمزيد من التفاصيل",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "التاريخ و الوقت",
|
||||
"edit_date_time_dialog_timezone": "وحدة زمنية",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -63,7 +63,7 @@
|
||||
"assets_trashed_from_server": "{} položek vyhozeno do koše na Immich serveru",
|
||||
"asset_viewer_settings_title": "Prohlížeč",
|
||||
"backup_album_selection_page_albums_device": "Alba v zařízení ({})",
|
||||
"backup_album_selection_page_albums_tap": "Klepnutím na položku ji zahrnete, dvojím klepnutím ji vyloučíte",
|
||||
"backup_album_selection_page_albums_tap": "Klepnutím na položku ji zahrnete, opětovným klepnutím ji vyloučíte",
|
||||
"backup_album_selection_page_assets_scatter": "Položky mohou být roztroušeny ve více albech. To umožňuje zahrnout nebo vyloučit alba během procesu zálohování.",
|
||||
"backup_album_selection_page_select_albums": "Vybraná alba",
|
||||
"backup_album_selection_page_selection_info": "Informace o výběru",
|
||||
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Odstranit sdílený odkaz",
|
||||
"description_input_hint_text": "Přidat popis...",
|
||||
"description_input_submit_error": "Chyba aktualizace popisu, další podrobnosti najdete v logu",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Datum a čas",
|
||||
"edit_date_time_dialog_timezone": "Časové pásmo",
|
||||
"edit_image_title": "Upravit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Slet delt link",
|
||||
"description_input_hint_text": "Tilføj en beskrivelse...",
|
||||
"description_input_submit_error": "Fejl med at opdatere beskrivelsen. Tjek loggen for flere detaljer",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Dato og klokkeslæt",
|
||||
"edit_date_time_dialog_timezone": "Tidszone",
|
||||
"edit_image_title": "Rediger",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Geteilten Link löschen",
|
||||
"description_input_hint_text": "Beschreibung hinzufügen...",
|
||||
"description_input_submit_error": "Beschreibung konnte nicht geändert werden, bitte im Log für mehr Details nachsehen.",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Datum und Uhrzeit",
|
||||
"edit_date_time_dialog_timezone": "Zeitzone",
|
||||
"edit_image_title": "Bearbeiten",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Διαγραφή Κοινοποιημένου Συνδέσμου",
|
||||
"description_input_hint_text": "Προσθήκη περιγραφής...",
|
||||
"description_input_submit_error": "Σφάλμα κατά την ενημέρωση της περιγραφής, ελέγξτε το αρχείο καταγραφής για περισσότερες λεπτομέρειες",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Ημερομηνία και Ώρα",
|
||||
"edit_date_time_dialog_timezone": "Ζώνη ώρας",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Delete Shared Link",
|
||||
"description_input_hint_text": "Add description...",
|
||||
"description_input_submit_error": "Error updating description, check the log for more details",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_image_title": "Edit",
|
||||
@ -252,10 +256,9 @@
|
||||
"home_page_share_err_local": "Can not share local assets via link, skipping",
|
||||
"home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
|
||||
"image_saved_successfully": "Image saved",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"image_viewer_page_state_provider_download_error": "Download Error",
|
||||
"image_viewer_page_state_provider_download_started": "Download Started",
|
||||
"image_viewer_page_state_provider_download_success": "Download Success",
|
||||
"image_viewer_page_state_provider_share_error": "Share Error",
|
||||
"invalid_date": "Invalid date",
|
||||
"invalid_date_format": "Invalid date format",
|
||||
|
@ -173,8 +173,8 @@
|
||||
"control_bottom_app_bar_delete": "Eliminar",
|
||||
"control_bottom_app_bar_delete_from_immich": "Borrar de Immich",
|
||||
"control_bottom_app_bar_delete_from_local": "Borrar del dispositivo",
|
||||
"control_bottom_app_bar_download": "Download",
|
||||
"control_bottom_app_bar_edit": "Edit",
|
||||
"control_bottom_app_bar_download": "Descargar",
|
||||
"control_bottom_app_bar_edit": "Editar",
|
||||
"control_bottom_app_bar_edit_location": "Editar ubicación",
|
||||
"control_bottom_app_bar_edit_time": "Editar fecha y hora",
|
||||
"control_bottom_app_bar_favorite": "Favorito",
|
||||
@ -190,7 +190,7 @@
|
||||
"create_shared_album_page_share": "Compartir",
|
||||
"create_shared_album_page_share_add_assets": "AGREGAR ELEMENTOS",
|
||||
"create_shared_album_page_share_select_photos": "Seleccionar Fotos",
|
||||
"crop": "Crop",
|
||||
"crop": "Recortar",
|
||||
"curated_location_page_title": "Lugares",
|
||||
"curated_object_page_title": "Objetos",
|
||||
"daily_title_text_date": "E dd, MMM",
|
||||
@ -210,9 +210,13 @@
|
||||
"delete_shared_link_dialog_title": "Eliminar enlace compartido",
|
||||
"description_input_hint_text": "Agregar descripción...",
|
||||
"description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Fecha y Hora",
|
||||
"edit_date_time_dialog_timezone": "Zona horaria",
|
||||
"edit_image_title": "Edit",
|
||||
"edit_image_title": "Editar",
|
||||
"edit_location_dialog_title": "Ubicación",
|
||||
"error_saving_image": "Error: {}",
|
||||
"exif_bottom_sheet_description": "Agregar Descripción...",
|
||||
@ -251,13 +255,13 @@
|
||||
"home_page_first_time_notice": "Si esta es la primera vez que usas la app, por favor, asegúrate de elegir un álbum de respaldo para que la línea de tiempo pueda cargar fotos y videos en los álbumes.",
|
||||
"home_page_share_err_local": "No se pueden compartir elementos locales a través de un enlace, omitiendo",
|
||||
"home_page_upload_err_limit": "Solo se pueden subir 30 elementos simultáneamente, omitiendo",
|
||||
"image_saved_successfully": "Image saved",
|
||||
"image_saved_successfully": "Imágenes guardas",
|
||||
"image_viewer_page_state_provider_download_error": "Error de descarga",
|
||||
"image_viewer_page_state_provider_download_started": "Descarga Iniciada",
|
||||
"image_viewer_page_state_provider_download_success": "Descarga exitosa",
|
||||
"image_viewer_page_state_provider_share_error": "Error al compartir",
|
||||
"invalid_date": "Invalid date",
|
||||
"invalid_date_format": "Invalid date format",
|
||||
"invalid_date": "Fecha incorrecta",
|
||||
"invalid_date_format": "Formato de fecha incorrecto",
|
||||
"library_page_albums": "Álbumes",
|
||||
"library_page_archive": "Archivo",
|
||||
"library_page_device_albums": "Álbumes en el dispositivo",
|
||||
@ -380,27 +384,27 @@
|
||||
"profile_drawer_sign_out": "Cerrar Sesión",
|
||||
"profile_drawer_trash": "Papelera",
|
||||
"recently_added_page_title": "Recién Agregadas",
|
||||
"save_to_gallery": "Save to gallery",
|
||||
"save_to_gallery": "Guardado en la galería",
|
||||
"scaffold_body_error_occurred": "Ha ocurrido un error",
|
||||
"search_bar_hint": "Busca tus fotos",
|
||||
"search_filter_apply": "Aplicar filtros",
|
||||
"search_filter_camera": "Camera",
|
||||
"search_filter_camera": "Cámara",
|
||||
"search_filter_camera_make": "Marca",
|
||||
"search_filter_camera_model": "Modelo",
|
||||
"search_filter_camera_title": "Select camera type",
|
||||
"search_filter_date": "Date",
|
||||
"search_filter_date_interval": "{start} to {end}",
|
||||
"search_filter_date_title": "Select a date range",
|
||||
"search_filter_date": "Fecha",
|
||||
"search_filter_date_interval": "{start} al {end}",
|
||||
"search_filter_date_title": "Selecciona un intervalo de fechas",
|
||||
"search_filter_display_option_archive": "Archivado",
|
||||
"search_filter_display_option_favorite": "Favorito",
|
||||
"search_filter_display_option_not_in_album": "No en álbum",
|
||||
"search_filter_display_options": "Display Options",
|
||||
"search_filter_display_options_title": "Display options",
|
||||
"search_filter_location": "Location",
|
||||
"search_filter_location": "Ubicación",
|
||||
"search_filter_location_city": "Ciudad",
|
||||
"search_filter_location_country": "País",
|
||||
"search_filter_location_state": "Estado",
|
||||
"search_filter_location_title": "Select location",
|
||||
"search_filter_location_title": "Seleccionar una ubicación",
|
||||
"search_filter_media_type": "Media Type",
|
||||
"search_filter_media_type_all": "Todos",
|
||||
"search_filter_media_type_image": "Imagen",
|
||||
@ -535,7 +539,7 @@
|
||||
"sharing_silver_appbar_create_shared_album": "Crear un álbum compartido",
|
||||
"sharing_silver_appbar_shared_links": "Enlaces compartidos",
|
||||
"sharing_silver_appbar_share_partner": "Compartir con el compañero",
|
||||
"sync": "Sync",
|
||||
"sync": "Sincronizar",
|
||||
"sync_albums": "Sync albums",
|
||||
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
|
||||
"sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Eliminar enlace compartido",
|
||||
"description_input_hint_text": "Agregar descripción...",
|
||||
"description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Eliminar enlace compartido",
|
||||
"description_input_hint_text": "Agregar descripción...",
|
||||
"description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Eliminar enlace compartido",
|
||||
"description_input_hint_text": "Agregar descripción...",
|
||||
"description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Poista jaettu linkki",
|
||||
"description_input_hint_text": "Lisää kuvaus...",
|
||||
"description_input_submit_error": "Virhe kuvauksen päivittämisessä, tarkista lisätiedot lokista",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Päivämäärä ja aika",
|
||||
"edit_date_time_dialog_timezone": "Aikavyöhyke",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Supprimer le lien partagé",
|
||||
"description_input_hint_text": "Ajouter une description...",
|
||||
"description_input_submit_error": "Erreur de mise à jour de la description, vérifier le journal pour plus de détails",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Supprimer le lien partagé",
|
||||
"description_input_hint_text": "Ajouter une description…",
|
||||
"description_input_submit_error": "Erreur de mise à jour de la description, vérifier le journal pour plus de détails",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date et heure",
|
||||
"edit_date_time_dialog_timezone": "Fuseau horaire",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -190,7 +190,7 @@
|
||||
"create_shared_album_page_share": "שתף",
|
||||
"create_shared_album_page_share_add_assets": "הוסף נכסים",
|
||||
"create_shared_album_page_share_select_photos": "בחירת תמונות",
|
||||
"crop": "Crop",
|
||||
"crop": "חתוך",
|
||||
"curated_location_page_title": "מקומות",
|
||||
"curated_object_page_title": "דברים",
|
||||
"daily_title_text_date": "E, MMM dd",
|
||||
@ -210,11 +210,15 @@
|
||||
"delete_shared_link_dialog_title": "מחק קישור משותף",
|
||||
"description_input_hint_text": "הוסף תיאור...",
|
||||
"description_input_submit_error": "שגיאה בעדכון תיאור, בדוק את היומן לפרטים נוספים",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "תאריך וזמן",
|
||||
"edit_date_time_dialog_timezone": "אזור זמן",
|
||||
"edit_image_title": "Edit",
|
||||
"edit_image_title": "ערוך",
|
||||
"edit_location_dialog_title": "מיקום",
|
||||
"error_saving_image": "Error: {}",
|
||||
"error_saving_image": "שגיאה: {}",
|
||||
"exif_bottom_sheet_description": "הוסף תיאור...",
|
||||
"exif_bottom_sheet_details": "פרטים",
|
||||
"exif_bottom_sheet_location": "מיקום",
|
||||
@ -251,7 +255,7 @@
|
||||
"home_page_first_time_notice": "אם זאת הפעם הראשונה שאת/ה משתמש/ת ביישום, נא להקפיד לבחור אלבומ(ים) לגיבוי כך שציר הזמן יוכל לאכלס תמונות וסרטונים באלבומ(ים)",
|
||||
"home_page_share_err_local": "לא ניתן לשתף נכסים מקומיים על ידי קישור, מדלג",
|
||||
"home_page_upload_err_limit": "ניתן להעלות רק מקסימום של 30 נכסים בכל פעם, מדלג",
|
||||
"image_saved_successfully": "Image saved",
|
||||
"image_saved_successfully": "תמונה נשמרה",
|
||||
"image_viewer_page_state_provider_download_error": "שגיאת הורדה",
|
||||
"image_viewer_page_state_provider_download_started": "ההורדה החלה",
|
||||
"image_viewer_page_state_provider_download_success": "הצלחת הורדה",
|
||||
@ -380,7 +384,7 @@
|
||||
"profile_drawer_sign_out": "יציאה",
|
||||
"profile_drawer_trash": "אשפה",
|
||||
"recently_added_page_title": "נוסף לאחרונה",
|
||||
"save_to_gallery": "Save to gallery",
|
||||
"save_to_gallery": "שמור לגלריה",
|
||||
"scaffold_body_error_occurred": "אירעה שגיאה",
|
||||
"search_bar_hint": "חפש/י בתמונות שלך",
|
||||
"search_filter_apply": "החל סינון",
|
||||
@ -535,10 +539,10 @@
|
||||
"sharing_silver_appbar_create_shared_album": "אלבום משותף חדש",
|
||||
"sharing_silver_appbar_shared_links": "קישורים משותפים",
|
||||
"sharing_silver_appbar_share_partner": "שיתוף עם שותף",
|
||||
"sync": "Sync",
|
||||
"sync_albums": "Sync albums",
|
||||
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
|
||||
"sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich",
|
||||
"sync": "סנכרן",
|
||||
"sync_albums": "סנכרן אלבומים",
|
||||
"sync_albums_manual_subtitle": "סנכרן את כל הסרטונים והתמונות שהועלו לאלבומי הגיבוי שנבחרו",
|
||||
"sync_upload_album_setting_subtitle": "צור והעלה תמונות וסרטונים שלך לאלבומים שנבחרו ביישום",
|
||||
"tab_controller_nav_library": "ספרייה",
|
||||
"tab_controller_nav_photos": "תמונות",
|
||||
"tab_controller_nav_search": "חיפוש",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "साझा किए गए लिंक को हटाएं",
|
||||
"description_input_hint_text": "Add description...",
|
||||
"description_input_submit_error": "Error updating description, check the log for more details",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Megosztott Link Törlése",
|
||||
"description_input_hint_text": "Leírás hozzáadása...",
|
||||
"description_input_submit_error": "Nem sikerült frissíteni a leírást. További információért kérjük, nézd meg az eseménynaplót",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Dátum és Idő",
|
||||
"edit_date_time_dialog_timezone": "Időzóna",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Elimina link condiviso",
|
||||
"description_input_hint_text": "Aggiungi descrizione...",
|
||||
"description_input_submit_error": "Errore modificare descrizione, controlli I log per maggiori dettagli",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Data e ora",
|
||||
"edit_date_time_dialog_timezone": "Fuso orario",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "共有リンクを消す",
|
||||
"description_input_hint_text": "説明を追加",
|
||||
"description_input_submit_error": "説明の編集に失敗しました。詳細はログを確認してください。",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "日付と時間",
|
||||
"edit_date_time_dialog_timezone": "タイムゾーン",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -31,7 +31,7 @@
|
||||
"album_viewer_appbar_share_err_delete": "앨범을 삭제하지 못했습니다.",
|
||||
"album_viewer_appbar_share_err_leave": "앨범에서 나가지 못했습니다.",
|
||||
"album_viewer_appbar_share_err_remove": "앨범에서 항목을 제거하지 못했습니다.",
|
||||
"album_viewer_appbar_share_err_title": "앨범 이름을 변경하지 못했습니다.",
|
||||
"album_viewer_appbar_share_err_title": "앨범명을 변경하지 못했습니다.",
|
||||
"album_viewer_appbar_share_leave": "앨범 나가기",
|
||||
"album_viewer_appbar_share_remove": "앨범에서 제거",
|
||||
"album_viewer_appbar_share_to": "공유 대상",
|
||||
@ -55,12 +55,12 @@
|
||||
"asset_list_settings_subtitle": "사진 배열 레이아웃 설정",
|
||||
"asset_list_settings_title": "사진 배열",
|
||||
"asset_restored_successfully": "항목이 성공적으로 복원되었습니다.",
|
||||
"assets_deleted_permanently": "{} 미디어가 영구 삭제됨",
|
||||
"assets_deleted_permanently_from_server": "Immich 서버에서 {} 미디어가 영구 삭제되었습니다.",
|
||||
"assets_removed_permanently_from_device": "장치에서 {} 미디어가 영구적으로 제거되었습니다.",
|
||||
"assets_restored_successfully": "{} 미디어가 성공적으로 복원되었습니다.",
|
||||
"assets_trashed": "{} 미디어가 휴지통에 버려졌습니다.",
|
||||
"assets_trashed_from_server": "Immich 서버에서 {} 미디어를 삭제했습니다.",
|
||||
"assets_deleted_permanently": "{}개 항목이 영구적으로 삭제됨",
|
||||
"assets_deleted_permanently_from_server": "{}개 항목이 Immich 서버에서 영구적으로 삭제됨",
|
||||
"assets_removed_permanently_from_device": "{}개 항목이 기기에서 영구적으로 삭제됨",
|
||||
"assets_restored_successfully": "항목 {}개를 복원했습니다.",
|
||||
"assets_trashed": "휴지통으로 {}개 항목이 이동되었습니다.",
|
||||
"assets_trashed_from_server": "휴지통으로 Immich 서버의 {}개 항목이 이동되었습니다.",
|
||||
"asset_viewer_settings_title": "보기 옵션",
|
||||
"backup_album_selection_page_albums_device": "기기의 앨범 ({})",
|
||||
"backup_album_selection_page_albums_tap": "한 번 눌러 선택, 두 번 눌러 제외하세요.",
|
||||
@ -69,55 +69,55 @@
|
||||
"backup_album_selection_page_selection_info": "선택한 앨범",
|
||||
"backup_album_selection_page_total_assets": "전체 항목",
|
||||
"backup_all": "모두",
|
||||
"backup_background_service_backup_failed_message": "백업하지 못했습니다. 다시 시도하는 중...",
|
||||
"backup_background_service_backup_failed_message": "항목을 백업하지 못했습니다. 다시 시도하는 중...",
|
||||
"backup_background_service_connection_failed_message": "서버에 연결하지 못했습니다. 다시 시도하는 중...",
|
||||
"backup_background_service_current_upload_notification": "{} 업로드 중",
|
||||
"backup_background_service_default_notification": "백업할 항목을 확인하는 중...",
|
||||
"backup_background_service_error_title": "백업 오류",
|
||||
"backup_background_service_in_progress_notification": "선택한 항목을 백업하는 중...",
|
||||
"backup_background_service_upload_failure_notification": "{} 업로드 실패",
|
||||
"backup_controller_page_albums": "백업 대상 앨범",
|
||||
"backup_controller_page_albums": "백업할 앨범",
|
||||
"backup_controller_page_background_app_refresh_disabled_content": "백그라운드 백업을 사용하려면 설정 > 일반 > 백그라운드 앱 새로 고침에서 백그라운드 앱 새로 고침을 활성화하세요.",
|
||||
"backup_controller_page_background_app_refresh_disabled_title": "백그라운드 새로 고침 비활성화됨",
|
||||
"backup_controller_page_background_app_refresh_enable_button_text": "설정으로 이동",
|
||||
"backup_controller_page_background_battery_info_link": "설정 방법",
|
||||
"backup_controller_page_background_battery_info_message": "최상의 백그라운드 백업 환경을 위해, Immich의 백그라운드 활동을 제한하는 배터리 최적화를 비활성화하세요.\n\n설정 방법은 기기마다 다르므로, 제조 업체에서 관련 정보를 찾아보세요.",
|
||||
"backup_controller_page_background_battery_info_message": "최상의 백그라운드 백업 환경을 위해 Immich 백그라운드 활동을 제한하는 배터리 최적화 기능을 비활성화하세요.\n\n기기마다 설정 방법에 차이가 있어 제조 업체에서 관련 정보를 찾아보세요.",
|
||||
"backup_controller_page_background_battery_info_ok": "확인",
|
||||
"backup_controller_page_background_battery_info_title": "배터리 최적화",
|
||||
"backup_controller_page_background_charging": "충전 중에만",
|
||||
"backup_controller_page_background_configure_error": "백그라운드 서비스 구성 실패",
|
||||
"backup_controller_page_background_delay": "새 콘텐츠 백업 간격: {}",
|
||||
"backup_controller_page_background_description": "백그라운드 서비스를 활성화하면 앱을 열지 않고도 새 콘텐츠를 자동으로 백업할 수 있습니다.",
|
||||
"backup_controller_page_background_is_off": "자동 백그라운드 백업이 비활성화되었습니다.",
|
||||
"backup_controller_page_background_is_on": "자동 백그라운드 백업이 활성화되었습니다.",
|
||||
"backup_controller_page_background_description": "백그라운드 서비스를 활성화하여 앱을 실행하지 않고 새 항목을 자동으로 백업하세요.",
|
||||
"backup_controller_page_background_is_off": "백그라운드 백업이 비활성화되었습니다.",
|
||||
"backup_controller_page_background_is_on": "백그라운드 백업이 활성화되었습니다.",
|
||||
"backup_controller_page_background_turn_off": "백그라운드 서비스 비활성화",
|
||||
"backup_controller_page_background_turn_on": "백그라운드 서비스 활성화",
|
||||
"backup_controller_page_background_wifi": "Wi-Fi에서만",
|
||||
"backup_controller_page_backup": "백업",
|
||||
"backup_controller_page_backup_selected": "선택: ",
|
||||
"backup_controller_page_backup_selected": "선택됨:",
|
||||
"backup_controller_page_backup_sub": "백업된 사진 및 동영상",
|
||||
"backup_controller_page_cancel": "취소",
|
||||
"backup_controller_page_created": "생성일: {}",
|
||||
"backup_controller_page_desc_backup": "앱을 열 때 새 항목을 서버에 자동으로 업로드하려면 포그라운드 백업을 활성화하세요.",
|
||||
"backup_controller_page_excluded": "제외: ",
|
||||
"backup_controller_page_desc_backup": "포그라운드 백업을 활성화하여 앱을 시작할 때 새 항목을 서버에 자동으로 업로드하세요.",
|
||||
"backup_controller_page_excluded": "제외됨:",
|
||||
"backup_controller_page_failed": "실패 ({})",
|
||||
"backup_controller_page_filename": "파일명: {} [{}]",
|
||||
"backup_controller_page_id": "ID: {}",
|
||||
"backup_controller_page_info": "백업 정보",
|
||||
"backup_controller_page_none_selected": "선택한 항목이 없습니다.",
|
||||
"backup_controller_page_remainder": "남은 항목",
|
||||
"backup_controller_page_remainder_sub": "백업할 사진 및 동영상",
|
||||
"backup_controller_page_remainder_sub": "백업 대기 중인 사진 및 동영상",
|
||||
"backup_controller_page_select": "선택",
|
||||
"backup_controller_page_server_storage": "저장 공간",
|
||||
"backup_controller_page_start_backup": "백업 시작",
|
||||
"backup_controller_page_status_off": "자동 백업이 비활성화되었습니다.",
|
||||
"backup_controller_page_status_on": "자동 백업이 활성화되었습니다.",
|
||||
"backup_controller_page_status_off": "포그라운드 백업이 비활성화되었습니다.",
|
||||
"backup_controller_page_status_on": "포그라운드 백업이 활성화되었습니다.",
|
||||
"backup_controller_page_storage_format": "{} 사용 중, 전체 {}",
|
||||
"backup_controller_page_to_backup": "백업 대상 앨범 목록",
|
||||
"backup_controller_page_to_backup": "백업할 앨범 목록",
|
||||
"backup_controller_page_total": "전체",
|
||||
"backup_controller_page_total_sub": "선택한 앨범의 모든 사진 및 동영상",
|
||||
"backup_controller_page_turn_off": "백업 비활성화",
|
||||
"backup_controller_page_turn_on": "백업 활성화",
|
||||
"backup_controller_page_total_sub": "선택한 앨범의 고유한 사진 및 동영상",
|
||||
"backup_controller_page_turn_off": "비활성화",
|
||||
"backup_controller_page_turn_on": "활성화",
|
||||
"backup_controller_page_uploading_file_info": "파일 정보 업로드 중",
|
||||
"backup_err_only_album": "유일한 앨범은 제거할 수 없습니다.",
|
||||
"backup_info_card_assets": "항목",
|
||||
@ -154,7 +154,7 @@
|
||||
"client_cert_enter_password": "비밀번호 입력",
|
||||
"client_cert_import": "가져오기",
|
||||
"client_cert_import_success_msg": "클라이언트 인증서를 가져왔습니다.",
|
||||
"client_cert_invalid_msg": "유효하지 않은 인증서이거나 비밀번호가 일치하지 않습니다.",
|
||||
"client_cert_invalid_msg": "유효하지 않은 인증서 또는 패스프레이즈가 일치하지 않습니다.",
|
||||
"client_cert_remove": "제거",
|
||||
"client_cert_remove_msg": "클라이언트 인증서가 제거되었습니다.",
|
||||
"client_cert_subtitle": "인증서 가져오기/제거는 로그인 전에만 가능합니다. PKCS12 (.p12, .pfx) 형식을 지원합니다.",
|
||||
@ -210,11 +210,15 @@
|
||||
"delete_shared_link_dialog_title": "공유 링크 삭제",
|
||||
"description_input_hint_text": "설명 추가...",
|
||||
"description_input_submit_error": "설명을 변경하는 중 문제가 발생했습니다. 자세한 내용은 로그를 참조하세요.",
|
||||
"download_error": "다운로드 중 문제가 발생했습니다.",
|
||||
"download_started": "다운로드가 시작되었습니다.",
|
||||
"download_sucess": "다운로드가 완료되었습니다.",
|
||||
"download_sucess_android": "미디어가 DCIM/Immich에 저장되었습니다.",
|
||||
"edit_date_time_dialog_date_time": "날짜 및 시간",
|
||||
"edit_date_time_dialog_timezone": "시간대",
|
||||
"edit_image_title": "편집",
|
||||
"edit_location_dialog_title": "위치",
|
||||
"error_saving_image": "오류입니다: {}",
|
||||
"error_saving_image": "오류: {}",
|
||||
"exif_bottom_sheet_description": "설명 추가...",
|
||||
"exif_bottom_sheet_details": "상세 정보",
|
||||
"exif_bottom_sheet_location": "위치",
|
||||
@ -251,7 +255,7 @@
|
||||
"home_page_first_time_notice": "앱을 처음 사용하는 경우 타임라인에 앨범의 사진과 동영상을 채울 수 있도록 백업할 앨범을 선택하세요.",
|
||||
"home_page_share_err_local": "기기의 항목은 링크로 공유할 수 없습니다. 건너뜁니다.",
|
||||
"home_page_upload_err_limit": "한 번에 최대 30개의 항목만 업로드할 수 있습니다.",
|
||||
"image_saved_successfully": "이미지 저장",
|
||||
"image_saved_successfully": "이미지가 저장되었습니다.",
|
||||
"image_viewer_page_state_provider_download_error": "다운로드 오류",
|
||||
"image_viewer_page_state_provider_download_started": "다운로드가 시작되었습니다.",
|
||||
"image_viewer_page_state_provider_download_success": "다운로드 완료",
|
||||
@ -282,14 +286,14 @@
|
||||
"login_form_back_button_text": "뒤로",
|
||||
"login_form_button_text": "로그인",
|
||||
"login_form_email_hint": "youremail@email.com",
|
||||
"login_form_endpoint_hint": "https://your-server-ip:port/api",
|
||||
"login_form_endpoint_hint": "http://your-server-ip:port/api",
|
||||
"login_form_endpoint_url": "서버 엔드포인트 URL",
|
||||
"login_form_err_http": "http:// 또는 https://로 시작해야 합니다.",
|
||||
"login_form_err_invalid_email": "유효하지 않은 이메일",
|
||||
"login_form_err_invalid_url": "잘못된 URL입니다.",
|
||||
"login_form_err_leading_whitespace": "앞에 공백 문자가 있습니다.",
|
||||
"login_form_err_trailing_whitespace": "뒤에 공백 문자가 있습니다.",
|
||||
"login_form_failed_get_oauth_server_config": "OAuth 로그인 중 문제 발생, 서버 URL을 확인해주세요.",
|
||||
"login_form_err_leading_whitespace": "시작 부분에 공백이 있습니다.",
|
||||
"login_form_err_trailing_whitespace": "끝 부분에 공백이 있습니다.",
|
||||
"login_form_failed_get_oauth_server_config": "OAuth 로그인 중 문제 발생, 서버 URL을 확인하세요.",
|
||||
"login_form_failed_get_oauth_server_disable": "이 서버는 OAuth 기능을 지원하지 않습니다.",
|
||||
"login_form_failed_login": "로그인 오류. 서버 URL, 이메일 및 비밀번호를 확인하세요.",
|
||||
"login_form_handshake_exception": "서버와 통신 중 인증서 예외가 발생했습니다. 자체 서명된 인증서를 사용 중이라면, 설정에서 자체 서명된 인증서 허용을 활성화하세요.",
|
||||
@ -343,7 +347,7 @@
|
||||
"notification_permission_dialog_cancel": "취소",
|
||||
"notification_permission_dialog_content": "알림을 활성화하려면 설정에서 알림 권한을 허용하세요.",
|
||||
"notification_permission_dialog_settings": "설정",
|
||||
"notification_permission_list_tile_content": "알림을 활성화하기 위해 권한을 부여하세요.",
|
||||
"notification_permission_list_tile_content": "알림을 활성화하려면 권한을 부여하세요.",
|
||||
"notification_permission_list_tile_enable_button": "알림 활성화",
|
||||
"notification_permission_list_tile_title": "알림 권한",
|
||||
"partner_list_user_photos": "{user}님의 사진",
|
||||
@ -371,7 +375,7 @@
|
||||
"profile_drawer_app_logs": "로그",
|
||||
"profile_drawer_client_out_of_date_major": "모바일 앱이 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요.",
|
||||
"profile_drawer_client_out_of_date_minor": "모바일 앱이 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요.",
|
||||
"profile_drawer_client_server_up_to_date": "모바일 앱과 서버가 최신 버전입니다.",
|
||||
"profile_drawer_client_server_up_to_date": "클라이언트와 서버가 최신입니다.",
|
||||
"profile_drawer_documentation": "문서",
|
||||
"profile_drawer_github": "Github",
|
||||
"profile_drawer_server_out_of_date_major": "서버 버전이 최신이 아닙니다. 최신 버전으로 업데이트하세요.",
|
||||
@ -389,7 +393,7 @@
|
||||
"search_filter_camera_model": "모델명",
|
||||
"search_filter_camera_title": "카메라 종류 선택",
|
||||
"search_filter_date": "날짜",
|
||||
"search_filter_date_interval": "{start}에서 {end} 까지",
|
||||
"search_filter_date_interval": "{start} - {end}",
|
||||
"search_filter_date_title": "날짜 범위 선택",
|
||||
"search_filter_display_option_archive": "보관함",
|
||||
"search_filter_display_option_favorite": "즐겨찾기",
|
||||
@ -411,8 +415,8 @@
|
||||
"search_page_categories": "분류",
|
||||
"search_page_favorites": "즐겨찾기",
|
||||
"search_page_motion_photos": "모션 포토",
|
||||
"search_page_no_objects": "사물 정보가 없습니다.",
|
||||
"search_page_no_places": "장소 정보가 없습니다.",
|
||||
"search_page_no_objects": "사용 가능한 사물 정보 없음",
|
||||
"search_page_no_places": "사용 가능한 위치 정보 없음",
|
||||
"search_page_people": "인물",
|
||||
"search_page_person_add_name_dialog_cancel": "취소",
|
||||
"search_page_person_add_name_dialog_hint": "이름",
|
||||
@ -435,7 +439,7 @@
|
||||
"search_suggestion_list_smart_search_hint_2": "m:your-search-term",
|
||||
"select_additional_user_for_sharing_page_suggestions": "추천",
|
||||
"select_user_for_sharing_page_err_album": "앨범을 생성하지 못했습니다.",
|
||||
"select_user_for_sharing_page_share_suggestions": "추천",
|
||||
"select_user_for_sharing_page_share_suggestions": "제안",
|
||||
"server_info_box_app_version": "앱 버전",
|
||||
"server_info_box_latest_release": "최신 버전",
|
||||
"server_info_box_server_url": "서버 URL",
|
||||
@ -454,12 +458,12 @@
|
||||
"setting_notifications_notify_minutes": "{}분 후",
|
||||
"setting_notifications_notify_never": "알리지 않음",
|
||||
"setting_notifications_notify_seconds": "{}초",
|
||||
"setting_notifications_single_progress_subtitle": "각 항목의 세부 업로드 정보 표시",
|
||||
"setting_notifications_single_progress_title": "백그라운드 작업의 세부 진행률 표시",
|
||||
"setting_notifications_single_progress_subtitle": "개별 항목의 상세 업로드 정보 표시",
|
||||
"setting_notifications_single_progress_title": "백그라운드 백업 상세 진행률 표시",
|
||||
"setting_notifications_subtitle": "알림 기본 설정 조정",
|
||||
"setting_notifications_title": "알림",
|
||||
"setting_notifications_total_progress_subtitle": "전체 업로드 진행률 (완료/전체)",
|
||||
"setting_notifications_total_progress_title": "백그라운드 작업의 전체 진행률 표시",
|
||||
"setting_notifications_total_progress_title": "백그라운드 백업 전체 진행률 표시",
|
||||
"setting_pages_app_bar_settings": "설정",
|
||||
"settings_require_restart": "설정을 적용하려면 Immich를 다시 시작하세요.",
|
||||
"setting_video_viewer_looping_subtitle": "상세 보기에서 동영상을 자동으로 반복합니다.",
|
||||
@ -467,7 +471,7 @@
|
||||
"setting_video_viewer_title": "동영상",
|
||||
"share_add": "추가",
|
||||
"share_add_photos": "사진 추가",
|
||||
"share_add_title": "앨범 제목 입력",
|
||||
"share_add_title": "앨범명 추가",
|
||||
"share_assets_selected": "{}개 항목 선택됨",
|
||||
"share_create_album": "앨범 생성",
|
||||
"shared_album_activities_input_disable": "댓글이 비활성화되었습니다",
|
||||
@ -528,7 +532,7 @@
|
||||
"shared_link_manage_links": "공유 링크 관리",
|
||||
"shared_link_public_album": "공개 앨범",
|
||||
"share_done": "완료",
|
||||
"share_invite": "앨범에 초대",
|
||||
"share_invite": "앨범으로 초대",
|
||||
"sharing_page_album": "공유 앨범",
|
||||
"sharing_page_description": "공유 앨범을 만들어 주변 사람들과 사진 및 동영상을 공유하세요.",
|
||||
"sharing_page_empty_list": "공유 앨범 없음",
|
||||
@ -537,8 +541,8 @@
|
||||
"sharing_silver_appbar_share_partner": "파트너와 공유",
|
||||
"sync": "동기화",
|
||||
"sync_albums": "앨범 동기화",
|
||||
"sync_albums_manual_subtitle": "업로드한 모든 동영상과 사진을 선택한 백업 앨범에 동기화합니다.",
|
||||
"sync_upload_album_setting_subtitle": "Immich에서 선택한 앨범에 사진 및 동영상을 만들고 업로드하세요.",
|
||||
"sync_albums_manual_subtitle": "업로드한 모든 동영상과 사진을 선택한 백업 앨범에 동기화",
|
||||
"sync_upload_album_setting_subtitle": "선택한 앨범을 Immich에 생성하고 사진 및 동영상을 업로드하세요.",
|
||||
"tab_controller_nav_library": "라이브러리",
|
||||
"tab_controller_nav_photos": "사진",
|
||||
"tab_controller_nav_search": "검색",
|
||||
@ -546,7 +550,7 @@
|
||||
"theme_setting_asset_list_storage_indicator_title": "항목에 스토리지 동기화 여부 표시",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "한 줄에 표시할 항목 수 ({})",
|
||||
"theme_setting_colorful_interface_subtitle": "배경에 대표 색상을 적용합니다.",
|
||||
"theme_setting_colorful_interface_title": "컬러풀 인터페이스",
|
||||
"theme_setting_colorful_interface_title": "미려한 인터페이스",
|
||||
"theme_setting_dark_mode_switch": "다크 모드",
|
||||
"theme_setting_image_viewer_quality_subtitle": "상세 보기 이미지 품질 조정",
|
||||
"theme_setting_image_viewer_quality_title": "이미지 보기 품질",
|
||||
@ -559,7 +563,7 @@
|
||||
"theme_setting_three_stage_loading_subtitle": "이 기능은 앱의 로드 성능을 향상시킬 수 있지만 더 많은 데이터를 사용합니다.",
|
||||
"theme_setting_three_stage_loading_title": "3단계 로드 활성화",
|
||||
"translated_text_options": "옵션",
|
||||
"trash_emptied": "휴지통 비우기",
|
||||
"trash_emptied": "휴지통을 비움",
|
||||
"trash_page_delete": "삭제",
|
||||
"trash_page_delete_all": "모두 삭제",
|
||||
"trash_page_empty_trash_btn": "휴지통 비우기",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Delete Shared Link",
|
||||
"description_input_hint_text": "Add description...",
|
||||
"description_input_submit_error": "Error updating description, check the log for more details",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Dzēst Kopīgošanas saiti",
|
||||
"description_input_hint_text": "Pievienot aprakstu...",
|
||||
"description_input_submit_error": "Atjauninot aprakstu, radās kļūda; papildinformāciju skatiet žurnālā",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Datums un Laiks",
|
||||
"edit_date_time_dialog_timezone": "Laika zona",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Delete Shared Link",
|
||||
"description_input_hint_text": "Add description...",
|
||||
"description_input_submit_error": "Error updating description, check the log for more details",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Slett delt link",
|
||||
"description_input_hint_text": "Legg til beskrivelse ...",
|
||||
"description_input_submit_error": "Feil ved oppdatering av beskrivelse, sjekk loggen for flere detaljer",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Dato og tid",
|
||||
"edit_date_time_dialog_timezone": "Tidssone",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Verwijder gedeelde link",
|
||||
"description_input_hint_text": "Beschrijving toevoegen...",
|
||||
"description_input_submit_error": "Beschrijving bijwerken mislukt, controleer het logboek voor meer details",
|
||||
"download_error": "Fout bij downloaden",
|
||||
"download_started": "Download gestart",
|
||||
"download_sucess": "Succesvol gedownload",
|
||||
"download_sucess_android": "Het bestand is gedownload naar DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Datum en tijd",
|
||||
"edit_date_time_dialog_timezone": "Tijdzone",
|
||||
"edit_image_title": "Bewerken",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Usuń udostępniony link",
|
||||
"description_input_hint_text": "Dodaj opis...",
|
||||
"description_input_submit_error": "Błąd aktualizacji opisu, sprawdź dziennik, aby uzyskać więcej szczegółów",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Data i godzina",
|
||||
"edit_date_time_dialog_timezone": "Strefa czasowa",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Excluir link compartilhado",
|
||||
"description_input_hint_text": "Adicionar descrição...",
|
||||
"description_input_submit_error": "Erro ao atualizar a descrição, verifique o registo para obter mais detalhes",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Data e Hora",
|
||||
"edit_date_time_dialog_timezone": "Fuso horário",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Șterge link distribuire",
|
||||
"description_input_hint_text": "Adaugă descriere...",
|
||||
"description_input_submit_error": "Eroare actualizare descriere, verifică log-urile pentru mai multe detalii",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Dată și Oră",
|
||||
"edit_date_time_dialog_timezone": "Fus orar",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Удалить общую ссылку",
|
||||
"description_input_hint_text": "Добавить описание...",
|
||||
"description_input_submit_error": "Не удалось обновить описание, проверьте логи, чтобы узнать причину",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Дата и время",
|
||||
"edit_date_time_dialog_timezone": "Часовой пояс",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Odstrániť zdieľaný odkaz",
|
||||
"description_input_hint_text": "Pridať popis...",
|
||||
"description_input_submit_error": "Chyba pri aktualizovaní popisu, zobrazte log pre viac detailov",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Dátum a čas",
|
||||
"edit_date_time_dialog_timezone": "Časové pásmo",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Izbriši povezavo skupne rabe",
|
||||
"description_input_hint_text": "Dodaj opis ...",
|
||||
"description_input_submit_error": "Napaka pri posodabljanju opisa, preverite dnevnik za več podrobnosti",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Datum in ura",
|
||||
"edit_date_time_dialog_timezone": "Časovni pas",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Delete Shared Link",
|
||||
"description_input_hint_text": "Add description...",
|
||||
"description_input_submit_error": "Error updating description, check the log for more details",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Delete Shared Link",
|
||||
"description_input_hint_text": "Add description...",
|
||||
"description_input_submit_error": "Error updating description, check the log for more details",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Delete Shared Link",
|
||||
"description_input_hint_text": "Add description...",
|
||||
"description_input_submit_error": "Error updating description, check the log for more details",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Ta Bort Delad Länk",
|
||||
"description_input_hint_text": "Lägg till beskrivning...",
|
||||
"description_input_submit_error": "Fel vid uppdatering av beskrivning, se loggen för fler detaljer",
|
||||
"download_error": "Fel vid nedladdning",
|
||||
"download_started": "Nedladdning påbörjad",
|
||||
"download_sucess": "Nedladdning lyckades",
|
||||
"download_sucess_android": "Media har laddats ner till DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Datum och Tid",
|
||||
"edit_date_time_dialog_timezone": "Tidszon",
|
||||
"edit_image_title": "Redigera",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "ลบลิงก์ที่แชร์",
|
||||
"description_input_hint_text": "เพื่มรายละเอียด...",
|
||||
"description_input_submit_error": "อัพเดตรายละเอียดผิดพลาด ตรวจสอบ log เพื่อรายละเอียดเพิ่มเติม",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "วันและเวลา",
|
||||
"edit_date_time_dialog_timezone": "เขดเวลา",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Видалити спільне посилання",
|
||||
"description_input_hint_text": "Додати опис...",
|
||||
"description_input_submit_error": "Помилка оновлення опису, перевірте логи для подробиць",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Дата і час",
|
||||
"edit_date_time_dialog_timezone": "Часовий пояс",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -94,7 +94,7 @@
|
||||
"backup_controller_page_background_turn_on": "Bật dịch vụ nền",
|
||||
"backup_controller_page_background_wifi": "Chỉ khi dùng Wi-Fi",
|
||||
"backup_controller_page_backup": "Sao lưu",
|
||||
"backup_controller_page_backup_selected": "Đã chọn:",
|
||||
"backup_controller_page_backup_selected": "Đã chọn: ",
|
||||
"backup_controller_page_backup_sub": "Ảnh và video đã sao lưu",
|
||||
"backup_controller_page_cancel": "Từ chối",
|
||||
"backup_controller_page_created": "Tạo vào: {}",
|
||||
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Xoá liên kết đã chia sẻ",
|
||||
"description_input_hint_text": "Thêm mô tả...",
|
||||
"description_input_submit_error": "Cập nhật mô tả không thành công, vui lòng kiểm tra nhật ký để biết thêm chi tiết",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Ngày và Giờ",
|
||||
"edit_date_time_dialog_timezone": "Múi giờ",
|
||||
"edit_image_title": "Sửa",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "删除共享链接",
|
||||
"description_input_hint_text": "添加描述...",
|
||||
"description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息",
|
||||
"download_error": "下载出错",
|
||||
"download_started": "开始下载",
|
||||
"download_sucess": "下载成功",
|
||||
"download_sucess_android": "媒体已下载至 DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "日期和时间",
|
||||
"edit_date_time_dialog_timezone": "时区",
|
||||
"edit_image_title": "编辑",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "删除共享链接",
|
||||
"description_input_hint_text": "添加描述...",
|
||||
"description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息",
|
||||
"download_error": "下载出错",
|
||||
"download_started": "开始下载",
|
||||
"download_sucess": "下载成功",
|
||||
"download_sucess_android": "媒体已下载至 DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "日期和时间",
|
||||
"edit_date_time_dialog_timezone": "时区",
|
||||
"edit_image_title": "编辑",
|
||||
|
@ -210,6 +210,10 @@
|
||||
"delete_shared_link_dialog_title": "Delete Shared Link",
|
||||
"description_input_hint_text": "Add description...",
|
||||
"description_input_submit_error": "Error updating description, check the log for more details",
|
||||
"download_error": "Download Error",
|
||||
"download_started": "Download started",
|
||||
"download_sucess": "Download success",
|
||||
"download_sucess_android": "The media has been downloaded to DCIM/Immich",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_image_title": "Edit",
|
||||
|
@ -401,7 +401,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 172;
|
||||
CURRENT_PROJECT_VERSION = 173;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -543,7 +543,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 172;
|
||||
CURRENT_PROJECT_VERSION = 173;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -571,7 +571,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 172;
|
||||
CURRENT_PROJECT_VERSION = 173;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
@ -58,11 +58,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.113.1</string>
|
||||
<string>1.114.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>172</string>
|
||||
<string>173</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Release"
|
||||
lane :release do
|
||||
increment_version_number(
|
||||
version_number: "1.113.1"
|
||||
version_number: "1.114.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 1.113.1
|
||||
- API version: 1.114.0
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 1.113.1+157
|
||||
version: 1.114.0+158
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
|
@ -7394,7 +7394,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
|
4
open-api/typescript-sdk/package-lock.json
generated
4
open-api/typescript-sdk/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 1.113.1
|
||||
* 1.114.0
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@nestjs/bullmq": "^10.0.1",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
@ -75,6 +75,7 @@ export const supportedPresetTokens = [
|
||||
'{{y}}/{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MM}}/{{filename}}',
|
||||
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}',
|
||||
'{{y}}/{{MMM}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}/{{filename}}',
|
||||
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
|
||||
|
@ -12,12 +12,14 @@ export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadat
|
||||
}
|
||||
|
||||
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
|
||||
export type SystemFlags = { mountFiles: boolean };
|
||||
|
||||
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
|
||||
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
|
||||
[SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string };
|
||||
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
|
||||
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
|
||||
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
|
||||
[SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string };
|
||||
[SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date };
|
||||
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
|
||||
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
|
||||
[SystemMetadataKey.SYSTEM_FLAGS]: SystemFlags;
|
||||
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
|
||||
}
|
||||
|
@ -153,6 +153,7 @@ export enum SystemMetadataKey {
|
||||
FACIAL_RECOGNITION_STATE = 'facial-recognition-state',
|
||||
ADMIN_ONBOARDING = 'admin-onboarding',
|
||||
SYSTEM_CONFIG = 'system-config',
|
||||
SYSTEM_FLAGS = 'system-flags',
|
||||
VERSION_CHECK_STATE = 'version-check-state',
|
||||
LICENSE = 'license',
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ export enum VectorIndex {
|
||||
export enum DatabaseLock {
|
||||
GeodataImport = 100,
|
||||
Migrations = 200,
|
||||
SystemFileMounts = 300,
|
||||
StorageTemplateMigration = 420,
|
||||
CLIPDimSize = 512,
|
||||
LibraryWatch = 1337,
|
||||
|
@ -21,6 +21,9 @@ type EmitEventMap = {
|
||||
'asset.tag': [{ assetId: string }];
|
||||
'asset.untag': [{ assetId: string }];
|
||||
|
||||
// session events
|
||||
'session.delete': [{ sessionId: string }];
|
||||
|
||||
// user events
|
||||
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
|
||||
};
|
||||
@ -43,6 +46,7 @@ export enum ClientEvent {
|
||||
SERVER_VERSION = 'on_server_version',
|
||||
CONFIG_UPDATE = 'on_config_update',
|
||||
NEW_RELEASE = 'on_new_release',
|
||||
SESSION_DELETE = 'on_session_delete',
|
||||
}
|
||||
|
||||
export interface ClientEventMap {
|
||||
@ -58,6 +62,7 @@ export interface ClientEventMap {
|
||||
[ClientEvent.SERVER_VERSION]: ServerVersionResponseDto;
|
||||
[ClientEvent.CONFIG_UPDATE]: Record<string, never>;
|
||||
[ClientEvent.NEW_RELEASE]: ReleaseNotification;
|
||||
[ClientEvent.SESSION_DELETE]: string;
|
||||
}
|
||||
|
||||
export enum ServerEvent {
|
||||
@ -77,7 +82,7 @@ export interface IEventRepository {
|
||||
/**
|
||||
* Send to connected clients for a specific user
|
||||
*/
|
||||
clientSend<E extends keyof ClientEventMap>(event: E, userId: string, data: ClientEventMap[E]): void;
|
||||
clientSend<E extends keyof ClientEventMap>(event: E, room: string, data: ClientEventMap[E]): void;
|
||||
/**
|
||||
* Send to all connected clients
|
||||
*/
|
||||
|
@ -26,7 +26,7 @@ export interface MapMarker extends ReverseGeocodeResult {
|
||||
|
||||
export interface IMapRepository {
|
||||
init(): Promise<void>;
|
||||
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null>;
|
||||
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
|
||||
getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
|
||||
fetchStyle(url: string): Promise<any>;
|
||||
}
|
||||
|
@ -50,7 +50,7 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
||||
|
||||
export interface IMetadataRepository {
|
||||
teardown(): Promise<void>;
|
||||
readTags(path: string): Promise<ImmichTags | null>;
|
||||
readTags(path: string): Promise<ImmichTags>;
|
||||
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
||||
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
|
||||
getCountries(userIds: string[]): Promise<Array<string | null>>;
|
||||
|
@ -17,7 +17,13 @@ async function bootstrapImmichAdmin() {
|
||||
|
||||
function bootstrapWorker(name: string) {
|
||||
console.log(`Starting ${name} worker`);
|
||||
|
||||
const worker = name === 'api' ? fork(`./dist/workers/${name}.js`) : new Worker(`./dist/workers/${name}.js`);
|
||||
|
||||
worker.on('error', (error) => {
|
||||
console.error(`${name} worker error: ${error}`);
|
||||
});
|
||||
|
||||
worker.on('exit', (exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
console.error(`${name} worker exited with code ${exitCode}`);
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
@ -37,7 +38,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
||||
private server?: Server;
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private moduleRef: ModuleRef,
|
||||
private eventEmitter: EventEmitter2,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
@ -62,12 +63,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
this.logger.log(`Websocket Connect: ${client.id}`);
|
||||
const auth = await this.authService.authenticate({
|
||||
const auth = await this.moduleRef.get(AuthService).authenticate({
|
||||
headers: client.request.headers,
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' },
|
||||
});
|
||||
await client.join(auth.user.id);
|
||||
if (auth.session) {
|
||||
await client.join(auth.session.id);
|
||||
}
|
||||
this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id });
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
|
||||
@ -96,8 +100,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
||||
}
|
||||
}
|
||||
|
||||
clientSend<E extends keyof ClientEventMap>(event: E, userId: string, data: ClientEventMap[E]) {
|
||||
this.server?.to(userId).emit(event, data);
|
||||
clientSend<E extends keyof ClientEventMap>(event: E, room: string, data: ClientEventMap[E]) {
|
||||
this.server?.to(room).emit(event, data);
|
||||
}
|
||||
|
||||
clientBroadcast<E extends keyof ClientEventMap>(event: E, data: ClientEventMap[E]) {
|
||||
|
@ -124,7 +124,7 @@ export class MapRepository implements IMapRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null> {
|
||||
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
|
||||
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
|
||||
|
||||
const response = await this.geodataPlacesRepository
|
||||
@ -159,7 +159,7 @@ export class MapRepository implements IMapRepository {
|
||||
`Response from database for natural earth reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`,
|
||||
);
|
||||
|
||||
return null;
|
||||
return { country: null, state: null, city: null };
|
||||
}
|
||||
|
||||
this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`);
|
||||
|
@ -36,11 +36,11 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
await this.exiftool.end();
|
||||
}
|
||||
|
||||
readTags(path: string): Promise<ImmichTags | null> {
|
||||
readTags(path: string): Promise<ImmichTags> {
|
||||
return this.exiftool.read(path).catch((error) => {
|
||||
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
|
||||
return null;
|
||||
}) as Promise<ImmichTags | null>;
|
||||
return {};
|
||||
}) as Promise<ImmichTags>;
|
||||
}
|
||||
|
||||
extractBinaryTag(path: string, tagName: string): Promise<Buffer> {
|
||||
|
@ -6,6 +6,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { SourceType } from 'src/enum';
|
||||
import {
|
||||
AssetFaceId,
|
||||
DeleteAllFacesOptions,
|
||||
@ -53,16 +54,20 @@ export class PersonRepository implements IPersonRepository {
|
||||
}
|
||||
|
||||
async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise<void> {
|
||||
if (sourceType) {
|
||||
if (!sourceType) {
|
||||
return this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
|
||||
}
|
||||
|
||||
await this.assetFaceRepository
|
||||
.createQueryBuilder('asset_faces')
|
||||
.delete()
|
||||
.andWhere('sourceType = :sourceType', { sourceType })
|
||||
.execute();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
|
||||
await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search');
|
||||
if (sourceType === SourceType.MACHINE_LEARNING) {
|
||||
await this.assetFaceRepository.query('REINDEX INDEX face_index');
|
||||
}
|
||||
}
|
||||
|
||||
getAllFaces(
|
||||
|
@ -6,6 +6,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
@ -20,6 +21,7 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
|
||||
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
|
||||
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
|
||||
@ -56,6 +58,7 @@ const oauthUserWithDefaultQuota = {
|
||||
describe('AuthService', () => {
|
||||
let sut: AuthService;
|
||||
let cryptoMock: Mocked<ICryptoRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
@ -87,6 +90,7 @@ describe('AuthService', () => {
|
||||
} as any);
|
||||
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
systemMock = newSystemMetadataRepositoryMock();
|
||||
@ -94,7 +98,7 @@ describe('AuthService', () => {
|
||||
shareMock = newSharedLinkRepositoryMock();
|
||||
keyMock = newKeyRepositoryMock();
|
||||
|
||||
sut = new AuthService(cryptoMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock);
|
||||
sut = new AuthService(cryptoMock, eventMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@ -208,6 +212,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
expect(sessionMock.delete).toHaveBeenCalledWith('token123');
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' });
|
||||
});
|
||||
|
||||
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
|
||||
|
@ -34,6 +34,7 @@ import { UserEntity } from 'src/entities/user.entity';
|
||||
import { Permission } from 'src/enum';
|
||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
@ -75,6 +76,7 @@ export class AuthService {
|
||||
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@ -114,6 +116,7 @@ export class AuthService {
|
||||
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||
if (auth.session) {
|
||||
await this.sessionRepository.delete(auth.session.id);
|
||||
await this.eventRepository.emit('session.delete', { sessionId: auth.session.id });
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -1247,6 +1247,7 @@ describe(MediaService.name, () => {
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-strict unofficial',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset 12',
|
||||
@ -1372,6 +1373,7 @@ describe(MediaService.name, () => {
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-strict unofficial',
|
||||
'-g 256',
|
||||
'-v verbose',
|
||||
'-vf format=nv12,hwupload_cuda,scale_cuda=-2:720',
|
||||
@ -1532,6 +1534,7 @@ describe(MediaService.name, () => {
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-strict unofficial',
|
||||
'-bf 7',
|
||||
'-refs 5',
|
||||
'-g 256',
|
||||
@ -1716,6 +1719,7 @@ describe(MediaService.name, () => {
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-strict unofficial',
|
||||
'-g 256',
|
||||
'-v verbose',
|
||||
'-vf format=nv12,hwupload,scale_vaapi=-2:720',
|
||||
@ -1913,6 +1917,7 @@ describe(MediaService.name, () => {
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-strict unofficial',
|
||||
'-g 256',
|
||||
'-v verbose',
|
||||
'-vf scale_rkrga=-2:720:format=nv12:afbc=1',
|
||||
|
@ -522,13 +522,13 @@ describe(MetadataService.name, () => {
|
||||
it('should extract the correct video orientation', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||
metadataMock.readTags.mockResolvedValue(null);
|
||||
metadataMock.readTags.mockResolvedValue({});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.video.id });
|
||||
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]);
|
||||
expect(assetMock.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ orientation: Orientation.Rotate270CW }),
|
||||
expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }),
|
||||
);
|
||||
});
|
||||
|
||||
@ -814,6 +814,9 @@ describe(MetadataService.name, () => {
|
||||
projectionType: 'EQUIRECTANGULAR',
|
||||
timeZone: tags.tz,
|
||||
rating: tags.Rating,
|
||||
country: null,
|
||||
state: null,
|
||||
city: null,
|
||||
});
|
||||
expect(assetMock.update).toHaveBeenCalledWith({
|
||||
id: assetStub.image.id,
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored';
|
||||
import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored';
|
||||
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
|
||||
import _ from 'lodash';
|
||||
import { Duration } from 'luxon';
|
||||
@ -11,7 +11,6 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { AssetType, SourceType } from 'src/enum';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
@ -30,7 +29,7 @@ import {
|
||||
QueueName,
|
||||
} from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IMapRepository } from 'src/interfaces/map.interface';
|
||||
import { IMapRepository, ReverseGeocodeResult } from 'src/interfaces/map.interface';
|
||||
import { IMediaRepository } from 'src/interfaces/media.interface';
|
||||
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
|
||||
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||
@ -56,23 +55,16 @@ const EXIF_DATE_TAGS: Array<keyof Tags> = [
|
||||
];
|
||||
|
||||
export enum Orientation {
|
||||
Horizontal = '1',
|
||||
MirrorHorizontal = '2',
|
||||
Rotate180 = '3',
|
||||
MirrorVertical = '4',
|
||||
MirrorHorizontalRotate270CW = '5',
|
||||
Rotate90CW = '6',
|
||||
MirrorHorizontalRotate90CW = '7',
|
||||
Rotate270CW = '8',
|
||||
Horizontal = 1,
|
||||
MirrorHorizontal = 2,
|
||||
Rotate180 = 3,
|
||||
MirrorVertical = 4,
|
||||
MirrorHorizontalRotate270CW = 5,
|
||||
Rotate90CW = 6,
|
||||
MirrorHorizontalRotate90CW = 7,
|
||||
Rotate270CW = 8,
|
||||
}
|
||||
|
||||
type ExifEntityWithoutGeocodeAndTypeOrm = Omit<ExifEntity, 'city' | 'state' | 'country' | 'description'> & {
|
||||
dateTimeOriginal: Date;
|
||||
};
|
||||
|
||||
const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null);
|
||||
const tzOffset = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.tzoffsetMinutes : null);
|
||||
|
||||
const validate = <T>(value: T): NonNullable<T> | null => {
|
||||
// handle lists of numbers
|
||||
if (Array.isArray(value)) {
|
||||
@ -218,36 +210,73 @@ export class MetadataService {
|
||||
}
|
||||
|
||||
async handleMetadataExtraction({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const { metadata } = await this.configCore.getConfig({ withCache: true });
|
||||
const { metadata, reverseGeocoding } = await this.configCore.getConfig({ withCache: true });
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
const { exifData, exifTags } = await this.exifData(asset);
|
||||
const stats = await this.storageRepository.stat(asset.originalPath);
|
||||
|
||||
if (asset.type === AssetType.VIDEO) {
|
||||
await this.applyVideoMetadata(asset, exifData);
|
||||
}
|
||||
const exifTags = await this.getExifTags(asset);
|
||||
|
||||
this.logger.verbose('Exif Tags', exifTags);
|
||||
|
||||
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
|
||||
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
|
||||
|
||||
const exifData = {
|
||||
assetId: asset.id,
|
||||
|
||||
// dates
|
||||
dateTimeOriginal,
|
||||
modifyDate,
|
||||
timeZone,
|
||||
|
||||
// gps
|
||||
latitude,
|
||||
longitude,
|
||||
country,
|
||||
state,
|
||||
city,
|
||||
|
||||
// image/file
|
||||
fileSizeInByte: stats.size,
|
||||
exifImageHeight: validate(exifTags.ImageHeight),
|
||||
exifImageWidth: validate(exifTags.ImageWidth),
|
||||
orientation: validate(exifTags.Orientation)?.toString() ?? null,
|
||||
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
|
||||
bitsPerSample: this.getBitsPerSample(exifTags),
|
||||
colorspace: exifTags.ColorSpace ?? null,
|
||||
|
||||
// camera
|
||||
make: exifTags.Make ?? null,
|
||||
model: exifTags.Model ?? null,
|
||||
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
||||
iso: validate(exifTags.ISO),
|
||||
exposureTime: exifTags.ExposureTime ?? null,
|
||||
lensModel: exifTags.LensModel ?? null,
|
||||
fNumber: validate(exifTags.FNumber),
|
||||
focalLength: validate(exifTags.FocalLength),
|
||||
|
||||
// comments
|
||||
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
|
||||
profileDescription: exifTags.ProfileDescription || null,
|
||||
rating: exifTags.Rating ?? null,
|
||||
|
||||
// grouping
|
||||
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
|
||||
autoStackId: this.getAutoStackId(exifTags),
|
||||
};
|
||||
|
||||
await this.applyMotionPhotos(asset, exifTags);
|
||||
await this.applyReverseGeocoding(asset, exifData);
|
||||
await this.applyTagList(asset, exifTags);
|
||||
await this.applyMotionPhotos(asset, exifTags);
|
||||
|
||||
await this.assetRepository.upsertExif(exifData);
|
||||
|
||||
const dateTimeOriginal = exifData.dateTimeOriginal;
|
||||
let localDateTime = dateTimeOriginal ?? undefined;
|
||||
|
||||
const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0;
|
||||
|
||||
if (dateTimeOriginal && timeZoneOffset) {
|
||||
localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
|
||||
}
|
||||
|
||||
await this.assetRepository.update({
|
||||
id: asset.id,
|
||||
duration: asset.duration,
|
||||
duration: exifTags.Duration?.toString() ?? null,
|
||||
localDateTime,
|
||||
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
|
||||
});
|
||||
@ -338,25 +367,20 @@ export class MetadataService {
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
|
||||
const { latitude, longitude } = exifData;
|
||||
const { reverseGeocoding } = await this.configCore.getConfig({ withCache: true });
|
||||
if (!reverseGeocoding.enabled || !longitude || !latitude) {
|
||||
return;
|
||||
private async getExifTags(asset: AssetEntity): Promise<ImmichTags> {
|
||||
const mediaTags = await this.repository.readTags(asset.originalPath);
|
||||
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : {};
|
||||
const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {};
|
||||
|
||||
// make sure dates comes from sidecar
|
||||
const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS);
|
||||
if (sidecarDate) {
|
||||
for (const tag of EXIF_DATE_TAGS) {
|
||||
delete mediaTags[tag];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const reverseGeocode = await this.mapRepository.reverseGeocode({ latitude, longitude });
|
||||
if (!reverseGeocode) {
|
||||
return;
|
||||
}
|
||||
Object.assign(exifData, reverseGeocode);
|
||||
} catch (error: Error | any) {
|
||||
this.logger.warn(
|
||||
`Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||
error?.stack,
|
||||
);
|
||||
}
|
||||
return { ...mediaTags, ...videoTags, ...sidecarTags };
|
||||
}
|
||||
|
||||
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
|
||||
@ -576,66 +600,65 @@ export class MetadataService {
|
||||
);
|
||||
}
|
||||
|
||||
private async exifData(
|
||||
asset: AssetEntity,
|
||||
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> {
|
||||
const stats = await this.storageRepository.stat(asset.originalPath);
|
||||
const mediaTags = await this.repository.readTags(asset.originalPath);
|
||||
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null;
|
||||
private getDates(asset: AssetEntity, exifTags: ImmichTags) {
|
||||
const dateTime = firstDateTime(exifTags as Maybe<Tags>, EXIF_DATE_TAGS);
|
||||
this.logger.debug(`Asset ${asset.id} date time is ${dateTime}`);
|
||||
|
||||
// ensure date from sidecar is used if present
|
||||
const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags);
|
||||
if (mediaTags && hasDateOverride) {
|
||||
for (const tag of EXIF_DATE_TAGS) {
|
||||
delete mediaTags[tag];
|
||||
}
|
||||
// created
|
||||
let dateTimeOriginal = dateTime?.toDate();
|
||||
if (!dateTimeOriginal) {
|
||||
this.logger.warn(`Asset ${asset.id} has no valid date (${dateTime}), falling back to asset.fileCreatedAt`);
|
||||
dateTimeOriginal = asset.fileCreatedAt;
|
||||
}
|
||||
|
||||
const exifTags = { ...mediaTags, ...sidecarTags };
|
||||
// timezone
|
||||
let timeZone = exifTags.tz ?? null;
|
||||
if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) {
|
||||
// exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly
|
||||
// https://github.com/photostructure/exiftool-vendored.js/issues/203
|
||||
timeZone = 'UTC+0';
|
||||
}
|
||||
|
||||
this.logger.verbose('Exif Tags', exifTags);
|
||||
if (timeZone) {
|
||||
this.logger.debug(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`);
|
||||
} else {
|
||||
this.logger.warn(`Asset ${asset.id} has no time zone information`);
|
||||
}
|
||||
|
||||
const dateTimeOriginalWithRawValue = this.getDateTimeOriginalWithRawValue(exifTags);
|
||||
const dateTimeOriginal = dateTimeOriginalWithRawValue.exifDate ?? asset.fileCreatedAt;
|
||||
const timeZone = this.getTimeZone(exifTags, dateTimeOriginalWithRawValue.rawValue);
|
||||
// offset minutes
|
||||
const offsetMinutes = dateTime?.tzoffsetMinutes || 0;
|
||||
let localDateTime = dateTimeOriginal;
|
||||
if (offsetMinutes) {
|
||||
localDateTime = new Date(dateTimeOriginal.getTime() + offsetMinutes * 60_000);
|
||||
this.logger.debug(`Asset ${asset.id} local time is offset by ${offsetMinutes} minutes`);
|
||||
}
|
||||
|
||||
const exifData = {
|
||||
// altitude: tags.GPSAltitude ?? null,
|
||||
assetId: asset.id,
|
||||
bitsPerSample: this.getBitsPerSample(exifTags),
|
||||
colorspace: exifTags.ColorSpace ?? null,
|
||||
return {
|
||||
dateTimeOriginal,
|
||||
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
|
||||
exifImageHeight: validate(exifTags.ImageHeight),
|
||||
exifImageWidth: validate(exifTags.ImageWidth),
|
||||
exposureTime: exifTags.ExposureTime ?? null,
|
||||
fileSizeInByte: stats.size,
|
||||
fNumber: validate(exifTags.FNumber),
|
||||
focalLength: validate(exifTags.FocalLength),
|
||||
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
||||
iso: validate(exifTags.ISO),
|
||||
latitude: validate(exifTags.GPSLatitude),
|
||||
lensModel: exifTags.LensModel ?? null,
|
||||
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
|
||||
autoStackId: this.getAutoStackId(exifTags),
|
||||
longitude: validate(exifTags.GPSLongitude),
|
||||
make: exifTags.Make ?? null,
|
||||
model: exifTags.Model ?? null,
|
||||
modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt,
|
||||
orientation: validate(exifTags.Orientation)?.toString() ?? null,
|
||||
profileDescription: exifTags.ProfileDescription || null,
|
||||
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
|
||||
timeZone,
|
||||
rating: exifTags.Rating ?? null,
|
||||
localDateTime,
|
||||
modifyDate: (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? asset.fileModifiedAt,
|
||||
};
|
||||
|
||||
if (exifData.latitude === 0 && exifData.longitude === 0) {
|
||||
this.logger.warn('Exif data has latitude and longitude of 0, setting to null');
|
||||
exifData.latitude = null;
|
||||
exifData.longitude = null;
|
||||
}
|
||||
|
||||
return { exifData, exifTags };
|
||||
private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) {
|
||||
let latitude = validate(tags.GPSLatitude);
|
||||
let longitude = validate(tags.GPSLongitude);
|
||||
|
||||
// TODO take ref into account
|
||||
|
||||
if (latitude === 0 && longitude === 0) {
|
||||
this.logger.warn('Latitude and longitude of 0, setting to null');
|
||||
latitude = null;
|
||||
longitude = null;
|
||||
}
|
||||
|
||||
let result: ReverseGeocodeResult = { country: null, state: null, city: null };
|
||||
if (reverseGeocoding.enabled && longitude && latitude) {
|
||||
result = await this.mapRepository.reverseGeocode({ latitude, longitude });
|
||||
}
|
||||
|
||||
return { ...result, latitude, longitude };
|
||||
}
|
||||
|
||||
private getAutoStackId(tags: ImmichTags | null): string | null {
|
||||
@ -645,28 +668,6 @@ export class MetadataService {
|
||||
return tags.BurstID ?? tags.BurstUUID ?? tags.CameraBurstID ?? tags.MediaUniqueID ?? null;
|
||||
}
|
||||
|
||||
private getDateTimeOriginal(tags: ImmichTags | Tags | null) {
|
||||
return this.getDateTimeOriginalWithRawValue(tags).exifDate;
|
||||
}
|
||||
|
||||
private getDateTimeOriginalWithRawValue(tags: ImmichTags | Tags | null): { exifDate: Date | null; rawValue: string } {
|
||||
if (!tags) {
|
||||
return { exifDate: null, rawValue: '' };
|
||||
}
|
||||
const first = firstDateTime(tags as Tags, EXIF_DATE_TAGS);
|
||||
return { exifDate: exifDate(first), rawValue: first?.rawValue ?? '' };
|
||||
}
|
||||
|
||||
private getTimeZone(exifTags: ImmichTags, rawValue: string) {
|
||||
const timeZone = exifTags.tz ?? null;
|
||||
if (timeZone == null && rawValue.endsWith('+00:00')) {
|
||||
// exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly
|
||||
// https://github.com/photostructure/exiftool-vendored.js/issues/203
|
||||
return 'UTC+0';
|
||||
}
|
||||
return timeZone;
|
||||
}
|
||||
|
||||
private getBitsPerSample(tags: ImmichTags): number | null {
|
||||
const bitDepthTags = [
|
||||
tags.BitsPerSample,
|
||||
@ -685,33 +686,37 @@ export class MetadataService {
|
||||
return bitsPerSample;
|
||||
}
|
||||
|
||||
private async applyVideoMetadata(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
|
||||
const { videoStreams, format } = await this.mediaRepository.probe(asset.originalPath);
|
||||
private async getVideoTags(originalPath: string) {
|
||||
const { videoStreams, format } = await this.mediaRepository.probe(originalPath);
|
||||
|
||||
const tags: Pick<ImmichTags, 'Duration' | 'Orientation'> = {};
|
||||
|
||||
if (videoStreams[0]) {
|
||||
switch (videoStreams[0].rotation) {
|
||||
case -90: {
|
||||
exifData.orientation = Orientation.Rotate90CW;
|
||||
tags.Orientation = Orientation.Rotate90CW;
|
||||
break;
|
||||
}
|
||||
case 0: {
|
||||
exifData.orientation = Orientation.Horizontal;
|
||||
tags.Orientation = Orientation.Horizontal;
|
||||
break;
|
||||
}
|
||||
case 90: {
|
||||
exifData.orientation = Orientation.Rotate270CW;
|
||||
tags.Orientation = Orientation.Rotate270CW;
|
||||
break;
|
||||
}
|
||||
case 180: {
|
||||
exifData.orientation = Orientation.Rotate180;
|
||||
tags.Orientation = Orientation.Rotate180;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (format.duration) {
|
||||
asset.duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS');
|
||||
tags.Duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS');
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
private async processSidecar(id: string, isSync: boolean): Promise<JobStatus> {
|
||||
|
@ -6,6 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
||||
import { AssetFileType, UserMetadataKey } from 'src/enum';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
|
||||
@ -17,6 +18,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
|
||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock';
|
||||
@ -64,6 +66,7 @@ const configs = {
|
||||
describe(NotificationService.name, () => {
|
||||
let albumMock: Mocked<IAlbumRepository>;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let notificationMock: Mocked<INotificationRepository>;
|
||||
@ -74,13 +77,23 @@ describe(NotificationService.name, () => {
|
||||
beforeEach(() => {
|
||||
albumMock = newAlbumRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
notificationMock = newNotificationRepositoryMock();
|
||||
systemMock = newSystemMetadataRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
|
||||
sut = new NotificationService(systemMock, notificationMock, userMock, jobMock, loggerMock, assetMock, albumMock);
|
||||
sut = new NotificationService(
|
||||
eventMock,
|
||||
systemMock,
|
||||
notificationMock,
|
||||
userMock,
|
||||
jobMock,
|
||||
loggerMock,
|
||||
assetMock,
|
||||
albumMock,
|
||||
);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
|
@ -6,7 +6,7 @@ import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
||||
import { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IEmailJob,
|
||||
IJobRepository,
|
||||
@ -30,6 +30,7 @@ export class NotificationService {
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
||||
@Inject(INotificationRepository) private notificationRepository: INotificationRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@ -74,6 +75,12 @@ export class NotificationService {
|
||||
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } });
|
||||
}
|
||||
|
||||
@OnEmit({ event: 'session.delete' })
|
||||
onSessionDelete({ sessionId }: ArgOf<'session.delete'>) {
|
||||
// after the response is sent
|
||||
setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500);
|
||||
}
|
||||
|
||||
async sendTestEmail(id: string, dto: SystemConfigSmtpDto) {
|
||||
const user = await this.userRepository.get(id, { withDeleted: false });
|
||||
if (!user) {
|
||||
|
@ -1,19 +1,29 @@
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { StorageService } from 'src/services/storage.service';
|
||||
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(StorageService.name, () => {
|
||||
let sut: StorageService;
|
||||
let databaseMock: Mocked<IDatabaseRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
databaseMock = newDatabaseRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
sut = new StorageService(storageMock, loggerMock);
|
||||
systemMock = newSystemMetadataRepositoryMock();
|
||||
|
||||
sut = new StorageService(databaseMock, storageMock, loggerMock, systemMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@ -21,9 +31,35 @@ describe(StorageService.name, () => {
|
||||
});
|
||||
|
||||
describe('onBootstrap', () => {
|
||||
it('should create the library folder on initialization', () => {
|
||||
sut.onBootstrap();
|
||||
it('should enable mount folder checking', async () => {
|
||||
systemMock.get.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountFiles: true });
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/encoded-video');
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library');
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile');
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs');
|
||||
});
|
||||
|
||||
it('should throw an error if .immich is missing', async () => {
|
||||
systemMock.get.mockResolvedValue({ mountFiles: true });
|
||||
storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
|
||||
|
||||
expect(storageMock.writeFile).not.toHaveBeenCalled();
|
||||
expect(systemMock.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if .immich is present but read-only', async () => {
|
||||
systemMock.get.mockResolvedValue({ mountFiles: true });
|
||||
storageMock.writeFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
|
||||
|
||||
expect(systemMock.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,23 +1,52 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { join } from 'node:path';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { ImmichStartupError } from 'src/utils/events';
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
constructor(
|
||||
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
@Inject(ISystemMetadataRepository) private systemMetadata: ISystemMetadataRepository,
|
||||
) {
|
||||
this.logger.setContext(StorageService.name);
|
||||
}
|
||||
|
||||
@OnEmit({ event: 'app.bootstrap' })
|
||||
onBootstrap() {
|
||||
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
|
||||
this.storageRepository.mkdirSync(libraryBase);
|
||||
async onBootstrap() {
|
||||
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
|
||||
const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
|
||||
|
||||
this.logger.log('Verifying system mount folder checks');
|
||||
|
||||
// check each folder exists and is writable
|
||||
for (const folder of Object.values(StorageFolder)) {
|
||||
if (!flags.mountFiles) {
|
||||
this.logger.log(`Writing initial mount file for the ${folder} folder`);
|
||||
await this.verifyWriteAccess(folder);
|
||||
}
|
||||
|
||||
await this.verifyReadAccess(folder);
|
||||
await this.verifyWriteAccess(folder);
|
||||
}
|
||||
|
||||
if (!flags.mountFiles) {
|
||||
flags.mountFiles = true;
|
||||
await this.systemMetadata.set(SystemMetadataKey.SYSTEM_FLAGS, flags);
|
||||
this.logger.log('Successfully enabled system mount folders checks');
|
||||
}
|
||||
|
||||
this.logger.log('Successfully verified system mount folder checks');
|
||||
});
|
||||
}
|
||||
|
||||
async handleDeleteFiles(job: IDeleteFilesJob) {
|
||||
@ -38,4 +67,38 @@ export class StorageService {
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
private async verifyReadAccess(folder: StorageFolder) {
|
||||
const { filePath } = this.getMountFilePaths(folder);
|
||||
try {
|
||||
await this.storageRepository.readFile(filePath);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to read ${filePath}: ${error}`);
|
||||
this.logger.error(
|
||||
`The "${folder}" folder appears to be offline/missing, please make sure the volume is mounted with the correct permissions`,
|
||||
);
|
||||
throw new ImmichStartupError(`Failed to validate folder mount (read from "<MEDIA_LOCATION>/${folder}")`);
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyWriteAccess(folder: StorageFolder) {
|
||||
const { folderPath, filePath } = this.getMountFilePaths(folder);
|
||||
try {
|
||||
this.storageRepository.mkdirSync(folderPath);
|
||||
await this.storageRepository.writeFile(filePath, Buffer.from(`${Date.now()}`));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to write ${filePath}: ${error}`);
|
||||
this.logger.error(
|
||||
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`,
|
||||
);
|
||||
throw new ImmichStartupError(`Failed to validate folder mount (write to "<MEDIA_LOCATION>/${folder}")`);
|
||||
}
|
||||
}
|
||||
|
||||
private getMountFilePaths(folder: StorageFolder) {
|
||||
const folderPath = StorageCore.getBaseFolder(folder);
|
||||
const filePath = join(folderPath, '.immich');
|
||||
|
||||
return { folderPath, filePath };
|
||||
}
|
||||
}
|
||||
|
@ -336,6 +336,7 @@ describe(SystemConfigService.name, () => {
|
||||
'{{y}}/{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MM}}/{{filename}}',
|
||||
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}',
|
||||
'{{y}}/{{MMM}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}/{{filename}}',
|
||||
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
|
||||
|
@ -12,6 +12,9 @@ type Item<T extends EmitEvent> = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export class ImmichStartupError extends Error {}
|
||||
export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError;
|
||||
|
||||
export const setupEventHandlers = (moduleRef: ModuleRef) => {
|
||||
const reflector = moduleRef.get(Reflector, { strict: false });
|
||||
const repository = moduleRef.get<IEventRepository>(IEventRepository);
|
||||
|
@ -115,6 +115,7 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
'-fps_mode passthrough',
|
||||
// explicitly selects the video stream instead of leaving it up to FFmpeg
|
||||
`-map 0:${videoStream.index}`,
|
||||
'-strict unofficial',
|
||||
];
|
||||
|
||||
if (audioStream) {
|
||||
|
@ -9,6 +9,7 @@ import { envName, excludePaths, isDev, resourcePaths, serverVersion } from 'src/
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
import { ApiService } from 'src/services/api.service';
|
||||
import { isStartUpError } from 'src/utils/events';
|
||||
import { otelStart } from 'src/utils/instrumentation';
|
||||
import { useSwagger } from 'src/utils/misc';
|
||||
|
||||
@ -73,6 +74,9 @@ async function bootstrap() {
|
||||
}
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
if (!isStartUpError(error)) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
});
|
||||
|
@ -4,6 +4,7 @@ import { MicroservicesModule } from 'src/app.module';
|
||||
import { envName, serverVersion } from 'src/constants';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
import { isStartUpError } from 'src/utils/events';
|
||||
import { otelStart } from 'src/utils/instrumentation';
|
||||
|
||||
export async function bootstrap() {
|
||||
@ -25,7 +26,9 @@ export async function bootstrap() {
|
||||
|
||||
if (!isMainThread) {
|
||||
bootstrap().catch((error) => {
|
||||
if (!isStartUpError(error)) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
12
web/package-lock.json
generated
12
web/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-web",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@formatjs/icu-messageformat-parser": "^2.7.8",
|
||||
@ -17,7 +17,6 @@
|
||||
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2",
|
||||
"@photo-sphere-viewer/video-plugin": "^5.7.2",
|
||||
"@zoom-image/svelte": "^0.2.6",
|
||||
"copy-image-clipboard": "^2.1.2",
|
||||
"dom-to-image": "^2.6.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"intl-messageformat": "^10.5.14",
|
||||
@ -75,7 +74,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
@ -3284,11 +3283,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/copy-image-clipboard": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/copy-image-clipboard/-/copy-image-clipboard-2.1.2.tgz",
|
||||
"integrity": "sha512-3VCXVl2IpFfOyD8drv9DozcNlwmqBqxOlsgkEGyVAzadjlPk1go8YNZyy8QmTnwHPxSFpeCR9OdsStEdVK7qDA=="
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.37.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "1.113.1",
|
||||
"version": "1.114.0",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0 --port 3000",
|
||||
@ -73,7 +73,6 @@
|
||||
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2",
|
||||
"@photo-sphere-viewer/video-plugin": "^5.7.2",
|
||||
"@zoom-image/svelte": "^0.2.6",
|
||||
"copy-image-clipboard": "^2.1.2",
|
||||
"dom-to-image": "^2.6.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"intl-messageformat": "^10.5.14",
|
||||
|
@ -19,7 +19,7 @@ describe('AlbumCover component', () => {
|
||||
const img = component.getByTestId('album-image') as HTMLImageElement;
|
||||
expect(img.alt).toBe('someName');
|
||||
expect(img.getAttribute('loading')).toBe('lazy');
|
||||
expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square text');
|
||||
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text');
|
||||
expect(img.getAttribute('src')).toBe('/asdf');
|
||||
expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' });
|
||||
});
|
||||
@ -36,7 +36,7 @@ describe('AlbumCover component', () => {
|
||||
const img = component.getByTestId('album-image') as HTMLImageElement;
|
||||
expect(img.alt).toBe('unnamed_album');
|
||||
expect(img.getAttribute('loading')).toBe('eager');
|
||||
expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square asdf');
|
||||
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square asdf');
|
||||
expect(img.getAttribute('src')).toStrictEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
|
@ -44,7 +44,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<AlbumCover {album} {preload} class="h-full w-full transition-all duration-300 hover:shadow-lg" />
|
||||
<AlbumCover {album} {preload} class="transition-all duration-300 hover:shadow-lg" />
|
||||
|
||||
<div class="mt-4">
|
||||
<p
|
||||
|
@ -41,7 +41,7 @@
|
||||
mdiPresentationPlay,
|
||||
mdiUpload,
|
||||
} from '@mdi/js';
|
||||
import { canCopyImagesToClipboard } from 'copy-image-clipboard';
|
||||
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
@ -101,7 +101,7 @@
|
||||
on:click={onZoomImage}
|
||||
/>
|
||||
{/if}
|
||||
{#if canCopyImagesToClipboard() && asset.type === AssetTypeEnum.Image}
|
||||
{#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image}
|
||||
<CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} on:click={onCopyImage} />
|
||||
{/if}
|
||||
|
||||
|
@ -8,17 +8,17 @@
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { isWebCompatibleImage, canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { type SwipeCustomEvent, swipe } from 'svelte-gestures';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let preloadAssets: AssetResponseDto[] | undefined = undefined;
|
||||
@ -81,23 +81,19 @@
|
||||
};
|
||||
|
||||
copyImage = async () => {
|
||||
if (!canCopyImagesToClipboard()) {
|
||||
if (!canCopyImageToClipboard()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await copyImageToClipboard(assetFileUrl);
|
||||
await copyImageToClipboard($photoViewer ?? assetFileUrl);
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('copied_image_to_clipboard'),
|
||||
timeout: 3000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error [photo-viewer]:', error);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Copying image to clipboard failed.',
|
||||
});
|
||||
handleError(error, $t('copy_error'));
|
||||
}
|
||||
};
|
||||
|
||||
@ -152,7 +148,7 @@
|
||||
]}
|
||||
/>
|
||||
{#if imageError}
|
||||
<BrokenAsset square />
|
||||
<BrokenAsset class="text-xl" />
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user