Compare commits

...

290 Commits

Author SHA1 Message Date
Alex
880ff7af5e Merge branch 'main' into fix/shared-link-nav 2026-04-24 10:20:43 -05:00
Yaros
39cfad7136 feat(mobile): action bottom sheet on map timeline (#27515) 2026-04-24 09:30:10 -05:00
renovate[bot]
350056dd1a fix(deps): update dependency uuid to v14 [security] (#28046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 11:24:33 +02:00
Alex
f0835d06f8 chore: migrate to FUTO Apple's account (#28020)
* chore: migrate to FUTO Apple's account

* chore: migrate to FUTO Apple's account

* chore: match widget and share extension

* chore: update app share group

* reuse group.app.immich.share
2026-04-22 11:53:20 -05:00
Alex
03b70cf029 fix: jump to timeline on new auto_router update (#28022) 2026-04-22 10:21:48 -05:00
Daniel Dietzler
4bfb8b36c2 chore!: migrate album owner to album_user (#27467)
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-22 16:52:23 +02:00
renovate[bot]
dfacde5af8 fix(deps): update typescript-projects (#28025)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-04-22 16:49:28 +02:00
Junghwan
317afe9e3b fix(web): normalize underscore locale codes in dynamic language selection (#27900)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-04-22 13:28:33 +00:00
Jason Rasmussen
1fb5f13237 fix: oauth prompt (#28021) 2026-04-22 09:19:28 -04:00
Mees Frensel
57e1b172dd dont use onMount 2026-04-22 12:59:29 +02:00
Luis Nachtigall
793a7054fb fix(mobile): thumbnail transition to asset viewer (#27850) 2026-04-21 15:54:40 -05:00
Luis Nachtigall
3a874dd441 fix(mobile): enable autoplay for motion photos in video viewer (#27961) 2026-04-21 15:53:21 -05:00
Luis Nachtigall
3dc7dc93d8 fix(mobile): clear local data on forced logout (#27957) 2026-04-21 15:52:00 -05:00
Yaros
70397dc5a6 fix(mobile): zero exposure (#28017) 2026-04-21 15:47:27 -05:00
Jason Rasmussen
a16d233a0c chore(web): sort imports (#27922)
* feat: sort imports

* fix: something?
2026-04-21 14:51:38 -04:00
Daniel Dietzler
bb0872afef chore: upgrade sql-tools (#27885) 2026-04-21 17:42:27 +00:00
Freddie Floydd
b9ca68f6e4 chore(web): rename components to PascalCase (#28013)
chore: rename components to PascalCase
2026-04-21 12:29:42 -04:00
Daniel Dietzler
837305da7e chore: un-skip tests (#28012) 2026-04-21 12:08:23 -04:00
Daniel Dietzler
e20fb44142 fix: web navigation/animation regression (#28011) 2026-04-21 14:51:37 +00:00
renovate[bot]
c2786978cd fix(deps): update typescript-projects (#28008)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-04-21 15:29:34 +02:00
renovate[bot]
312bb91a4f chore(deps): update github-actions (#28005)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 13:35:55 +02:00
renovate[bot]
c1934b904c chore(deps): update dependency opentofu to v1.11.6 (#27999)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 13:31:16 +02:00
renovate[bot]
47752d158a fix(deps): update dependency @mapbox/mapbox-gl-rtl-text to v0.4.0 (#28007)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 13:25:30 +02:00
renovate[bot]
6267322b9c chore(deps): update dependency exiftool-vendored to v35.17.0 (#28004)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 13:08:12 +02:00
Mees Frensel
7efafd9db1 chore: use special case for shared link with slug route 2026-04-21 12:54:54 +02:00
renovate[bot]
93c3cd49f3 chore(deps): update node.js to v24.15.0 (#28006)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 09:47:32 +00:00
renovate[bot]
f52825ab08 chore(deps): update prom/prometheus docker digest to 5550dc6 (#27998)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 11:23:24 +02:00
renovate[bot]
d74dc74f92 chore(deps): update dependency terragrunt to v1.0.1 (#28002)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 11:23:07 +02:00
Luis Nachtigall
539a39ae49 refactor(mobile): Migrate durationInSeconds to durationMs (#26615) 2026-04-20 23:28:11 -04:00
Daniel Dietzler
f68cd424a7 chore: tags styling (#27984) 2026-04-20 22:06:43 -05:00
Alex
20c0cc7e73 fix: show neon light (#27994) 2026-04-20 20:12:54 -04:00
Aki Hakune
be1b9a5f67 feat(server): add MPO file type support (#27963)
* feat(server): add MPO file type support

* fix(server): document description error
2026-04-20 17:45:53 -04:00
shenlong
d9011c0829 refactor: test organisation and service test (#27991)
* refactor: test organisation

# Conflicts:
#	mobile/test/unit/utils/editor_test.dart

* regroup hash_service_test

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-20 17:45:20 -04:00
shenlong
f909648bce chore: pump flutter to 3.41.7 (#27990)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-20 14:51:27 +00:00
Min Idzelis
c78b1d8ab4 fix(web): prevent interaction with detail panel behind person side panel (#27309) 2026-04-20 15:26:06 +02:00
Jason Rasmussen
94a34436a3 chore: remove unused packages & code (#27925) 2026-04-20 08:39:46 -04:00
shenlong
0eef15a3ab chore(mobile): minor dependency updates (#27949)
* chore: minor dependency updates

* regenerate pod and remove unused imports

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-19 11:56:39 -05:00
shenlong
6982896549 feat: android periodic work manager task (#23563)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-19 11:55:07 -05:00
Matthew Momjian
2c812a2561 fix(docs): helmet file affected containers (#27939)
fix helmet file
2026-04-18 12:19:39 -04:00
Mert
0b1188e42e chore(server): separate ffmpeg arguments (#27937)
separate arguments
2026-04-18 15:33:44 +00:00
Freddie Floydd
be20cd2bf9 chore(web): bump svelte-check version to silence big warning stack trace (#27935)
chore: bump svelte-check version to silence big warning log
2026-04-18 14:42:47 +00:00
LJspice
b8591cb591 feat(server): add OIDC logout URL override option (#27389)
* feat(server): add OIDC logout URL override option
- Added toggle and field consistent with existing mobile redirect URI override.
- Existing auto-discovery remains default.
- Update tests and docs for new feature.

* fix(server): changes from review for OIDC logout URL override
- Rename 'logoutUri' to 'endSessionEndpoint'
- Remove toggle, just use override if provided
- Moved field in settings UI
2026-04-18 04:18:21 +00:00
Freddie Floydd
384d3a0984 fix(web): fix stale album page load (#27825)
* invalidate album data on album update to fix stale page load

* invalidate album data on album update to fix stale page load

* factor out callback, make async and await invalidate

* chore: formatting

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-04-17 21:24:33 -04:00
Freddie Floydd
03af669856 refactor(web): co-locate single-use components in /routes (#27921)
* co-locate single use components to /routes

* revert accidentally changed paths

* fix mangled path

* fmt

* fix accidentally moved multi-use components
2026-04-17 21:21:36 -04:00
renovate[bot]
b0e4850d76 chore(deps): update dependency flutter to v3.41.6 (#27915)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-18 05:14:44 +05:30
Freddie Floydd
36ebcaf00c fix(web): compute hashes for uploads in chunks (#27878)
* add @noble/hashes as a dep for web

* hash files in chunks

* drop old reference to crypto in test code

* use web worker for file hashing
2026-04-17 19:08:46 -04:00
shenlong
7a86f2b7b9 chore: remove stale mobile/.isar submodule entry (#27913)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-18 04:29:13 +05:30
sparsh985
55f2b3b6a0 feat(server): add configurable OAuth prompt parameter (#26755)
* feat(server): add configurable OAuth prompt parameter

Add a `prompt` field to the OAuth system config, allowing admins to
configure the OIDC `prompt` parameter (e.g. `select_account`, `login`,
`consent`). Defaults to empty string (no prompt sent), preserving
backward compatibility.

This is useful for providers like Google where users want to be prompted
to select an account when multiple accounts are signed in.

Discussed in #20762

* chore: regenerate OpenAPI spec and clients for OAuth prompt field

* Adding e2e test cases

* feat: web setting

* feat: docs

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-04-17 21:20:07 +00:00
shenlong
fd5e8d6521 chore: pump auto_route (#27876)
* chore: pump auto_route

* make build

* chore: use drift from pubdev (#27877)

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-17 20:28:36 +00:00
Freddie Floydd
6798d5df32 fix(server): require at least one field to be set when updating memory (#27842)
* add zod util to require one field is set in some schemas. appy to update memory endpoint

* add test
2026-04-17 20:18:48 +00:00
Min Idzelis
9d33853544 fix(web): respect abort signal after timeline bucket fetches (#27563)
Change-Id: I4bf7c7458883b50bd21484b1029d62526a6a6964
2026-04-17 16:18:14 -04:00
bo0tzz
a46e46452c fix: run profile picture through thumbnail pipeline (#27890)
* fix: run profile picture through thumbnail pipeline

* fix: format
2026-04-17 16:15:59 -04:00
santanoce
dbf30b77bf feat(server): added backchannel logout api endpoint (#26235)
* feat(server): added backchannel logout api endpoint

* test(server): fixed e2e tests

* fix(server): fixed suggested changes by reviewer

* feat(server): created function invalidateOAuth

* fix(server): fixed session.repository.sql

* test(server): added unit tests for backchannelLogout function

* test(server): added e2e tests for oidc backchnnel logout

* docs(server): added documentation on backchannel logout url

* docs(server): fixed typo

* feat(server): minor improvements of the oidc backchannel logout

* test(server): fixed tests after merge with main

* fix(server): fixed e2e test file

* refactor(server): tiny refactor of validateLogoutToken

* chore: cleanup

* fix: tests

* fix: make jwks extractable

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-04-17 18:45:33 +00:00
bo0tzz
8afca348ff fix: sanitize filenames before adding to zip (#27893)
* fix: sanitize filenames before adding to zip

* fix: lints

* chore: drop split()
2026-04-17 13:05:53 -04:00
shenlong
2070f775d6 refactor: remove riverpod generator (#27874)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-17 20:01:44 +05:30
bo0tzz
a456a05052 chore: make filesystem backup docs even more explicit (#27013) 2026-04-17 15:42:40 +02:00
Sergey Katsubo
b7eff33f90 chore(web): refactor date section of asset viewer (#24514)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-04-17 12:56:39 +00:00
Mees Frensel
18c0228f1b fix(web): remove json title from duplicate tool thumbnail (#27886) 2026-04-17 08:37:04 -04:00
Jason Rasmussen
2f8be45fe0 chore!: remove /api/server/theme endpoint (#27880)
chore: remove server/theme endpoint
2026-04-17 08:30:03 -04:00
Daniel Dietzler
41968fdcac feat: cache shared link (#27889) 2026-04-17 08:17:39 -04:00
Yaros
79c392ceba fix(docs): instructions on how to use local immich ui (#27813) 2026-04-17 12:29:14 +02:00
Mees Frensel
8fbeb64c59 fix(web): use event for zooming out after opening face editor (#27789) 2026-04-17 12:14:24 +02:00
Mees Frensel
7d181f0686 fix!: set duration to null when not present (#26982) 2026-04-17 11:57:10 +02:00
shenlong
2172dde7dc chore: remove immich lint (#27873)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-16 15:55:40 -05:00
Luis Nachtigall
fce220b1d7 chore(mobile): update special format column detection (#27867) 2026-04-16 14:01:59 -04:00
shenlong
2a47c35eb7 chore: pump flutter to 3.41.6 (#27834)
* chore: pump flutter to 3.41.6

* more isar cleanup

* ignore experimental use for TableMigration

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-16 22:26:46 +05:30
Jason Rasmussen
6aadb7b5bd feat: dynamic languages (#27869)
Co-authored-by: xantin <github@xantin.be>
2026-04-16 12:37:37 -04:00
renovate[bot]
88bce52042 fix(deps): update dependency jose to v6 (#27862)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-04-16 18:36:07 +02:00
Timothy Dobras
d046f16860 fix(oauth): normalize email claim to lowercase and trim before account lookup and registration (#26841)
* fix(oauth): normalize email claim to lowercase before account lookup and registration

* test(auth): add test for OAuth email case normalization

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-04-16 15:41:42 +00:00
renovate[bot]
88815a0345 chore(deps): update base-image to v202604141125 (major) (#27858)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 15:40:40 +00:00
Jason Rasmussen
57212f29bf chore: bump (#27866) 2026-04-16 11:32:33 -04:00
renovate[bot]
95fa8fbdab chore(deps): update machine-learning (#26970)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 11:32:08 -04:00
renovate[bot]
687b7cad6f chore(deps): update dependency terragrunt to v1 (#27860)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 10:36:15 -04:00
Steven Massaro
ac2ebcee37 chore: improve randomness of /search/random endpoint (#27531) 2026-04-16 14:36:05 +00:00
bo0tzz
3356e81c85 fix!: do not allow insecure oauth requests by default (#27844)
* fix!: do not allow insecure oauth requests by default

* fix: format

* fix: make open-api

* fix: tests

* nit: casing

* chore: migration to allow insecure if current oauth uses http
2026-04-16 10:11:58 -04:00
renovate[bot]
9c642bd6fc chore(deps): update github-actions (#27857)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 15:29:48 +02:00
bo0tzz
9da0cb3cf4 chore: link to PR template in auto-close message (#27756) 2026-04-16 09:14:36 -04:00
renovate[bot]
4ff6cca4da fix(deps): update dependency pillow to >=12.2,<12.3 [security] (#27773)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 09:13:39 -04:00
renovate[bot]
2b7ae4981f chore(deps): update dependency pytest to v9.0.3 [security] (#27777)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 09:12:49 -04:00
renovate[bot]
e63df4121a chore(deps): update dependency @types/node to ^24.12.2 (#27856)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 13:12:38 +00:00
renovate[bot]
03b4ab2935 fix(deps): update dependency simple-icons to v16 (#27855)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 13:07:35 +00:00
bo0tzz
facd3bd331 fix: oauth issuerUrl validation (#27848) 2026-04-16 09:06:55 -04:00
renovate[bot]
20ddf2e7d2 fix(deps): update dependency nestjs-cls to v6 (#27852)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 13:45:42 +02:00
Mees Frensel
f597ebb672 use regex after all 2026-04-16 13:30:04 +02:00
renovate[bot]
7f0025b3fc chore(deps): update dependency @types/nodemailer to v8 (#27851)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 13:28:23 +02:00
Mees Frensel
60f4dedb29 chore(web): small fixes for location picker modal (#27822) 2026-04-16 11:49:24 +02:00
Mees Frensel
b9694d9b52 Merge branch 'main' into fix/shared-link-nav 2026-04-16 11:48:25 +02:00
Min Idzelis
d5d2ebd9bf fix(web): close edit faces panel on Escape key press (#27519)
Move `showEditFaces` state to `assetViewerManager` so the edit faces
panel open/close state is globally accessible. Add Escape key handling
to `PersonSidePanel` that closes the assign-face sub-panel first, then
the edit faces panel. Guard the asset viewer's global Escape-to-close
action so it doesn't fire while either face panel is open.

Change-Id: I0c947fe0758aef0eac473f4cc72f6a3b6a6a6964
2026-04-15 20:22:20 -05:00
Yaros
37abbeba52 fix(mobile): readonly redirect when not logged in (#27728) 2026-04-15 20:20:08 -05:00
Min Idzelis
50557002b7 fix(web): stale adaptive image when original overlays preview (#27621)
AdaptiveImage stacks quality layers (thumbnail → preview → original) as they load. Without compositor layer promotion on the container, the browser could render a stale frame when the original-quality layer was overlaid on top of the preview-quality layer.

Add `will-change: transform` as a class on AdaptiveImage's root element so it gets a dedicated compositor layer from first paint. This also subsumes the imperative `style.willChange = 'transform'` that zoomImageAction was applying to the same element (the zoom target from photo-viewer is the AdaptiveImage root), so drop that now-redundant code.

Change-Id: Icd866a2bb5a5fce299c36404547fa0546a6a6964
2026-04-15 20:19:18 -05:00
Min Idzelis
4aa31d38bf fix(web): svelte regression - cancel video preview fetch when bind:this is cleared early (#27713)
fix(web): cancel video preview fetch when bind:this is cleared early

In Svelte 5.53.9, `bind:this` is now cleared earlier in the unmount
sequence ("better bind:this cleanup timing"). The video thumbnail's
$effect was relying on the old order to read the bound `player` element
and clear its `src` to abort the in-flight `/api/assets/{id}/video/playback`
range request — but the bind is now `undefined` by the time the effect
runs, so the cleanup is silently skipped. The detached <video> keeps
its src, and Firefox does not abort an in-flight media fetch when the
element is detached/GC'd. Long-lived 206 range requests then saturate
Firefox's 6-connection HTTP/1.1 per-host limit and freeze the timeline
(see #27585).

Capture the player reference inside the effect and tear down via the
effect cleanup return — Svelte runs the prior cleanup (with the captured
ref) before `bind:this` is cleared. Use the canonical
`pause() / removeAttribute('src') / load()` sequence which actually aborts
the fetch in Firefox, even on a detached element.

Fixes #27585

Change-Id: I4d9fba859955f5c54f603c345e61d4206a6a6964
2026-04-15 20:18:59 -05:00
Min Idzelis
3d8df74b43 refactor(web): turn thumbhash action into Thumbhash component (#27741)
refactor(web): extract thumbhash canvas into Thumbhash component

Change-Id: If78955bed48b6e690df398e5e2ae61fb6a6a6964
2026-04-15 20:18:49 -05:00
renovate[bot]
2ff9f95527 chore(deps): update dependency python-multipart to v0.0.26 [security] (#27838)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 19:06:27 -04:00
Jason Rasmussen
a69eecf3bc chore!: remove without assets (#27835)
* chore!: remove without assets

* fix: linting and e2e

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-04-15 18:34:22 -04:00
Jason Rasmussen
4ffa26c969 feat: auth logout page (#27831)
* feat: auth logout page

* feat: skip login if already logged in
2026-04-15 16:33:52 -04:00
Jason Rasmussen
ac06514db5 feat: album map markers endpoint (#27830) 2026-04-15 15:58:34 -04:00
Jason Rasmussen
792cb9148b chore!: rename API key schemas (#27828)
chore!: rename API schemas
2026-04-15 15:58:26 -04:00
Daniel Dietzler
8ee5d3039a chore!: remove deviceId and deviceAssetId (#27818)
chore: remove deviceId and deviceAssetId
2026-04-15 15:00:33 -04:00
Jason Rasmussen
d410131312 chore!: remove old timeline sync endpoints (#27804) 2026-04-15 13:58:48 -04:00
bo0tzz
5334a6254a fix: make web build stage deterministic (#27823) 2026-04-15 19:31:23 +02:00
shenlong
79fccdbee0 refactor: yeet old timeline (#27666)
* refactor: yank old timeline

# Conflicts:
#	mobile/lib/presentation/pages/editing/drift_edit.page.dart
#	mobile/lib/providers/websocket.provider.dart
#	mobile/lib/routing/router.dart

* more cleanup

* remove native code

* chore: bump sqlite-data version

* remove old background tasks from BGTaskSchedulerPermittedIdentifiers

* rebase

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-15 23:00:27 +05:30
Brandon Wees
6dd6053222 feat: mobile editing (#25397)
* feat: mobile editing

fix: openapi patch

this sucks :pepehands:

chore: migrate some changes from the filtering PR

chore: color tweak

fix: hide edit button on server versions

chore: translation

* chore: code review changes

chore: code review

* sealed class

* const constant

* enum

* concurrent queries

* chore: major cleanup to use riverpod provider

* fix: aspect ratio selection

* chore: typesafe websocket event parsing

* fix: wrong disable state for save button

* chore: remove isCancelled shim

* chore: cleanup postframe callback usage

* chore: clean import

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-04-15 09:26:40 -05:00
Mees Frensel
8454cb2631 chore: exit open-api script on errors (#27815) 2026-04-15 10:09:51 -04:00
Daniel Dietzler
603fc7401f fix: redirect original (#27759) 2026-04-15 15:43:09 +02:00
Jason Rasmussen
ed70e0febf chore: stop using legacy partner create endpoint (#27806) 2026-04-15 16:16:43 +05:30
Jason Rasmussen
5f5e3344d5 chore!: remove unused token response param (#27805) 2026-04-15 00:58:00 -04:00
Brandon Wees
6da2d3d587 chore!: remove getRandom api endpoint (#27780)
* chore!: remove getRandom api endpoint

* chore: sync openapi

* fix: test

* chore: more cleanup
2026-04-14 21:32:12 -04:00
Jason Rasmussen
41d2d84b21 chore!: remove deprecated env variables (#27802) 2026-04-14 21:30:31 -04:00
Jason Rasmussen
6ba17bb86f refactor!: remove my shared link dto (#27023)
refactor!: remove deprecated shared link apis
2026-04-14 20:58:02 -04:00
Jason Rasmussen
e1a84d3ab6 refactor!: remove replace asset (#27022) 2026-04-14 20:21:05 -04:00
Timon
7d8f843be6 refactor!: migrate class-validator to zod (#26597) 2026-04-14 23:39:03 +02:00
OdinOxin
3753b7a4d1 feat: sort users alphabetically when adding to album (#27731) 2026-04-14 21:21:22 +02:00
Jonathan Jogenfors
84a1fb27ca feat(web): lazy load library and server statistics (#26406)
* feat: add offline library statistics

* fix comments

* feat: add offline library statistics

* fix comments

* fix Daniel's comments

* fix Daniels comment 2
2026-04-14 12:54:09 -04:00
Yaros
81780b0cc0 fix(web): add partner photo to album from multiselect (#27767)
* fix(web): add partner photo to album

* chore: fix formatting

* fix: run-job assets

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-04-14 16:48:39 +00:00
Min Idzelis
5e81a5a054 feat(web): remove delay from Skeleton (#27580)
Change-Id: I95a37f1af832c005a8f009d6f07df8ac6a6a6964
2026-04-14 12:47:37 -04:00
Miguel Raposo
e4e2f586b5 fix(server): render storage template date/time tokens in UTC (#24350) (#26917) 2026-04-14 18:45:14 +02:00
OdinOxin
a001adf14a feat: filter users on share (#27732)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-04-14 16:43:45 +00:00
Daniel Dietzler
136814540a fix: asset multi select download shortcut (#27784) 2026-04-14 12:29:55 -04:00
Jason Rasmussen
fed5cc1ae1 feat: upgrade immich/ui (#27792) 2026-04-14 16:18:12 +00:00
Yaros
641ab51b80 fix(web): selection clearing on preview (#27702)
* fix(web): selection clearing on preview

* chore: remove unnecessary checks
2026-04-14 10:06:32 -05:00
Yaros
3b47ca1c37 fix(mobile): add keys for person tiles in search (#27689)
* fix(mobile): key for person tiles in search

* chore: add key to avatar

* chore: use simple personId

* chore: rename key in person page
2026-04-14 10:05:09 -05:00
Jason Rasmussen
8fb2c7755d feat: commands (#27546) 2026-04-14 09:34:46 -04:00
Mees Frensel
925fabff27 Merge branch 'main' into fix/shared-link-nav 2026-04-14 15:25:56 +02:00
Mees Frensel
33ec6ee0e8 fix(web): fix shared link navigation after password login 2026-04-14 15:17:38 +02:00
Jason Rasmussen
1ba0989e15 refactor: auth manager (#27638) 2026-04-14 08:49:24 -04:00
renovate[bot]
daed3f0966 chore(deps): update dependency @sveltejs/kit to v2.57.1 [security] (#27762)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-14 11:50:25 +02:00
renovate[bot]
46d612ad8c chore(deps): update dependency nodemailer to v8.0.5 [security] (#27623)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-14 11:49:26 +02:00
renovate[bot]
513dead2c2 chore(deps): update dependency @nestjs/core to v11.1.18 [security] (#27544)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-14 09:49:03 +00:00
renovate[bot]
ca006c1569 fix(deps): update typescript-projects (#27573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-04-14 11:41:09 +02:00
renovate[bot]
4e8e8304fd fix(deps): update react-email monorepo (major) (#27572)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-14 11:40:58 +02:00
Nicolas-micuda-becker
d377d2e145 fix(web): center images in RTL layouts (#27678) (#27753) 2026-04-13 13:29:35 -05:00
shenlong
9c9feddf7d refactor: folder page to use new models (#27657)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-13 12:19:44 -05:00
Andreas Heinz
bfcf34d8b5 feat(web): persist state of file path information in details panel (#27770)
feat(enhancement): persist state of file path info in details panel
2026-04-13 12:18:34 -05:00
github-actions
95e57a24cb chore: version v2.7.5 2026-04-13 14:27:31 +00:00
Weblate (bot)
eada662981 chore(web): update translations (#27589)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ar/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/da/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/de_CH/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/eo/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/id/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ko/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/lt/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/lv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/th/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/uk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/yue_Hant/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/
Translation: Immich/immich

Co-authored-by: Aliyss Snow <mangoworksbeta@gmail.com>
Co-authored-by: Bannawat Thongbai <kaji.kanlapat99@gmail.com>
Co-authored-by: Carlo Beltrame <weblate@pendantmusic.ch>
Co-authored-by: Dawnsink <dai@cosmopeace.com>
Co-authored-by: Edmundas <edmius@gmail.com>
Co-authored-by: Happy <59247878+happy2452354@users.noreply.github.com>
Co-authored-by: Jeppe Nellemann <jepnel@proton.me>
Co-authored-by: Linerly <linerly@proton.me>
Co-authored-by: Link Notig <TestMailProtonWhyNot@protonmail.com>
Co-authored-by: Mees Frensel <meesfrensel@gmail.com>
Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Co-authored-by: Osama <laptooxz@proton.me>
Co-authored-by: PPNplus <ppnplus@protonmail.com>
Co-authored-by: Tim Morley <weblate.3919org@timsk.org>
Co-authored-by: UDP <udp@users.noreply.hosted.weblate.org>
Co-authored-by: User 123456789 <user123456789@users.noreply.hosted.weblate.org>
Co-authored-by: Volodymyr Sakharov <savolodya@gmail.com>
Co-authored-by: Vykintas Vyšniauskas <vykintasv@gmail.com>
Co-authored-by: WellsTsai <dan50907@gmail.com>
Co-authored-by: brainheart95 <josephdm4d@gmail.com>
Co-authored-by: dvbthien <dvbthien@users.noreply.hosted.weblate.org>
Co-authored-by: 이찬웅 <lcw7527@gmail.com>
2026-04-13 14:25:01 +00:00
Zack Pollard
352f6ecc28 fix(server): add rate limit and deduplication to version check (#27747) 2026-04-13 12:35:46 +00:00
github-actions
bee49cef02 chore: version v2.7.4 2026-04-10 16:32:26 +00:00
shenlong
6d0c6a4008 chore: pump cronet version (#27685)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-04-10 16:29:05 +00:00
Luis Nachtigall
8a975e5ea9 refactor(mobile): cleanup iOS image loading pipeline (#27672)
* refactor: replace DispatchQueue + DispatchSemaphore with OperationQueue for image processing

* implement RequestRegistry and UnfairLock for managing cancellable requests

* implement requests registry for local and remote image processing

* remove Cancellable protocol and cancel method from request registry

* refactor: introduce ImageRequest base class with unified cancellation and finish helpers

* refactor: add get method to RequestRegistry and streamline request removal in image processing

* add guard to cancel to prevent double onCancel calls

* fix duplicate code merge issue

* refactor(ios): enhance finish method to return callback status

* remove unfitting methods form ImageRequest.swift and fix memory issue

* revert bad merge

* refactor(ios): resolve cancellation issues

* refactor(ios): streamline image request completion handling

* add return statements

* refactor(ios): simplify image request cancellation and registry handling

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-04-10 10:56:35 -05:00
Luis Nachtigall
d39e7da10d fix(mobile): fix flutter cache eviction on thumbnails (#27663)
* fix: add markFinished parameter to loadRequest and loadCodecRequest methods

* update loadRequest and loadCodecRequest methods to use isFinal

* Apply suggestions from code review

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>

* remove redundant check

* fix: ensure isFinished is set correctly during cache eviction

* formatting

---------

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
2026-04-10 10:28:55 -05:00
Daniel Dietzler
bc400d68ac chore: move .tsbuildinfo file to dist folder (#27682) 2026-04-10 16:02:25 +02:00
renovate[bot]
d7f038ec60 chore(deps): update dependency eslint-plugin-unicorn to v64 (#27575)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-04-10 10:23:42 +00:00
Mees Frensel
26957f37ce fix(server): hide original filename when not showing metadata (#27581) 2026-04-10 12:07:18 +02:00
github-actions
3254d31cd2 chore: version v2.7.3 2026-04-09 17:51:40 +00:00
Jason Rasmussen
7b269d1638 fix: ssr open graph tags (#27639)
fix: SSR open graph tags
2026-04-09 12:16:41 -04:00
Luis Nachtigall
b5bed02300 fix(mobile): get provider refs before async gaps in backup page (#27597)
* fix(mobile): get provider refs before async gaps in backup page

* fix(mobile): use previously created provider refs in start backup function
2026-04-08 20:55:53 -05:00
Zack Pollard
5553910236 fix(web): don't cache empty search results for people search (#27632) 2026-04-09 02:33:04 +01:00
Zack Pollard
8d67c1f820 fix(server): people search not showing for 3 or less characters (#27629)
Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
2026-04-09 01:56:07 +01:00
Matthew Momjian
ed0ec30917 fix(docs): updated docker deprecation link (#27633)
new link
2026-04-08 20:33:11 -04:00
Luis Nachtigall
2b0f6c9202 fix(mobile): improve image load cancellation handling (#27624)
fix(image): improve image load cancellation handling
2026-04-08 17:23:42 -04:00
André Erasmus
55ab8c65b6 fix(server): avoid false restore failures on large database imports (#27420)
* fix(server): increase restore health check timeout and reject with Error

* chore: clean up

---------

Co-authored-by: André Erasmus <25480506+NoBadDays@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-04-08 16:03:41 -04:00
Cullen Jennings
781d568f29 fix(docs): typo 'Start rating' to 'Star rating' (#27606) 2026-04-08 18:25:45 +00:00
Zack Pollard
6a361dae72 fix(server): use randomized cron for version check scheduling (#27626)
Also removes unnecessary rate limit
2026-04-08 19:15:38 +01:00
renovate[bot]
64766c8c06 chore(deps): update github-actions (#27560)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 17:32:54 +02:00
github-actions
6a63e814a5 chore: version v2.7.2 2026-04-07 20:58:38 +00:00
Jason Rasmussen
6441c3b77c fix: server build (#27599) 2026-04-07 20:53:04 +00:00
github-actions
b03a649e74 chore: version v2.7.1 2026-04-07 20:22:28 +00:00
Mert
2903b2653b fix(server): library import batch size (#27595)
* lower batch size

* update test
2026-04-07 15:58:03 -04:00
Mert
9ba9a22c40 fix(ml): downgrade numpy (#27591)
downgrade numpy
2026-04-07 15:57:42 -04:00
bo0tzz
f1882c2926 fix: csp quotes (#27592) 2026-04-07 15:54:30 -04:00
Daniel Dietzler
4278789083 chore: git ignore tsBuildInfo (#27594) 2026-04-07 15:53:10 -04:00
renovate[bot]
921c8a8de3 chore(deps): update dependency typescript to v6 (#27577)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-04-07 17:15:55 +02:00
github-actions
afec61addc chore: version v2.7.0 2026-04-07 15:08:18 +00:00
Weblate (bot)
a1a03efbcd chore(web): update translations (#27483)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ar/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/de/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/el/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/gl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ko/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/lt/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nn/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ru/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/th/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/tr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hans/
Translation: Immich/immich

Co-authored-by: Dawnsink <dai@cosmopeace.com>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: Francesco Fiorentino <gallgricela+trotter@gmail.com>
Co-authored-by: Frank Paul Silye <frankps@gmail.com>
Co-authored-by: Gianni De Wachter <gianni.dewachter@gmail.com>
Co-authored-by: HackingAll <hacking.all.YT@gmail.com>
Co-authored-by: Haru Ijima <haruijimakun@gmail.com>
Co-authored-by: Hurricane_32 <rodrigorimo@hotmail.com>
Co-authored-by: Jarek Iwanus <jiwanus@proton.me>
Co-authored-by: Junghyuk Kwon <kwon@junghy.uk>
Co-authored-by: Krastyo Krastev <roshavi4ak@gmail.com>
Co-authored-by: Luis Peregrina <luis.a.peregrina@gmail.com>
Co-authored-by: MarcSerraPeralta <marcserraperalta@gmail.com>
Co-authored-by: Matjaž T. <matjaz@moj-svet.si>
Co-authored-by: Petri Hämäläinen <petri.hamalainen@mailbox.org>
Co-authored-by: Simen Haugen <simen00@gmail.com>
Co-authored-by: Sylvain Pichon <service@spichon.fr>
Co-authored-by: TA <tobi@warsnich.de>
Co-authored-by: TV Box <realceday.tvbox@gmail.com>
Co-authored-by: Veerasak Kritsanapraphan <veerasak.kritsanapraphan@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: chamdim <chamdim@protonmail.com>
Co-authored-by: miksuk28 <mikhail@sukhanik.no>
Co-authored-by: muziqaz <muziqaz@users.noreply.hosted.weblate.org>
Co-authored-by: nanai <ivagamerytmc@gmail.com>
Co-authored-by: naxxerd <top.gear2951@dsme.no>
Co-authored-by: ray ra <verdonsky22@gmail.com>
Co-authored-by: 张建涛 <app521@gmail.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
2026-04-07 15:05:52 +00:00
Dominik Szymański
1d0e5cf18d fix: allow bots to access /s/ urls (#27579)
#27548 Add Allow directive for custom share links social media preview
2026-04-07 09:22:53 -05:00
Min Idzelis
de9ec95db1 fix(web): handle unhandled promise rejection in CancellableTask (#27553)
When a concurrent caller awaits `this.complete` inside `execute()` and
`cancel()` is called, the promise rejects with `undefined` outside of any
try/catch, causing "Uncaught (in promise) undefined" console spam during
rapid timeline scrolling.

- Wrap the `await this.complete` path in try/catch, returning 'CANCELED'
- Guard the `finally` block to only null `cancelToken` if it still belongs
  to this call, preventing a race condition with `cancel()` to `init()`

Change-Id: I65764dd664eb408433fc6e5fc2be4df56a6a6964
2026-04-07 09:22:29 -05:00
renovate[bot]
7f784952eb chore(deps): update dependency rollup-plugin-visualizer to v7 (#27576)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 11:01:23 +00:00
renovate[bot]
3d6c7ba353 chore(deps): update dependency @types/supertest to v7 (#27574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 10:57:45 +00:00
renovate[bot]
3be97db118 fix(deps): update react monorepo to v19 (major) (#27571)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 12:53:33 +02:00
renovate[bot]
8f3a99ffbc chore(deps): update dependency eslint-plugin-compat to v7 (#27570)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 10:52:25 +00:00
renovate[bot]
e6d114af10 chore(deps): update dependency terragrunt to v0.99.5 (#27567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 12:37:29 +02:00
renovate[bot]
4e28811f09 chore(deps): update prom/prometheus docker digest to dda13e2 (#27566)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 11:49:05 +02:00
renovate[bot]
4987032e62 chore(deps): update docker.io/valkey/valkey:9 docker digest to 3b55fba (#27559)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 11:48:06 +02:00
renovate[bot]
572bad8ede chore(deps): update dependency vite to v8.0.5 [security] (#27543)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 18:20:10 +00:00
Afonso Mendonça Ribeiro
95c1f0efeb fix: timestamp handling for database backup in Web UI (#27359)
* Fix #26502: Fix timestamp handling for database backup in Web UI

Frontend parsed backup timestamps as UTC, but they were in the
server's local timezone, causing wrong relative times.

Add `timezone` field to DatabaseBackupDto to expose server timezone.
Update frontend to parse timestamps using this timezone.
Convert timestamps to user's local timezone before rendering.
Fallback to browser timezone if server timezone is missing.

Ensures correct relative time display in Web UI.

* fix: regenerate open-api types and remove custom backup type

- Ran `make open-api` to update types based on backend changes
- Removed custom BackupWithTimezone type in MaintenanceBackupsList
- Updated timezone props to use the newly generated native type

* fix: simplify timezone handling for database backups

- Updated DatabaseBackupDto to make timezone a required property
- Removed manual DateTime.local().zoneName fallbacks
- Cleaned up type casts after regenerating OpenAPI types

* fix: Add missing newline at end of spec file
2026-04-06 17:27:48 +02:00
Thomas
fbe631fe91 fix(mobile): convert video controls from hook to stateful widget (#27514)
We are generally looking to move away from hooks as they are hard to
reason about and have weird bugs. In particular, the timer did not
properly capture the ref of the callback, and so it would execute on old
state. A standard stateful widget does not have this problem, and is
easier to organise.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-04-06 10:13:45 -05:00
Putu Prema
2143a0c935 fix(mobile): transparent system navbar when trash bottom bar is visible (#27093)
* disable bottom safe area on trash bottom bar so that it extends below the system nav bar

* remove manual padding calculations

* re-add static vertical padding to maintain previous bottom bar height
2026-04-06 09:28:07 -05:00
Ray
136bd1e2eb feat(server): Add support for .ts files (#27529) 2026-04-06 15:50:05 +02:00
Min Idzelis
564065a3ed fix(web): reset cursor style when slideshow bar unmounts (#27521) 2026-04-06 14:07:49 +02:00
Min Idzelis
9bcce59719 fix(e2e): fix search gallery delete tests (#27536) 2026-04-06 14:00:50 +02:00
Luis Nachtigall
cd86a83c33 refactor(mobile): introduce image request registry on iOS (#27486)
* refactor: replace DispatchQueue + DispatchSemaphore with OperationQueue for image processing

* implement RequestRegistry and UnfairLock for managing cancellable requests

* implement requests registry for local and remote image processing

* remove Cancellable protocol and cancel method from request registry

* use mutex

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-04-05 18:55:43 -04:00
Min Idzelis
f29c06799f fix(web): isFullScreen initial value check is incorrect (#27520) 2026-04-05 21:19:58 +00:00
Luis Nachtigall
6fcf651d76 refactor(mobile): IOS replace DispatchQueue + DispatchSemaphore with OperationQueue for image processing (#27471)
refactor: replace DispatchQueue + DispatchSemaphore with OperationQueue for image processing
2026-04-05 16:11:02 -04:00
Zack Pollard
196307bca5 chore(server): use dev version check endpoint for non-production environments (#27508) 2026-04-05 10:52:59 +01:00
Thomas
776b9cbad5 fix(mobile): use key on video controls (#27512)
The widget is not recreated correctly when videoPlayName changes, which
can cause weird behaviour with hooks and timers (like timers not firing
when they should do). Using a key forces flutter to recreate the widget
properly and allow the assumptions in build to work correctly.
2026-04-04 21:54:34 -05:00
Thomas
960be0c27a fix(mobile): don't update search filters in-place (#26965)
* fix(mobile): don't update search filters in-place

Search filters are currently modified in-place, which can feel quite
janky. The chips behind the bottom sheet update instantly, and the
search page gets confused because filters have been applied but no
search has been initiated. Filters should keep their own copy of the
filter when they're opened, and the commit + search on apply.

The previous filter and pre-filter concepts were also cleaned up. They
added complexity, and `search()` now owns the full life cycle of the
filter.

This now also reverts the changes from #27155, as this solution should
be cleaner.

* refactor & color tweak

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-04-04 22:53:03 +00:00
Thomas
123119ca0d chore(mobile): reduce buffering timer duration (#27111)
3 seconds is too long for some people, and it can be confusing to see
the video not playing and no indication that it's buffering. Reducing
the duration of the timer should show the spinner faster and prevent
confusion.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-04-04 21:25:40 +00:00
Thomas
1772f720bf fix(mobile): reset video controls hide timer when showing controls ch… (#26985)
fix(mobile): reset video controls hide timer when showing controls changes

The hide timer currently only resets when the status of the video
changes, but does not account for when the controls change. This means
that two things happen:

 1. The hide timer does not reset when the controls become visible
    again, the controls will stay visible forever or until the playback
    status changes.
 2. The hide timer will fire too quickly, and will hide the controls
    much sooner than 5 seconds if the controls are hidden and then shown
    again before the hide timer fires.
2026-04-04 16:12:56 -05:00
Thomas
bcc29903de chore(mobile): persist video controls visibility when swiping (#26986)
At current, the controls for videos are always hidden when opening an
asset from the timeline, and when swiping between assets. The latter is
actually quite annoying, so it would be better UX if video controls were
hidden when opening from the timeline like before, but visibility of the
controls was retained when swiping between assets.
2026-04-04 16:11:59 -05:00
Thomas
767caf9bfe fix(mobile): ignore pointer events on toasts (#26990)
These toasts can sometimes cover UI elements and make them impossible to
interact with until they are dismissed. Specifically, deleting an asset
will show a toast over the video controls and prevent seeking.
2026-04-04 10:39:13 -05:00
Min Idzelis
649d14822a refactor(web): rename MonthGroup to TimelineMonth (#27447)
Rename MonthGroup class to TimelineMonth to better convey that it represents a single month within the timeline. Updates the file, class, and all references across 16 files.

Change-Id: Id50fd6d4b7d0e431571b67c0f81c0e316a6a6964
2026-04-03 13:27:12 -04:00
Jason Rasmussen
207672c481 fix: user-agent format (#27488)
* fix: user-agent format

* ci: fix static analysis

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-04-03 12:26:50 -04:00
John Maguire
4fcd9c2e0d feat: add preview button when selecting images (#27305)
* Add preview button when selecting images

* Fix test helper

* prettier

* styling

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2026-04-03 16:21:43 +00:00
renovate[bot]
a2687d674e chore(deps): update dependency lodash-es to v4.18.1 [security] (#27448)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 18:16:05 +02:00
renovate[bot]
fb1bc7f9e2 chore(deps): update dependency lodash to v4.18.1 [security] (#27461)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 18:15:31 +02:00
Mert
18e8d30b1c fix(server): double exif join (#27485) 2026-04-03 18:14:46 +02:00
Vogeluff
95ef60628c fix(web): always show search type button (#27043)
* fix(web): always show search type button

* fix(web): formatting fixes

* fix(web): search-type-button inactive styling outline/secondary

* chore: styling

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2026-04-03 15:37:03 +00:00
Mees Frensel
a19b7148e5 feat(web): use ui meter component for storage (#27459) 2026-04-03 09:36:22 -05:00
Weblate (bot)
8e414e42f3 chore(web): update translations (#27029)
Co-authored-by: -J- <heyj0e@tuta.io>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: André Nøbbe Christiansen <andre@nobbe.dk>
Co-authored-by: Arif Budiman <arifpedia@gmail.com>
Co-authored-by: Chao En, Kuo <daniel970275@gmail.com>
Co-authored-by: Cornelius Christiansen <christiansen.cornelius@gmail.com>
Co-authored-by: David Kurniawan <kurniawandavid17@gmail.com>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: Deyan Stamboliev <deyan.stamboliev@gmail.com>
Co-authored-by: Fatah Rokbi <fatahrokbi@gmail.com>
Co-authored-by: Felipe Gomes <seutiaoemporio@gmail.com>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Francesco Miccoli <fra03mi@gmail.com>
Co-authored-by: Frank Paul Silye <frankps@gmail.com>
Co-authored-by: Gideon Wentink <gjwentink@gmail.com>
Co-authored-by: HackingAll <hacking.all.YT@gmail.com>
Co-authored-by: Hakan <gucsav@yahoo.com>
Co-authored-by: Hans Cats <hanscats@gmail.com>
Co-authored-by: Happy <59247878+happy2452354@users.noreply.github.com>
Co-authored-by: Hosted Weblate user 85894 <reo7s@users.noreply.hosted.weblate.org>
Co-authored-by: Hurricane_32 <rodrigorimo@hotmail.com>
Co-authored-by: Indrek Haav <indrek.haav@hotmail.com>
Co-authored-by: Indrek Haav <indrekhaav@users.noreply.hosted.weblate.org>
Co-authored-by: Ivan Dimitrov <idimitrov08@gmail.com>
Co-authored-by: Jarek Iwanus <jiwanus@proton.me>
Co-authored-by: Jeppe Nellemann <jepnel@proton.me>
Co-authored-by: JiZPaper <JiZPaper@gmail.com>
Co-authored-by: Joseph <josephlegrand33+hosted.weblate.org@gmail.com>
Co-authored-by: Jozef Gaal <preklady@mayday.sk>
Co-authored-by: Link Notig <TestMailProtonWhyNot@protonmail.com>
Co-authored-by: Marin Čorkalo <mcorkalo@gmail.com>
Co-authored-by: Marwan Jalaleddine <marwanjalaleddine@gmail.com>
Co-authored-by: Matjaž T. <matjaz@moj-svet.si>
Co-authored-by: Matthias Hirsch <ma.hirsch.hh@gmail.com>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Co-authored-by: Niklas Trautwein <jniklast@gmail.com>
Co-authored-by: Olaf Nielsen <solluh@mail.de>
Co-authored-by: Oleksandr Yurov <oyurov@icloud.com>
Co-authored-by: Oscar Guillén <osguima3@gmail.com>
Co-authored-by: PigeonPeak <pigeonpeak@proton.me>
Co-authored-by: PontusÖsterlindh <pontus@osterlindh.com>
Co-authored-by: Psycho <unhomen@gmail.com>
Co-authored-by: Roi Gabay <roigby@gmail.com>
Co-authored-by: Runskrift <anders@rimfrost.nu>
Co-authored-by: Sepehr Behroozi <sep.behroozi@gmail.com>
Co-authored-by: Shimul Roy <stenasaha@gmail.com>
Co-authored-by: Skanda <skillwiz94@gmail.com>
Co-authored-by: Sylvain Pichon <service@spichon.fr>
Co-authored-by: Szymon Kucharski <szymon.kucharski5@gmail.com>
Co-authored-by: TA <tobi@warsnich.de>
Co-authored-by: TV Box <realceday.tvbox@gmail.com>
Co-authored-by: Tage Lauritsen <tage@tunenet.dk>
Co-authored-by: Tim Morley <weblate.3919org@timsk.org>
Co-authored-by: UDP <udp@users.noreply.hosted.weblate.org>
Co-authored-by: Vegard Fladby <vegard@fladby.org>
Co-authored-by: Xo <xocodokie@users.noreply.hosted.weblate.org>
Co-authored-by: Yllelder <yllelder@gmail.com>
Co-authored-by: anton garcias <isaga.percompartir@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: bural <bural@mailbox.org>
Co-authored-by: chamdim <chamdim@protonmail.com>
Co-authored-by: dacx910 <dacx910@users.noreply.hosted.weblate.org>
Co-authored-by: dark fury <nodo05nodo05@gmail.com>
Co-authored-by: dark&white <darkwhite@users.noreply.hosted.weblate.org>
Co-authored-by: dvbthien <dvbthien@users.noreply.hosted.weblate.org>
Co-authored-by: fabiosequeira <fabio.sequeira.t0126448@edu.atec.pt>
Co-authored-by: josu. <josugarralda@gmail.com>
Co-authored-by: kylo32 <kylo32@gmail.com>
Co-authored-by: millallo <millallo@tiscali.it>
Co-authored-by: muziqaz <muziqaz@users.noreply.hosted.weblate.org>
Co-authored-by: pcnc <paul@cioanca.eu>
Co-authored-by: pyccl <changcongliang@163.com>
Co-authored-by: pythoncontroller <zinovlaun@gmail.com>
Co-authored-by: stefano trubian <trubianstefano@gmail.com>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
Co-authored-by: walpeDEV <walpe.aw@proton.me>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
Co-authored-by: 藍焰-0Blue_Yan0 <jim080825@gmail.com>
2026-04-03 00:04:17 +01:00
Zack Pollard
db0f86c749 feat: move version checks to our own infrastructure (#27450) 2026-04-02 23:32:26 +01:00
Jason Rasmussen
adb6b39eec fix: migrations (#27477) 2026-04-02 17:49:26 -04:00
Jason Rasmussen
c8ae99e7d7 fix: escape html (#27469) 2026-04-02 15:19:24 -04:00
Alex
37823bcd51 feat: create new person in face editor (#27364)
* feat: create new person in face editor

* add delay

* fix: test

* i18n

* fix: unit test

* pr feedback
2026-04-02 15:28:40 +00:00
Mees Frensel
b465f2b58f fix: scrollbar ui theme colors (#27455) 2026-04-02 10:18:47 -05:00
Min Idzelis
2166f07b1f refactor(web): rename DayGroup to TimelineDay (#27446)
Rename DayGroup class to TimelineDay to better convey that it represents
a single day within the timeline. Updates the file, class, and all
references across 13 files.

Change-Id: I9faef9bad73cd5b11f40daaf5eb140dd6a6a6964
2026-04-01 19:30:54 -04:00
Min Idzelis
c9e251c78c feat(web): highlight active person thumbnail in detail panel and edit faces panel (#27401)
- Dim non-hovered person thumbnails to 40% opacity when any face is active
- Add ring highlight on the active person's thumbnail
- Add focus-visible outline styling for keyboard navigation
- Apply same treatment to both detail panel people section and edit faces side panel

Change-Id: I4ac10fe4568b95f3e0e8d9104133180f6a6a6964

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-04-01 10:49:09 -05:00
Mees Frensel
da4b88fc14 fix(web): transition bg and border-radius (#27438)
* fix(web): transition bg and border-radius

* also transition thumb
2026-04-01 09:34:49 -05:00
okxint
d1e2e8ab4e fix(server): use substring matching for person name search (#26903) 2026-04-01 13:31:54 +00:00
Timon
2a619d3c10 fix(web): Enable stack selector in shared album view (#24641) 2026-04-01 15:19:14 +02:00
Brandon Wees
c29493e3a0 fix: withFilePath select edited or unedited file (#27328)
* fix: withFilePath select edited or unedited file

* chore: test
2026-04-01 08:19:38 -04:00
renovate[bot]
4ef777d145 chore(deps): update dependency handlebars to v4.7.9 [security] (#27334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 08:17:58 -04:00
renovate[bot]
0b40f4fd76 chore(deps): update dependency happy-dom to v20.8.9 [security] (#27350)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 08:16:45 -04:00
bo0tzz
ecba4e2a62 chore: tee GITHUB_OUTPUT for debugging (#27378) 2026-04-01 08:15:43 -04:00
Michel Heusschen
4eb531197e fix(web): prevent AssetUpdate from adding unrelated timeline assets (#27369) 2026-04-01 08:14:28 -04:00
Alex
505a07a825 feat: add move to lock folder in folder view (#27384) 2026-04-01 08:10:39 -04:00
Robin Meese
548dbe8ad6 feat(docs): add keycloack example to oauth docs (#27425) 2026-04-01 13:39:36 +02:00
renovate[bot]
0c184940f4 chore(deps): update github-actions (#27416)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 15:58:36 +00:00
Channing Bellamy
be180fd9da fix: detection of WebM container (#24182) 2026-03-31 11:44:51 -04:00
renovate[bot]
859f58174e chore(deps): update node.js to v24.14.1 (#27412)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 12:46:38 +02:00
renovate[bot]
a6c7e76008 chore(deps): update grafana/grafana docker tag to v12.4.2 (#27411)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 12:39:33 +02:00
renovate[bot]
0ff94213e6 chore(deps): update dependency exiftool-vendored to v35.15.1 (#27415)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 12:39:11 +02:00
Yaros
6b1dd6f680 fix(mobile): favorite button not updating state (#27271) 2026-03-30 21:24:56 -05:00
Min Idzelis
7d4286bbc5 fix(web): add drop shadow to asset viewer nav bar and prevent button shrinking (#27404)
- Add subtle drop shadow to the asset viewer nav bar for better visual
  separation from the image behind it
- Add drop shadow to the OCR text recognition button in the lower right
- Prevent nav bar action buttons from shrinking to nothing by adding
  *:shrink-0 to the flex container, with p-1/-m-1 to avoid clipping
  focus outlines

Change-Id: I61cdc0ec66a65cde1c95b40c2c5428006a6a6964
2026-03-30 19:22:10 -05:00
Min Idzelis
18201a26d9 feat(web): OCR overlay interactivity during zoom (#27039)
Change-Id: Id62e1a0264df2de0f3177a59b24bc5176a6a6964
2026-03-30 19:19:53 -05:00
Daniel Dietzler
a2e3635ac9 chore: use esm global import (#27408) 2026-03-31 00:22:07 +02:00
Min Idzelis
ce346bf956 feat(web): dim photo outside hovered face bounding box (#27402)
When hovering over a detected face in the photo viewer, an SVG mask overlay
dims the rest of the image (40% black) while leaving the hovered face fully
visible. The overlay fades in/out smoothly via CSS opacity transition by
freezing the last highlighted box positions in state, preventing the overlay
from popping off instantly when the mouse leaves.

Change-Id: I07e2eb2b297820ec89812785fe7943846a6a6964
2026-03-30 16:16:38 -05:00
Mert
a1a2939868 fix(mobile): low upload timeout on android (#27399)
fix timeout
2026-03-30 16:05:21 -05:00
renovate[bot]
e8309585d6 fix(deps): update dependency nodemailer to v8 [security] (#27351)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-30 19:44:39 +02:00
Jason Rasmussen
17d4941089 refactor: asset select manager (#27330) 2026-03-30 15:45:57 +01:00
Tony Fung
b09ebb11e9 perf(server): optimize people page query (#27346)
Optimize People page query: modified SQL to use index for faster performance
2026-03-28 21:33:05 +00:00
Nicolas-micuda-becker
181b028b09 fix(web): keep upload totals stable when dismissing items (#27247) (#27354)
* fix(web): keep upload totals stable when dismissing items (#27247)

* chore: remove package-lock.json

---------

Co-authored-by: bwees <brandonwees@gmail.com>
2026-03-28 16:25:44 -05:00
bo0tzz
eb20b715e4 fix: don't auto-close manually reopened PRs (#27347) 2026-03-28 12:50:09 +00:00
Luis Nachtigall
a277c6311f fix(mobile): streamline error handling for live photo saving (#27337) 2026-03-27 19:07:38 -05:00
Jason Rasmussen
5889c42eb6 refactor: asset select manager (#27329) 2026-03-27 14:23:33 -04:00
Jason Rasmussen
14cce0cba3 refactor: asset select manager (#27327) 2026-03-27 13:48:51 -04:00
Jason Rasmussen
9b80ffd9c6 refactor: selection mananger (#27325) 2026-03-27 12:41:52 -04:00
Luis Nachtigall
306a3b8c7f fix(mobile): images loads sometimes cancel too early (#27067)
* refactor listener tracking for image stream completers and fix early cancel call

* fix: improve cache listener identification in image stream tracking

* add documentation and test cases for listener tracking in ImageStreamCompleter

* fix: remove unnecessary image provision flag from listener tracking

* fix: override setImage method in cache aware listener tracker mixin

* fix: rename test file
2026-03-27 10:35:50 -04:00
Putu Prema
be0fc403d8 fix(mobile): mismatch between system and app color when using low-chroma system color scheme (#27282)
use DynamicSchemeVariant.fidelity to preserve low-chroma system color scheme as the app color
2026-03-27 09:21:43 -05:00
Yaros
c13fd9e4b5 fix(mobile): video icon not showing on memories (#27311) 2026-03-27 09:11:02 -05:00
Thomas
8724848fce chore(mobile): reduce spacing on video controls (#27313)
The spacing was required for the old slider, but the new one has its own
spacing and makes it redundant. There is too much now, and we've
received feedback that it should be less sparse. The default track
height of 16px is an improvement over the old track height, but it is
very thick. A middleground of 12px might be better.
2026-03-27 09:10:19 -05:00
Min Idzelis
2d950db940 refactor(web): replace intersection booleans with enum (#27306)
Change-Id: I0c9703d5960031142ae47fef23805e0a6a6a6964
2026-03-27 08:37:12 -04:00
Min Idzelis
4b9ebc2cff refactor(web): migrate isFaceEditMode from standalone store to assetViewerManager (#27307) 2026-03-27 13:20:15 +01:00
Saurav Sharma
e2d26ebdea fix(web): prevent Safari from overwriting live photo image with video (#26898)
When downloading a live photo, Safari overwrites the image file with
the motion video because both share the same base filename. Append
'-motion' suffix to the video filename to prevent collision.

For example, IMG_1234.heic and IMG_1234.mov become IMG_1234.heic
and IMG_1234-motion.mov.

Fixes #23055
2026-03-26 14:37:05 -04:00
Phlogi
8c6adf7157 feat(server): resolve duplicates (#25316)
* feat(web): Synchronize information from deduplicated images

* Added new settings menu to the the deduplication tab.
* The toggable options in the settings are synchronization of: albums, favorites, ratings, description, visibility and location.
* When synchronizing the albums, the resolved images will be added to all albums of the duplicates.
* When synchronizing the favorite status, the resolved images will be marked as favorite, if at least one selectable image is marked as favorite.
* When synchronizing the ratings, the highest rating from the selectable images will be applied to the resolved image.
* When synchronizing the description, all descriptions from the selectable images will be merged into one description for the resolved image.
* When synchronizing the visibility, the most restrictive visibility setting from the selectable images will be applied to the resolved image.
* When synchronizing the location, if exactly one unique location exists among the selectable images, this location will be applied to the resolved image.
* There is no additional UI for these settings to keep the visual clutter minimal. The settings are applied automatically based on the user's preferences.

* Replace addAssetToAlbums with copyAsset

* fix linter

* feat(web): add duplicate sync fields and fix typo

* feat(web): add tag sync and enhance duplicate resolution

This update introduces tag synchronization for duplicate resolution,
ensuring all unique tag IDs from duplicates are applied to kept assets.
The visibility sync logic is updated to use a simplified ordering, as the hidden status items will never show up in a duplicate set.
Album synchronization now merges albums directly via addAssetsToAlbums; as the approach with copyAsset API endpoint was ineffiecient.
Description, rating, and location sync logic is improved for correctness.
and deduplication. i18n strings were added / updated.

* feat(server): move duplicate resolution to backend with sync and stacking

Moves duplicate metadata synchronization from frontend to backend, enabling robust
batch operations and proper validation. This is an improved refactor of PR #13851.

New endpoints:
- POST /duplicates/resolve - batch resolve with configurable metadata sync
- POST /duplicates/stack - create stacks from duplicate groups
- GET /duplicates - now includes suggestedKeepAssetIds based on file size and EXIF

Key changes:
- Move sync logic (albums, tags, favorites, ratings, descriptions, location, visibility) to server
- Add server-side metadata merge policies with proper conflict resolution
- Replace client-side resolution logic with new backend endpoints
- Add comprehensive E2E tests (70+ test cases) and unit tests
- Update OpenAPI specs and TypeScript SDK

No breaking changes - only additions to existing API.

* feat(preferences): enable all duplicate sync settings by default

* chore: clean up

* chore: clean up

* refactor: rename & clean up

* fix: preference upgrade

* chore: linting

* refactor(e2e): use updateAssets API for setAssetDuplicateId

* fix: visibility sync logic in duplicate resolution

* fix(duplicate): write description to exifUpdate

Previously the duplicate resolution populated assetUpdate.description even
though description belongs to exif info.

* fix(duplicate): remove redundant updateLockedColumns wrapper

updateAllExif already computes lockedProperties via distinctLocked
using Object.keys(options). The wrapper added a lockedProperties key
to the options object, causing the spurious string 'lockedProperties'
to be stored in the lockedProperties array.

* fix(duplicate): write merged tags to asset_exif to survive metadata re-extraction

During duplicate resolution, replaceAssetTags correctly wrote merged tag
IDs to the tag_asset table, but never updated asset_exif.tags or locked
the tags property. The subsequent SidecarWrite → AssetExtractMetadata
chain calls applyTagList, which destructively replaces tag_asset rows
with whatever is in asset_exif.tags — still the original per-asset tags,
not the merged set.

Write merged tag values to asset_exif.tags via updateAllExif (which also
locks the property via distinctLocked), and queue SidecarWrite when tags
change so they persist to the sidecar file.

* docs(duplicates): clarify location and tag sync behavior

* refactor(duplicate): remove sync settings, always sync all metadata on resolve

Remove DuplicateSyncSettingsDto and the per-field sync toggles
(albums, favorites, rating, description, visibility, location, tags).
Duplicate resolution now unconditionally syncs all metadata from
trashed assets to kept assets.

- Remove DuplicateSyncSettingsDto and settings field from DuplicateResolveDto
- Update DuplicateService to always run all sync logic without conditionals
- Delete DuplicateSettingsModal.svelte and settings gear button from UI
- Remove DuplicateSettings type and duplicateSettings persisted store
- Update unit and e2e tests to remove settings from resolve requests

* docs: update duplicates utility to reflect automatic metadata sync

* docs(web): replace duplicates info modal with link to documentation

* chore: clean up

* fix: add missing type cast to jsonAgg in duplicate repository getAll

* fix: skip persisting rating=0 in duplicate merge to avoid unnecessary sidecar write

---------

Co-authored-by: Toni <51962051+EinToni@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2026-03-26 18:33:55 +00:00
Mees Frensel
48fdd39d30 feat(web): use ui pin input element (#27200) 2026-03-26 18:24:46 +00:00
Jonathan Jogenfors
22bf7c2005 feat(server): add checksum algorithm field (#26573)
* feat: add checksum algorithm field

* fix comments

* chore: rename migration

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-03-26 18:20:25 +00:00
Mees Frensel
47b45453c8 chore(web): refactor activity status (#26956)
* chore(web): refactor activity status

* fix: size change

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-03-26 18:15:42 +00:00
Robin Wohlers-Reichel
448c069fb6 feat(web): add shortcuts to rotate images (#26927) 2026-03-26 19:13:01 +01:00
Diogo Tavares Sendim Fernandes
958f270f0d fix(web): keep map view open after closing asset viewer (#26980) 2026-03-26 18:11:05 +00:00
renovate[bot]
9f699fdfc3 chore(deps): update typescript-projects (#26973)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-26 19:02:27 +01:00
renovate[bot]
00da7b88a1 chore(deps): update dependency @types/node to ^24.12.0 (#26966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 13:52:45 -04:00
Michel Heusschen
144a57ddff refactor(server): use helpers for shared link queries (#27088)
* fix(server): prevent album shared link from breaking after uploads

* update test

* add withSharedAssets helper

* remove options

* add more helpers

* update selects
2026-03-26 13:51:00 -04:00
Daniel Dietzler
1bd2d474d7 fix: various comamnd palette usages (#27304) 2026-03-26 17:45:14 +00:00
Jason Rasmussen
b33874ef12 feat: add support for helmet configuration (#27058) 2026-03-26 17:41:23 +00:00
bo0tzz
dbaf4b548b fix: pin success-check-action to correct tag (#27230) 2026-03-26 17:37:23 +00:00
Jason Rasmussen
7d58d5be12 refactor: memory manager (#27206) 2026-03-26 17:36:25 +00:00
renovate[bot]
42fe86d24c chore(deps): update grafana/grafana docker tag to v12.4.1 (#26969)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:31:08 +01:00
Michael Maycock
eeb55c279b fix(web): preserve timezone when changing timestamp (Closes #25354) (#27095) 2026-03-26 17:30:47 +00:00
renovate[bot]
5c159d70a7 chore(deps): update node.js to v24.14.0 (#26972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:30:38 +01:00
Paul Makles
44ae0fa7ed fix(database restores): don't assume onboarding has completed (#27052) 2026-03-26 18:30:14 +01:00
renovate[bot]
f782782662 fix(deps): update dependency kysely to v0.28.14 [security] (#27068)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:29:53 +01:00
renovate[bot]
4436cab827 chore(deps): update dependency yaml to v2.8.3 [security] (#27293)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:28:46 +01:00
renovate[bot]
74789ad1c4 chore(deps): update dependency picomatch to v4.0.4 [security] (#27281)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:28:05 +01:00
Jason Rasmussen
7877097b3f refactor: asset viewing store (#27204) 2026-03-26 13:22:40 -04:00
Jason Rasmussen
fb84c1cf61 chore: remove unused file (#27202) 2026-03-26 13:22:31 -04:00
Jason Rasmussen
940a1d4ab8 refactor: change location (#27201) 2026-03-26 13:22:14 -04:00
Jason Rasmussen
fae25dbe65 chore: whitelist server deploy files (#27056) 2026-03-26 13:22:03 -04:00
Mert
8dd0d7f34c fix(server): memory fragmentation (#27027) 2026-03-26 18:21:52 +01:00
Jason Rasmussen
9b78f2c0ba chore: remove unused resources (#27055) 2026-03-26 13:21:36 -04:00
Timon
67cedfef17 feat(web): add RemoveFromAlbumAction to asset viewer nav bar (#27000) 2026-03-26 18:20:28 +01:00
Andreas Heinz
c9c2322b9d feat(web): focus on face-editor search input (#27136) 2026-03-26 18:18:23 +01:00
Daniel Dietzler
389356149a refactor: actionable toasts (#27203) 2026-03-26 18:18:06 +01:00
Michel Heusschen
4812a2e2d8 fix(server): refresh unedited asset dimensions on metadata extraction (#27220) 2026-03-26 18:17:32 +01:00
Vogeluff
8f01d06927 feat(web): add a seperate tooltip for switching from dark to light mode (#27297) 2026-03-26 18:15:16 +01:00
github-actions
a2ff075e9a chore: version v2.6.3 2026-03-26 16:23:35 +00:00
Brandon Wees
d8b39906f9 fix: incorrect asset face sync (#27243)
* fix: incorrect asset face sync

* chore: sync sql
2026-03-26 09:39:02 -05:00
Michel Heusschen
b36911a16b fix(server): filter out empty search suggestions (#27292)
* fix(server): filter out empty search suggestions

* make sql
2026-03-26 09:36:04 -05:00
Alex
b074ee202e chore: move slideshow control button group to the left (#27287) 2026-03-26 14:31:11 +00:00
bo0tzz
78bb6cf926 chore: log id of existing asset on duplicate upload (#27266) 2026-03-26 11:50:53 +01:00
Yaros
c980f5fc19 chore(docs): withPeople parameter description (#27262)
* fix(server): withPeople inconsistent

* fix: query failing in some occasions

* test: add medium tests for withPeople option

* Revert "test: add medium tests for withPeople option"

This reverts commit 6c1505ba6b.

* Revert "fix: query failing in some occasions"

This reverts commit 221feeca45.

* Revert "fix(server): withPeople inconsistent"

This reverts commit 4289a9f23d.

* chore: change endpoint description

* chore: generate open-api
2026-03-26 11:50:29 +01:00
Yaros
a26d9e05ba fix(web): shifting motion image button (#27275) 2026-03-26 11:49:21 +01:00
bo0tzz
c862163204 fix: explicitly specify repo in auto-close job (#27291) 2026-03-26 10:43:51 +00:00
Michel Heusschen
5fb8f9bf1a fix(web): prevent horizontal scroll bar in asset viewer side panel (#27270)
* fix(web): prevent horizontal scroll bar in asset viewer side panel

* simplify
2026-03-25 21:02:31 -05:00
Mees Frensel
b9b5dba037 fix(web): crop square ratio i18n (#27257) 2026-03-25 14:05:43 -05:00
renovate[bot]
8bfa75087c chore(deps): update base-image to v202603251709 (major) (#27273)
chore(deps): update base-image to v202603251709

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 14:04:26 -05:00
bo0tzz
95280edd6c fix: let renovate update base images (#27272) 2026-03-25 18:00:40 +00:00
Mert
a9666d2cef fix(mobile): remove upload timeout (#27237)
remove timeout
2026-03-24 14:40:48 -04:00
renovate[bot]
4af9edc20b chore(deps): update github-actions (#27215)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 14:31:00 +01:00
renovate[bot]
c975fe5bc7 chore(deps): update github-actions (major) (#27225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 12:40:10 +00:00
renovate[bot]
12a4d8e2ee chore(deps): update ghcr.io/jdx/mise docker tag to v2026.3.12 (#27224)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 12:06:19 +00:00
1780 changed files with 63682 additions and 90476 deletions

6
.gitattributes vendored
View File

@@ -6,6 +6,12 @@ mobile/openapi/**/*.dart linguist-generated=true
mobile/lib/**/*.g.dart -diff -merge
mobile/lib/**/*.g.dart linguist-generated=true
mobile/android/**/*.g.kt -diff -merge
mobile/android/**/*.g.kt linguist-generated=true
mobile/ios/**/*.g.swift -diff -merge
mobile/ios/**/*.g.swift linguist-generated=true
mobile/lib/**/*.drift.dart -diff -merge
mobile/lib/**/*.drift.dart linguist-generated=true

2
.github/.nvmrc vendored
View File

@@ -1 +1 @@
24.13.1
24.15.0

View File

@@ -30,12 +30,17 @@ jobs:
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
echo "uses_template=$OK" | tee --append "$GITHUB_OUTPUT"
close_template:
runs-on: ubuntu-latest
needs: parse_template
if: ${{ needs.parse_template.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
if: >-
${{
needs.parse_template.outputs.uses_template == 'false'
&& github.event.pull_request.state != 'closed'
&& !contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
}}
permissions:
pull-requests: write
steps:
@@ -46,7 +51,7 @@ jobs:
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f body="This PR has been automatically closed as the description doesn't follow [our template](https://github.com/immich-app/immich/blob/main/.github/pull_request_template.md). After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
@@ -66,7 +71,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --add-label "auto-closed:template"
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "auto-closed:template"
close_llm:
runs-on: ubuntu-latest
@@ -113,7 +118,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --remove-label "auto-closed:template" || true
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --remove-label "auto-closed:template" || true
- name: Check for remaining auto-closed labels
id: check_labels
@@ -121,9 +126,9 @@ jobs:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
REMAINING=$(gh pr view "$PR_NUMBER" --json labels \
REMAINING=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels \
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
echo "remaining=$REMAINING" | tee --append "$GITHUB_OUTPUT"
- name: Reopen PR
if: ${{ steps.check_labels.outputs.remaining == '0' }}

View File

@@ -51,14 +51,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -79,7 +79,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -103,7 +103,7 @@ jobs:
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
@@ -114,14 +114,14 @@ jobs:
key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
cache: true
- name: Setup Android SDK
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
with:
packages: ''
@@ -153,14 +153,14 @@ jobs:
fi
- name: Publish Android Artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: github.ref == 'refs/heads/main'
with:
path: |
@@ -185,13 +185,13 @@ jobs:
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -210,7 +210,7 @@ jobs:
working-directory: ./mobile
- name: Setup Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@7372622e62b60b3cb750dcd2b9e32c247ffec26a # v1.302.0
with:
ruby-version: '3.3'
bundler-cache: true
@@ -291,7 +291,7 @@ jobs:
security delete-keychain build.keychain || true
- name: Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ios-release-ipa
path: mobile/ios/Runner.ipa

View File

@@ -19,7 +19,7 @@ jobs:
actions: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31
uses: oasdiff/oasdiff-action/breaking@f8cb9308b42121e793f835bd14c0b8090420430c # v0.0.39
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json

View File

@@ -31,7 +31,7 @@ jobs:
working-directory: ./cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -42,7 +42,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -71,7 +71,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -83,13 +83,13 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
@@ -104,7 +104,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
flavor: |
latest=false
@@ -115,7 +115,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
file: cli/Dockerfile
platforms: linux/amd64,linux/arm64

View File

@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f
image: ghcr.io/immich-app/mdq:main@sha256:557cca601891b8b7d78b940071d35aaf7aaeb9b327d19b22cf282118edbc5272
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

@@ -44,7 +44,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
category: '/language:${{matrix.language}}'

View File

@@ -23,14 +23,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-large"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
permissions:
contents: read
actions: read
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
permissions:
contents: read
actions: read
@@ -178,7 +178,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
with:
needs: ${{ toJSON(needs) }}
@@ -189,6 +189,6 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
with:
needs: ${{ toJSON(needs) }}

View File

@@ -21,14 +21,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -54,7 +54,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -67,7 +67,7 @@ jobs:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -86,7 +86,7 @@ jobs:
run: pnpm build
- name: Upload build output
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docs-build-output
path: docs/build/

View File

@@ -20,7 +20,7 @@ jobs:
artifact: ${{ steps.get-artifact.outputs.result }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,7 +29,7 @@ jobs:
run: echo 'The triggering workflow did not succeed' && exit 1
- name: Get artifact
id: get-artifact
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
@@ -48,7 +48,7 @@ jobs:
return { found: true, id: matchArtifact.id };
- name: Determine deploy parameters
id: parameters
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
with:
@@ -119,7 +119,7 @@ jobs:
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -131,11 +131,11 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
- name: Load parameters
id: parameters
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PARAM_JSON: ${{ needs.checks.outputs.parameters }}
with:
@@ -147,7 +147,7 @@ jobs:
core.setOutput("shouldDeploy", parameters.shouldDeploy);
- name: Download artifact
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }}
with:
@@ -211,7 +211,7 @@ jobs:
run: 'mise run //deployment:tf apply'
- name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
if: ${{ steps.parameters.outputs.event == 'pr' }}
with:
token: ${{ steps.token.outputs.token }}

View File

@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
- name: Destroy Docs Subdomain
env:
@@ -42,7 +42,7 @@ jobs:
run: 'mise run //deployment:tf destroy -- -refresh=false'
- name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
with:
token: ${{ steps.token.outputs.token }}
number: ${{ github.event.number }}

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,7 +29,7 @@ jobs:
persist-credentials: true
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -42,13 +42,13 @@ jobs:
run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix
- name: Commit and push
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
with:
default_author: github_actions
message: 'chore: fix formatting'
- name: Remove label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
if: always()
with:
github-token: ${{ steps.generate-token.outputs.token }}

View File

@@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -14,13 +14,13 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Require PR to have a changelog label
uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1
uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2
with:
token: ${{ steps.token.outputs.token }}
mode: exactly

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -50,7 +50,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -63,10 +63,10 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -86,7 +86,7 @@ jobs:
- name: Commit and tag
id: push-tag
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
with:
default_author: github_actions
message: 'chore: version ${{ steps.output.outputs.version }}'
@@ -124,7 +124,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -136,13 +136,13 @@ jobs:
persist-credentials: false
- name: Download APK
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
draft: true
tag_name: ${{ needs.bump_version.outputs.version }}

View File

@@ -14,12 +14,12 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
@@ -32,12 +32,12 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
@@ -48,14 +48,14 @@ jobs:
name: 'preview'
})
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
if: ${{ github.event.pull_request.head.repo.fork }}
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
message: 'PRs from forks cannot have preview environments.'
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
github-token: ${{ steps.token.outputs.token }}

View File

@@ -19,7 +19,7 @@ jobs:
working-directory: ./open-api/typescript-sdk
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -30,7 +30,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0

View File

@@ -20,14 +20,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -49,7 +49,7 @@ jobs:
working-directory: ./mobile
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -61,7 +61,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml

View File

@@ -17,14 +17,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -63,7 +63,7 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -75,7 +75,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -108,7 +108,7 @@ jobs:
working-directory: ./cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -119,7 +119,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -155,7 +155,7 @@ jobs:
working-directory: ./cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -166,7 +166,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -197,7 +197,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -208,7 +208,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -241,7 +241,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -252,7 +252,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -279,7 +279,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -290,7 +290,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -327,7 +327,7 @@ jobs:
working-directory: ./e2e
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -338,7 +338,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -373,7 +373,7 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -385,7 +385,7 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -412,7 +412,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -424,7 +424,7 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -464,7 +464,7 @@ jobs:
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: e2e-server-docker-logs-${{ matrix.runner }}
@@ -484,7 +484,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -496,7 +496,7 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -522,7 +522,7 @@ jobs:
run: pnpm test:web
if: ${{ !cancelled() }}
- name: Archive e2e test (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
@@ -533,7 +533,7 @@ jobs:
run: pnpm test:web:ui
if: ${{ !cancelled() }}
- name: Archive ui test (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success() || failure()
with:
name: e2e-ui-test-results-${{ matrix.runner }}
@@ -544,7 +544,7 @@ jobs:
run: pnpm test:web:maintenance
if: ${{ !cancelled() }}
- name: Archive maintenance tests (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success() || failure()
with:
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
@@ -554,7 +554,7 @@ jobs:
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: e2e-web-docker-logs-${{ matrix.runner }}
@@ -566,7 +566,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
with:
needs: ${{ toJSON(needs) }}
mobile-unit-tests:
@@ -578,7 +578,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -588,7 +588,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -610,7 +610,7 @@ jobs:
working-directory: ./machine-learning
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -620,7 +620,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
python-version: 3.11
- name: Install dependencies
@@ -650,7 +650,7 @@ jobs:
working-directory: ./.github
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -661,7 +661,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -680,7 +680,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -701,7 +701,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -712,7 +712,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -763,7 +763,7 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -774,7 +774,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:

View File

@@ -24,14 +24,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -47,7 +47,7 @@ jobs:
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -68,6 +68,6 @@ jobs:
permissions: {}
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
with:
needs: ${{ toJSON(needs) }}

2
.gitignore vendored
View File

@@ -28,3 +28,5 @@ vite.config.js.timestamp-*
.pnpm-store
.devcontainer/library
.devcontainer/.env*
*.tsbuildinfo
*.tsbuildInfo

3
.gitmodules vendored
View File

@@ -1,6 +1,3 @@
[submodule "mobile/.isar"]
path = mobile/.isar
url = https://github.com/isar/isar
[submodule "e2e/test-assets"]
path = e2e/test-assets
url = https://github.com/immich-app/test-assets

12
.vscode/settings.json vendored
View File

@@ -13,10 +13,6 @@
"editor.wordBasedSuggestions": "off"
},
"[javascript]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
@@ -29,18 +25,10 @@
"editor.formatOnSave": true
},
"[svelte]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true
},
"[typescript]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},

View File

@@ -1 +1 @@
24.13.1
24.15.0

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.6.2",
"version": "2.7.5",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.11.0",
"@types/node": "^24.12.2",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -28,13 +28,13 @@
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^63.0.0",
"eslint-plugin-unicorn": "^64.0.0",
"globals": "^17.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"typescript": "^6.0.0",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.0",
"vitest": "^4.0.0",
"vitest-fetch-mock": "^0.4.0",
@@ -68,6 +68,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.13.1"
"node": "24.15.0"
}
}

View File

@@ -4,7 +4,7 @@ import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { describe, expect, it, MockedFunction, vi } from 'vitest';
import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk';
import { AssetRejectReason, AssetUploadAction, checkBulkUpload, defaults, getSupportedMediaTypes } from '@immich/sdk';
import createFetchMock from 'vitest-fetch-mock';
import {
@@ -120,7 +120,7 @@ describe('checkForDuplicates', () => {
vi.mocked(checkBulkUpload).mockResolvedValue({
results: [
{
action: Action.Accept,
action: AssetUploadAction.Accept,
id: testFilePath,
},
],
@@ -144,10 +144,10 @@ describe('checkForDuplicates', () => {
vi.mocked(checkBulkUpload).mockResolvedValue({
results: [
{
action: Action.Reject,
action: AssetUploadAction.Reject,
id: testFilePath,
assetId: 'fc5621b1-86f6-44a1-9905-403e607df9f5',
reason: Reason.Duplicate,
reason: AssetRejectReason.Duplicate,
},
],
});
@@ -167,7 +167,7 @@ describe('checkForDuplicates', () => {
vi.mocked(checkBulkUpload).mockResolvedValue({
results: [
{
action: Action.Accept,
action: AssetUploadAction.Accept,
id: testFilePath,
},
],
@@ -187,7 +187,7 @@ describe('checkForDuplicates', () => {
mocked.mockResolvedValue({
results: [
{
action: Action.Accept,
action: AssetUploadAction.Accept,
id: testFilePath,
},
],

View File

@@ -1,9 +1,9 @@
import {
Action,
AssetBulkUploadCheckItem,
AssetBulkUploadCheckResult,
AssetMediaResponseDto,
AssetMediaStatus,
AssetUploadAction,
Permission,
addAssetsToAlbum,
checkBulkUpload,
@@ -234,7 +234,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
const results = response.results as AssetBulkUploadCheckResults;
for (const { id: filepath, assetId, action } of results) {
if (action === Action.Accept) {
if (action === AssetUploadAction.Accept) {
newFiles.push(filepath);
} else {
// rejects are always duplicates
@@ -404,8 +404,6 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
const { baseUrl, headers } = defaults;
const formData = new FormData();
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
formData.append('deviceId', 'CLI');
formData.append('fileCreatedAt', stats.mtime.toISOString());
formData.append('fileModifiedAt', stats.mtime.toISOString());
formData.append('fileSize', String(stats.size));

View File

@@ -15,8 +15,12 @@
"incremental": true,
"skipLibCheck": true,
"esModuleInterop": true,
"baseUrl": "./",
"rootDir": "./src",
"paths": {
"src/*": ["./src/*"],
},
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
"types": ["vitest/globals"]
},
"exclude": ["dist", "node_modules"]
"exclude": ["dist", "node_modules", "vite.config.ts"]
}

View File

@@ -1,6 +1,6 @@
[tools]
terragrunt = "0.99.4"
opentofu = "1.11.5"
terragrunt = "1.0.1"
opentofu = "1.11.6"
[tasks."tg:fmt"]
run = "terragrunt hclfmt"

View File

@@ -20,6 +20,7 @@ services:
- /tmp
volumes:
- ..:/usr/src/app
# - ../../ui:/usr/src/ui
- pnpm_cache:/buildcache/pnpm_cache
- server_node_modules:/usr/src/app/server/node_modules
- web_node_modules:/usr/src/app/web/node_modules
@@ -90,6 +91,7 @@ services:
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
IMMICH_HELMET_FILE: 'true'
ports:
- 9230:9230
- 9231:9231
@@ -155,7 +157,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
image: prom/prometheus@sha256:5550dc63da361dc30f6fe02ac0e4dfc736ededfef3c8d12a634db04a67824d78
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.3.2-ubuntu@sha256:6cca4b429a1dc0d37d401dee54825c12d40056c3c6f3f56e3f0d6318ce77749b
image: grafana/grafana:12.4.2-ubuntu@sha256:78839fe49e1425c02416fa8072591533a72bd9598e563b54a07d78f9e27fb5d3
volumes:
- grafana-data:/var/lib/grafana

View File

@@ -61,7 +61,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
user: '1000:1000'
security_opt:
- no-new-privileges:true

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -1 +1 @@
24.13.1
24.15.0

View File

@@ -210,7 +210,7 @@ The provided restore process ensures your database is never in a broken state by
## Filesystem
Immich stores two types of content in the filesystem: (a) original, unmodified assets (photos and videos), and (b) generated content. We recommend backing up the entire contents of `UPLOAD_LOCATION`, but only the original content is critical, which is stored in the following folders:
Immich does not handle filesystem backups for you. You have to arrange these yourself! Immich stores two types of content in the filesystem: (a) original, unmodified assets (photos and videos), and (b) generated content. We recommend backing up the entire contents of `UPLOAD_LOCATION`, but only the original content is critical, which is stored in the following folders:
1. `UPLOAD_LOCATION/library`
2. `UPLOAD_LOCATION/upload`

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -14,6 +14,7 @@ Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an i
- [Authelia](https://www.authelia.com/integration/openid-connect/immich/)
- [Okta](https://www.okta.com/openid-connect/)
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
- [Keycloak](https://www.keycloak.org)
## Prerequisites
@@ -49,6 +50,10 @@ Before enabling OAuth in Immich, a new client application needs to be configured
- `https://immich.example.com/auth/login`
- `https://immich.example.com/user-settings`
3. Configure Backchannel logout URL
If the authentication server supports it, the **Backchannel logout URL** can be specified, and it is of the form: `http://DOMAIN:PORT/api/oauth/backchannel-logout`.
## Enable OAuth
Once you have a new OAuth client application configured, Immich can be configured using the Administration Settings page, available on the web (Administration -> Settings).
@@ -62,6 +67,8 @@ Once you have a new OAuth client application configured, Immich can be configure
| `scope` | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| `id_token_signed_response_alg` | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| `userinfo_signed_response_alg` | string | none | The algorithm used to sign the userinfo response (examples: RS256, HS256) |
| `prompt` | string | (empty) | Prompt parameter for authorization url (examples: select_account, login, consent) |
| `end_session_endpoint` | URL | (empty) | Http(s) alternative end session endpoint (logout URI) |
| Request timeout | string | 30,000 (30 seconds) | Number of milliseconds to wait for http requests to complete before giving up |
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
@@ -180,6 +187,7 @@ Configuration of OAuth in Immich System Settings
| Scope | openid email profile immich_scope |
| ID Token Signed Response Algorithm | RS256 |
| Userinfo Signed Response Algorithm | RS256 |
| End Session Endpoint | https://auth.example.com/logout?rd=https://immich.example.com/ |
| Storage Label Claim | uid |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
@@ -253,4 +261,40 @@ Configuration of OAuth in Immich System Settings
</details>
<details>
<summary>Keycloak Example</summary>
### Keycloak Example
Here's an example of OAuth configured for Keycloak:
Create your immich client on your Keycloak Realm.
<img src={require('./img/keycloak-general-settings.webp').default} width='100%' title="Keycloak Client general Settings" />
<img src={require('./img/keycloak-access-settings.webp').default} width='100%' title="Keycloak Client Access Settings" />
<img src={require('./img/keycloak-capability-config.webp').default} width='100%' title="Keycloak Client Capability Configuration" />
Configuration of OAuth in Immich System Settings
| Setting | Value |
| ---------------------------- | ----------------------------------------------------- |
| Issuer URL | `https://<KEYCLOAK_DOMAIN>/realms/<YOUR_REALM>` |
| Client ID | immich |
| Client Secret | can be optained from Clients -> immich -> Credentials |
| Scope | openid email profile |
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Role Claim | immich_role |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
| Button Text | Sign in with Keycloak (recommended) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled (optional) |
| Mobile Redirect URI Override | Disabled |
| Mobile Redirect URI | |
Role Claim can be managed via Client Role. Remember to create a mapper with claim name `immich_role`.
</details>
[oidc]: https://openid.net/connect/

View File

@@ -80,9 +80,9 @@ To see local changes to `@immich/ui` in Immich, do the following:
1. Install `@immich/ui` as a sibling to `immich/`, for example `/home/user/immich` and `/home/user/ui`
2. Build the `@immich/ui` project via `pnpm run build`
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yaml` file (`../../ui:/usr/ui`)
4. Uncomment the corresponding alias in the `web/vite.config.js` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui')`)
5. Uncomment the import statement in `web/src/app.css` file `@import '/usr/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';`
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yml` file (`../../ui:/usr/src/ui`)
4. Uncomment the corresponding alias in the `web/vite.config.ts` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui/packages/ui')`)
5. Uncomment the import statement in `web/src/app.css` file `@import '../../../ui/packages/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';`
6. Start up the stack via `make dev`
7. After making changes in `@immich/ui`, rebuild it (`pnpm run build`)

View File

@@ -0,0 +1,28 @@
# Duplicates Utility
Immich comes with a duplicates utility to help you detect assets that look visually similar. The duplicate detection feature relies on machine learning and is enabled by default. For more information about when the duplicate detection job runs, see [Jobs and Workers](/administration/jobs-workers). Once an asset has been processed and added to a duplicate group, it becomes available to review in the "Review duplicates" utility, which can be found [here](https://my.immich.app/utilities/duplicates).
## Reviewing duplicates
The review duplicates page allows the user to individually select which assets should be kept and which ones should be trashed. When more than one asset is kept, there is an option to automatically put the kept assets into a stack.
### Automatic preselection
When using "Deduplicate All" or viewing suggestions, Immich automatically preselects which assets to keep based on:
1. **Image size in bytes** — larger files are preferred as they typically have higher quality.
2. **Count of EXIF data** — assets with more metadata are preferred.
### Synchronizing metadata
When resolving duplicates, metadata from trashed assets is automatically synchronized to the kept assets. The following metadata is synchronized:
| Name | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Album | The kept assets will be added to _every_ album that the other assets in the group belong to. |
| Favorite | If any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. |
| Rating | If one or more assets in the duplicate group have a rating, the highest rating is selected and synchronized to the kept assets. |
| Description | Descriptions from each asset are combined together and synchronized to all the kept assets. |
| Visibility | The most restrictive visibility is applied to the kept assets. |
| Location | Latitude and longitude are copied if all assets with geolocation data in the group share the same coordinates. |
| Tag | Tags from all assets in the group are merged and applied to every kept asset. |

View File

@@ -26,7 +26,7 @@ You can search the following types of content:
| Time frame | Start and end date of a specific time bucket |
| Media type | Image or video or both |
| Display options | In Archive, in Favorites or Not in any album |
| Start rating | User-assigned start rating |
| Star rating | User-assigned star rating |
<img src={require('./img/advanced-search-filters.webp').default} width="70%" title='Advanced search filters' />

View File

@@ -18,6 +18,7 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
| `JPEG 2000` | `.jp2` | :white_check_mark: | |
| `JPEG` | `.jpeg` `.jpg` `.jpe` `.insp` | :white_check_mark: | |
| `JPEG XL` | `.jxl` | :white_check_mark: | |
| `MPO` | `.mpo` | :white_check_mark: | Multi-Picture |
| `PNG` | `.png` | :white_check_mark: | |
| `PSD` | `.psd` | :white_check_mark: | Adobe Photoshop |
| `RAW` | `.raw` | :white_check_mark: | |
@@ -28,17 +29,17 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
## Video formats
| Format | Extension(s) | Supported? | Notes |
| :---------- | :-------------------- | :----------------: | :---- |
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
| `AVI` | `.avi` | :white_check_mark: | |
| `FLV` | `.flv` | :white_check_mark: | |
| `M4V` | `.m4v` | :white_check_mark: | |
| `MATROSKA` | `.mkv` | :white_check_mark: | |
| `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | |
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
| `MXF` | `.mxf` | :white_check_mark: | |
| `QUICKTIME` | `.mov` | :white_check_mark: | |
| `WEBM` | `.webm` | :white_check_mark: | |
| `WMV` | `.wmv` | :white_check_mark: | |
| Format | Extension(s) | Supported? | Notes |
| :---------- | :-------------------------- | :----------------: | :---- |
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
| `AVI` | `.avi` | :white_check_mark: | |
| `FLV` | `.flv` | :white_check_mark: | |
| `M4V` | `.m4v` | :white_check_mark: | |
| `MATROSKA` | `.mkv` | :white_check_mark: | |
| `MP2T` | `.mts` `.m2ts` `.m2t` `.ts` | :white_check_mark: | |
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
| `MXF` | `.mxf` | :white_check_mark: | |
| `QUICKTIME` | `.mov` | :white_check_mark: | |
| `WEBM` | `.webm` | :white_check_mark: | |
| `WMV` | `.wmv` | :white_check_mark: | |

View File

@@ -3,8 +3,8 @@
You may decide that you'd like to modify the style document which is used to
draw the maps in Immich. In addition to visual customization, this also allows
you to pick your own map tile provider instead of the default one. The default
`style.json` for [light theme](https://github.com/immich-app/immich/tree/main/server/resources/style-light.json)
and [dark theme](https://github.com/immich-app/immich/blob/main/server/resources/style-dark.json)
`style.json` for [light theme](https://tiles.immich.cloud/v1/style/light.json)
and [dark theme](https://tiles.immich.cloud/v1/style/dark.json)
can be used as a basis for creating your own style.
There are several sources for already-made `style.json` map themes, as well as

View File

@@ -20,8 +20,6 @@ def upload(file):
}
data = {
'deviceAssetId': f'{file}-{stats.st_mtime}',
'deviceId': 'python',
'fileCreatedAt': datetime.fromtimestamp(stats.st_mtime),
'fileModifiedAt': datetime.fromtimestamp(stats.st_mtime),
'isFavorite': 'false',

View File

@@ -193,6 +193,7 @@ The default configuration looks like this:
"defaultStorageQuota": null,
"enabled": false,
"issuerUrl": "",
"endSessionEndpoint": "",
"mobileOverrideEnabled": false,
"mobileRedirectUri": "",
"profileSigningAlgorithm": "none",

View File

@@ -29,22 +29,23 @@ These environment variables are used by the `docker-compose.yml` file and do **N
## General
| Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
| Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `IMMICH_HELMET_FILE` | Path to a json file with [helmet](https://www.npmjs.com/package/helmet) options. Set to `false` to disable. Set to `true` to use `server/helmet.json`. | `false` | server | api |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.

View File

@@ -49,7 +49,7 @@ Immich requires [**Docker**](https://docs.docker.com/get-started/get-docker/) wi
The Compose plugin will be installed by both Docker Engine and Desktop by following the linked installation guides; it can also be [separately installed](https://docs.docker.com/compose/install/).
:::note
Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/compose/migrate/) and is no longer supported by Immich.
Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/retired/#docker-compose-v1-replaced-by-compose-v2) and is no longer supported by Immich.
:::
### Special requirements for Windows users

View File

@@ -6,6 +6,8 @@ You can read more about the differences between storage template engine on and o
The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text. If the template produces [multiple files with the same filename, they won't be overwritten](https://github.com/immich-app/immich/discussions/3324) as a sequence number is appended to the filename.
Date and time variables in storage templates are rendered in the server's local timezone.
```bash title="Default template"
Year/Year-Month-Day/Filename.Extension
```

View File

@@ -17,10 +17,10 @@
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "~3.9.0",
"@docusaurus/preset-classic": "~3.9.0",
"@docusaurus/theme-common": "~3.9.0",
"@docusaurus/theme-mermaid": "~3.9.0",
"@docusaurus/core": "~3.10.0",
"@docusaurus/preset-classic": "~3.10.0",
"@docusaurus/theme-common": "~3.10.0",
"@docusaurus/theme-mermaid": "~3.10.0",
"@mdi/js": "^7.3.67",
"@mdi/react": "^1.6.1",
"@mdx-js/react": "^3.0.0",
@@ -30,17 +30,17 @@
"postcss": "^8.4.25",
"prism-react-renderer": "^2.3.1",
"raw-loader": "^4.0.2",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^3.2.4",
"url": "^0.11.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "~3.9.0",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.7.0",
"@docusaurus/module-type-aliases": "~3.10.0",
"@docusaurus/tsconfig": "^3.10.0",
"@docusaurus/types": "^3.10.0",
"prettier": "^3.7.4",
"typescript": "^5.1.6"
"typescript": "^6.0.0"
},
"browserslist": {
"production": [
@@ -58,6 +58,6 @@
"node": ">=20"
},
"volta": {
"node": "24.13.1"
"node": "24.15.0"
}
}

View File

@@ -1,7 +1,11 @@
[
{
"label": "v2.6.2",
"url": "https://docs.v2.6.2.archive.immich.app"
"label": "v2.7.5",
"url": "https://docs.v2.7.5.archive.immich.app"
},
{
"label": "v2.6.3",
"url": "https://docs.v2.6.3.archive.immich.app"
},
{
"label": "v2.5.6",

View File

@@ -1,8 +1,4 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": "."
}
"extends": "@docusaurus/tsconfig"
}

View File

@@ -1,5 +1,12 @@
import { exportJWK, generateKeyPair } from 'jose';
import {
calculateJwkThumbprint,
exportJWK,
importPKCS8,
importSPKI,
SignJWT,
} from 'jose';
import Provider from 'oidc-provider';
import { PRIVATE_KEY_PEM, PUBLIC_KEY_PEM } from './test-keys';
export enum OAuthClient {
DEFAULT = 'client-default',
@@ -44,6 +51,29 @@ const claims = [
},
];
const privateKey = await importPKCS8(PRIVATE_KEY_PEM, 'RS256', {
extractable: true,
});
const publicKey = await importSPKI(PUBLIC_KEY_PEM, 'RS256', {
extractable: true,
});
const kid = await calculateJwkThumbprint(await exportJWK(publicKey));
export async function generateLogoutToken(iss: string, sub: string) {
return await new SignJWT({
iss: iss,
aud: OAuthClient.DEFAULT,
iat: Math.floor(Date.now() / 1000),
jti: crypto.randomUUID(),
sub: sub,
events: {
'http://schemas.openid.net/event/backchannel-logout': {},
},
})
.setProtectedHeader({ alg: 'RS256', typ: 'logout+jwt', kid: kid })
.sign(privateKey);
}
const withDefaultClaims = (sub: string) => ({
sub,
email: `${sub}@immich.app`,
@@ -66,8 +96,6 @@ const getClaims = (sub: string, use?: string) => {
};
const setup = async () => {
const { privateKey, publicKey } = await generateKeyPair('RS256');
const redirectUris = [
'http://127.0.0.1:2285/auth/login',
'https://photos.immich.app/oauth/mobile-redirect',

View File

@@ -7,7 +7,7 @@
"start": "tsx startup.ts"
},
"devDependencies": {
"jose": "^5.6.3",
"jose": "^6.0.0",
"@types/oidc-provider": "^9.0.0",
"oidc-provider": "^9.0.0",
"tsx": "^4.20.6"

View File

@@ -0,0 +1,38 @@
export const PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCVj5C7hzN3E2HO
TcJ+DN/e2NSTQFj4rPylz4J8xjm8Es7l0k2kK5EEGvUNVGZbw7s055c+6kwP9eqg
B5XFE7+26Fcq1sou6Tbm310kU4dnMW5l2CgwrhaGyb1pNysao0AMLT60dFYqtUwn
ha9ceCsa+ZU1JrknVf3rONtppBvhWoI7CO9XX1keVQ0unHPzCWUjpXTzC8OGEbmB
2w7ZIUf8OfJkd5RZ4OtIpML71W9n13aDxT50x2/EW/pFLFtQ/oaleOKHpvlRXDRX
W86G4moUJym3gHMXMUj2aOcFG2UJnpLruKz3i5qZwYiTRlBP6O9EIQNCVtYxchuN
V1CCcBU1AgMBAAECggEAJLfXMu8Nx89ynPVyyUMMaFfoEpHC9iR0L5obQVpiPMYK
VRqVVLecdftPS9s7eQ58BNBRzdC0ZVu841aRYs3HLNbsZZhPkYZQpAxU//Dg5okY
fzj7Hv5yidt4HN9+Pd8z/3lRMnj4WapifLaBt8xJ2ujJBMBRxzJBsXDnT0+Kx7+y
bYDeuVfyUTEikaK3QZTbuRF3D3eiuN16GG+hv8UqTF2eYbPxdiLjYpTSHa4mH88C
qfJz2Xt4SEzmyeo3G+MO17wDFOwtEe8ojlJfULHnHJSFdUwTfYIFM1bg5/fJ9MOS
/fO3TSG+wkQqjQa6eoGssAzP87fL2XNLzlDtGY/7uQKBgQDHuJHOtf1EjOvNYiP7
EN+8QGs41ghzt9CQRQxWbHpusR3IW3P83KMXwYmrlG70oOUXBRGSB/ESXUofXc5W
pu5+Y55S44aUnu/a9yOBttYW0dtHZSL0zFT+PlVASwUzFZ2zcH1KXlUkSpfL5OAD
PyDDTnBZ2AWh45fRO9wLo6PPuQKBgQC/tI03RqU3mOjqukKbquYeIpXHfRU5Z0DM
u9ru1THYEl6fmkMXycxo/mvW3awyFuyKy/VodqIgKnFgumEqCHZh6OAMm/LC7TfA
l9tjFSs/MyOqQVD4kbX+z6Oq4c4GccDoXfsQ3gzECoBapegi/F+6/25y+/C8ghXb
J/Jg1GQXXQKBgQDFgWbfzuVZZyrBfu4qGLPJDMN7/114YizknwPma3xf/tN/EcGQ
K/k1QvWMMkvPq1UiAKcxjJ0AFjV482FcG9T6NDWbrtmmG88C8Sex3Ue2ZW2+GuwI
vhDHJIlV/Vp0/Elp7DJa2xLDwuh+gCZvz3vs6KL+ljxrrhCyn8mp0PfsMQKBgFFZ
KnuETOO0zVGdzFoGQTQUdP58A5+iQwsdxB+I9Ge+E80iRso3ZbhADj7VPhbbR3D2
b6LuhImluQrUzBpsEOAnU7vGCVPSGdBuIDiBaSKebsn2gYeZPWNtdQQ0YZq2dqek
Cb/0mfIuipzsvf7qnSza62F7q4IyqVegMegI+Jg5AoGATM3NMy7JZeKzSkm+3ohU
3xZOwgqKV9SH+0OeYWpuBxT7D7FlrKKI4NJ3XN3hg2f/DJAF6dH11CPe7pk94yol
HMbh+PQUQ6GYvAzxIOvagWboQ3lzeyubNMpyFjfOrIE/WOQCUBZ9tIwCHIarIuyi
QRuNOj3+U8T/n1Ww352HBdw=
-----END PRIVATE KEY-----`;
export const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlY+Qu4czdxNhzk3Cfgzf
3tjUk0BY+Kz8pc+CfMY5vBLO5dJNpCuRBBr1DVRmW8O7NOeXPupMD/XqoAeVxRO/
tuhXKtbKLuk25t9dJFOHZzFuZdgoMK4Whsm9aTcrGqNADC0+tHRWKrVMJ4WvXHgr
GvmVNSa5J1X96zjbaaQb4VqCOwjvV19ZHlUNLpxz8wllI6V08wvDhhG5gdsO2SFH
/DnyZHeUWeDrSKTC+9VvZ9d2g8U+dMdvxFv6RSxbUP6GpXjih6b5UVw0V1vOhuJq
FCcpt4BzFzFI9mjnBRtlCZ6S67is94uamcGIk0ZQT+jvRCEDQlbWMXIbjVdQgnAV
NQIDAQAB
-----END PUBLIC KEY-----`;

View File

@@ -1 +1 @@
24.13.1
24.15.0

View File

@@ -44,7 +44,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.6.2",
"version": "2.7.5",
"description": "",
"main": "index.js",
"type": "module",
@@ -32,15 +32,15 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.11.0",
"@types/node": "^24.12.2",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
"@types/supertest": "^7.0.0",
"dotenv": "^17.2.3",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^63.0.0",
"eslint-plugin-unicorn": "^64.0.0",
"exiftool-vendored": "^35.0.0",
"globals": "^17.0.0",
"luxon": "^3.4.4",
@@ -51,13 +51,13 @@
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^5.3.3",
"typescript": "^6.0.0",
"typescript-eslint": "^8.28.0",
"utimes": "^5.2.1",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.0"
},
"volta": {
"node": "24.13.1"
"node": "24.15.0"
}
}

View File

@@ -0,0 +1,651 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/duplicates', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
[user1, user2] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
]);
});
beforeEach(async () => {
// Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs
// Note: We don't reset users since they're set up once in beforeAll
// Stack must be reset before asset due to foreign key constraint
await utils.resetDatabase(['stack', 'asset', 'album', 'tag']);
});
describe('GET /duplicates', () => {
it('should return empty array when no duplicates', async () => {
const { status, body } = await request(app)
.get('/duplicates')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([]);
});
it('should return duplicate groups with suggestedKeepAssetIds', async () => {
// Create assets with different file sizes for duplicate detection
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Manually set duplicateId on both assets to create a duplicate group
const duplicateId = '00000000-0000-4000-8000-000000000001';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.get('/duplicates')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([
{
duplicateId,
assets: expect.arrayContaining([
expect.objectContaining({ id: asset1.id }),
expect.objectContaining({ id: asset2.id }),
]),
suggestedKeepAssetIds: expect.any(Array),
},
]);
expect(body[0].suggestedKeepAssetIds.length).toBe(1);
});
});
describe('POST /duplicates/resolve', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post('/duplicates/resolve')
.send({
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return failure for non-existent duplicate group', async () => {
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId: uuidDto.dummy,
status: 'FAILED',
reason: expect.stringContaining('not found or access denied'),
},
],
});
});
it('should resolve duplicate group with keepers', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000002';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId,
status: 'SUCCESS',
},
],
});
// Verify side effects: duplicateId cleared on kept asset
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
// Verify side effects: trashed asset is trashed and duplicateId cleared
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(trashedAsset.isTrashed).toBe(true);
expect(trashedAsset.duplicateId).toBeNull();
});
it('should reject when keepAssetIds and trashAssetIds overlap', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000003';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('disjoint');
});
it('should require keepAssetIds when partially trashing', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000004';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('must cover all assets');
});
it('should reject partial resolution (not all assets covered)', async () => {
const [asset1, asset2, asset3] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000010';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('must cover all assets');
});
it('should reject asset not in duplicate group', async () => {
const [asset1, asset2, outsideAsset] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000011';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('not a member of duplicate group');
});
it('should allow trash-all without keepers', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000012';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId,
status: 'SUCCESS',
},
],
});
// Verify both assets are trashed
const [asset1Info, asset2Info] = await Promise.all([
utils.getAssetInfo(user1.accessToken, asset1.id),
utils.getAssetInfo(user1.accessToken, asset2.id),
]);
expect(asset1Info.isTrashed).toBe(true);
expect(asset1Info.duplicateId).toBeNull();
expect(asset2Info.isTrashed).toBe(true);
expect(asset2Info.duplicateId).toBeNull();
});
it('should reject cross-user duplicate group access', async () => {
const asset1 = await utils.createAsset(user1.accessToken);
const asset2 = await utils.createAsset(user2.accessToken);
const duplicateId = '00000000-0000-4000-8000-000000000013';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
// User1 tries to resolve a group containing user2's asset
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('not a member of duplicate group');
});
it('should synchronize favorites when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Mark one asset as favorite
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], isFavorite: true });
const duplicateId = '00000000-0000-4000-8000-000000000020';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify favorite was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.isFavorite).toBe(true);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize visibility when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Archive one asset
await utils.archiveAssets(user1.accessToken, [asset2.id]);
const duplicateId = '00000000-0000-4000-8000-000000000021';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify visibility was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.visibility).toBe('archive');
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize rating when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set rating on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], rating: 5 });
const duplicateId = '00000000-0000-4000-8000-000000000022';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify rating was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.rating).toBe(5);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize description when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set description on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], description: 'Test description for duplicate' });
const duplicateId = '00000000-0000-4000-8000-000000000023';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify description was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate');
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize location when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set location on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 });
const duplicateId = '00000000-0000-4000-8000-000000000024';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify location was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.latitude).toBe(40.7128);
expect(keptAsset.exifInfo?.longitude).toBe(-74.006);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize albums when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Create albums and add assets to different albums
const album1 = await utils.createAlbum(user1.accessToken, {
albumName: 'Album 1',
assetIds: [asset1.id],
});
const album2 = await utils.createAlbum(user1.accessToken, {
albumName: 'Album 2',
assetIds: [asset2.id],
});
const duplicateId = '00000000-0000-4000-8000-000000000025';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify keeper is now in both albums
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
// Check albums directly
const { status: album1Status, body: album1Body } = await request(app)
.get(`/albums/${album1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
const { status: album2Status, body: album2Body } = await request(app)
.get(`/albums/${album2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(album1Status).toBe(200);
expect(album2Status).toBe(200);
expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id);
expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id);
});
it('should synchronize tags when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Wait for metadata extraction to complete before adding tags
// Otherwise, metadata jobs will race and overwrite our tags
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
// Create tags and tag assets differently
const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']);
await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]);
await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]);
const duplicateId = '00000000-0000-4000-8000-000000000026';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify keeper has both tags
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
expect(keptAsset.tags).toBeDefined();
const tagIds = keptAsset.tags?.map((t) => t.id) || [];
expect(tagIds).toContain(tags[0].id);
expect(tagIds).toContain(tags[1].id);
});
it('should handle batch resolve with mixed success and failure', async () => {
// Create first group that will succeed
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId1 = '00000000-0000-4000-8000-000000000027';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1);
// Create second group with non-existent duplicate ID (will fail)
const fakeId = '00000000-0000-4000-8000-000000000099';
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [
{ duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] },
{ duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] },
],
});
expect(status).toBe(200);
expect(body.status).toBe('COMPLETED');
expect(body.results).toHaveLength(2);
// First group should succeed
expect(body.results[0].duplicateId).toBe(duplicateId1);
expect(body.results[0].status).toBe('SUCCESS');
// Second group should fail
expect(body.results[1].duplicateId).toBe(fakeId);
expect(body.results[1].status).toBe('FAILED');
expect(body.results[1].reason).toContain('not found or access denied');
// Verify first group was actually resolved despite second failure
const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(asset1Info.duplicateId).toBeNull();
const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(asset2Info.isTrashed).toBe(true);
});
it('should trash assets when trash is enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000028';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
// Ensure trash is enabled (default)
const config = await utils.getSystemConfig(admin.accessToken);
expect(config.trash.enabled).toBe(true);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify asset is trashed (not deleted)
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(trashedAsset.isTrashed).toBe(true);
});
it('should delete assets when trash is disabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000029';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
// Disable trash
await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
trash: { enabled: false, days: 30 },
});
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Asset should be marked as deleted (force delete)
const { status: getStatus } = await request(app)
.get(`/assets/${asset2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
// Asset should still be accessible (soft deleted) but marked as deleted
expect(getStatus).toBe(200);
// Re-enable trash for other tests
await utils.resetAdminConfig(admin.accessToken);
});
});
});

View File

@@ -2,6 +2,8 @@ export const uuidDto = {
invalid: 'invalid-uuid',
// valid uuid v4
notFound: '00000000-0000-4000-a000-000000000000',
dummy: '00000000-0000-4000-a000-000000000001',
dummy2: '00000000-0000-4000-a000-000000000002',
};
const adminLoginDto = {

View File

@@ -10,7 +10,9 @@ describe('/admin/database-backups', () => {
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
admin = await utils.adminSetup({
onboarding: false,
});
await utils.resetBackups(admin.accessToken);
});
@@ -94,7 +96,9 @@ describe('/admin/database-backups', () => {
({ status, body }) => status === 200 && !body.maintenanceMode,
);
admin = await utils.adminSetup();
admin = await utils.adminSetup({
onboarding: false,
});
});
it.sequential('should not work when the server is configured', async () => {

View File

@@ -130,12 +130,11 @@ describe('/albums', () => {
describe('GET /albums', () => {
it("should not show other users' favorites", async () => {
const { status, body } = await request(app)
.get(`/albums/${user1Albums[0].id}?withoutAssets=false`)
.get(`/albums/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toEqual(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining({ isFavorite: false })],
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
lastModifiedAssetTimestamp: expect.any(String),
startDate: expect.any(String),
@@ -155,23 +154,31 @@ describe('/albums', () => {
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedLink,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedEditorUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedViewerUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user2.userId,
albumName: user2SharedUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
]),
shared: true,
}),
]),
@@ -185,23 +192,31 @@ describe('/albums', () => {
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedEditorUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedViewerUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedLink,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1NotShared,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: false,
}),
]),
@@ -217,23 +232,31 @@ describe('/albums', () => {
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedEditorUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedViewerUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedLink,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user2.userId,
albumName: user2SharedUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
]),
shared: true,
}),
]),
@@ -249,8 +272,10 @@ describe('/albums', () => {
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user1.userId,
albumName: user1NotShared,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: false,
}),
]),
@@ -287,13 +312,17 @@ describe('/albums', () => {
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user4.userId,
albumName: user4DeletedAsset,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user4.userId }) },
]),
shared: false,
}),
expect.objectContaining({
ownerId: user4.userId,
albumName: user4Empty,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user4.userId }) },
]),
shared: false,
}),
]),
@@ -304,13 +333,12 @@ describe('/albums', () => {
describe('GET /albums/:id', () => {
it('should return album info for own album', async () => {
const { status, body } = await request(app)
.get(`/albums/${user1Albums[0].id}?withoutAssets=false`)
.get(`/albums/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
lastModifiedAssetTimestamp: expect.any(String),
startDate: expect.any(String),
@@ -322,7 +350,7 @@ describe('/albums', () => {
it('should return album info for shared album (editor)', async () => {
const { status, body } = await request(app)
.get(`/albums/${user2Albums[0].id}?withoutAssets=false`)
.get(`/albums/${user2Albums[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
@@ -331,14 +359,14 @@ describe('/albums', () => {
it('should return album info for shared album (viewer)', async () => {
const { status, body } = await request(app)
.get(`/albums/${user1Albums[3].id}?withoutAssets=false`)
.get(`/albums/${user1Albums[3].id}`)
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: user1Albums[3].id });
});
it('should return album info with assets when withoutAssets is undefined', async () => {
it('should return album info', async () => {
const { status, body } = await request(app)
.get(`/albums/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
@@ -346,25 +374,6 @@ describe('/albums', () => {
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
lastModifiedAssetTimestamp: expect.any(String),
startDate: expect.any(String),
endDate: expect.any(String),
albumUsers: expect.any(Array),
shared: true,
});
});
it('should return album info without assets when withoutAssets is true', async () => {
const { status, body } = await request(app)
.get(`/albums/${user1Albums[0].id}?withoutAssets=true`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [],
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
assetCount: 1,
lastModifiedAssetTimestamp: expect.any(String),
@@ -379,21 +388,21 @@ describe('/albums', () => {
await utils.deleteAssets(user1.accessToken, [user1Asset2.id]);
const { status, body } = await request(app)
.get(`/albums/${user2Albums[0].id}?withoutAssets=true`)
.get(`/albums/${user2Albums[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user2Albums[0],
assets: [],
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
assetCount: 1,
lastModifiedAssetTimestamp: expect.any(String),
endDate: expect.any(String),
startDate: expect.any(String),
albumUsers: expect.any(Array),
shared: true,
});
expect(body).toEqual(
expect.objectContaining({
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
assetCount: 1,
lastModifiedAssetTimestamp: expect.any(String),
endDate: expect.any(String),
startDate: expect.any(String),
albumUsers: expect.any(Array),
shared: true,
}),
);
});
});
@@ -419,16 +428,13 @@ describe('/albums', () => {
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
ownerId: user1.userId,
albumName: 'New album',
description: '',
albumThumbnailAssetId: null,
shared: false,
albumUsers: [],
albumUsers: [{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) }],
hasSharedLink: false,
assets: [],
assetCount: 0,
owner: expect.objectContaining({ email: user1.userEmail }),
isActivityEnabled: true,
order: AssetOrder.Desc,
});
@@ -644,11 +650,11 @@ describe('/albums', () => {
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
albumUsers: [
albumUsers: expect.arrayContaining([
expect.objectContaining({
user: expect.objectContaining({ id: user2.userId }),
}),
],
]),
}),
);
});
@@ -660,7 +666,7 @@ describe('/albums', () => {
.send({ albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner'));
expect(body).toEqual(errorDto.badRequest('User already added'));
});
it('should not be able to add existing user to shared album', async () => {
@@ -686,7 +692,7 @@ describe('/albums', () => {
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
});
expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer);
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Viewer);
const { status } = await request(app)
.put(`/albums/${album.id}/user/${user2.userId}`)
@@ -701,7 +707,10 @@ describe('/albums', () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(body).toEqual(
expect.objectContaining({
albumUsers: [expect.objectContaining({ role: AlbumUserRole.Editor })],
albumUsers: [
expect.objectContaining({ role: AlbumUserRole.Owner }),
expect.objectContaining({ role: AlbumUserRole.Editor }),
],
}),
);
});
@@ -712,7 +721,7 @@ describe('/albums', () => {
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
});
expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer);
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Viewer);
const { status, body } = await request(app)
.put(`/albums/${album.id}/user/${user2.userId}`)

View File

@@ -1,7 +1,6 @@
import {
AssetMediaResponseDto,
AssetMediaStatus,
AssetResponseDto,
AssetTypeEnum,
AssetVisibility,
getAssetInfo,
@@ -19,7 +18,7 @@ import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, tempDir, TEN_TIMES, testAssetDir, utils } from 'src/utils';
import { app, asBearerAuth, tempDir, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@@ -95,8 +94,8 @@ describe('/asset', () => {
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken, {
isFavorite: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
fileCreatedAt: yesterday.toUTC().toISO(),
fileModifiedAt: yesterday.toUTC().toISO(),
assetData: { filename: 'example.mp4' },
}),
utils.createAsset(user1.accessToken),
@@ -380,62 +379,12 @@ describe('/asset', () => {
});
});
describe('GET /assets/random', () => {
beforeAll(async () => {
await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration');
});
it.each(TEN_TIMES)('should return 1 random assets', async () => {
const { status, body } = await request(app)
.get('/assets/random')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(1);
expect(assets[0].ownerId).toBe(user1.userId);
});
it.each(TEN_TIMES)('should return 2 random assets', async () => {
const { status, body } = await request(app)
.get('/assets/random?count=2')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(2);
for (const asset of assets) {
expect(asset.ownerId).toBe(user1.userId);
}
});
it.skip('should return 1 asset if there are 10 assets in the database but user 2 only has 1', async () => {
const { status, body } = await request(app)
.get('/assets/random')
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]);
});
});
describe('PUT /assets/:id', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/assets/${user2Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
@@ -1142,8 +1091,6 @@ describe('/asset', () => {
const { body, status } = await request(app)
.post('/assets')
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
.field('deviceAssetId', 'example-image')
.field('deviceId', 'e2e')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.attach('assetData', makeRandomImage(), 'example.jpg');
@@ -1160,8 +1107,6 @@ describe('/asset', () => {
const { body, status } = await request(app)
.post('/assets')
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
.field('deviceAssetId', 'example-image')
.field('deviceId', 'e2e')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.attach('assetData', randomBytes(2014), 'example.jpg');
@@ -1215,29 +1160,4 @@ describe('/asset', () => {
expect(video.checksum).toStrictEqual(checksum);
});
});
describe('POST /assets/exist', () => {
it('ignores invalid deviceAssetIds', async () => {
const response = await utils.checkExistingAssets(user1.accessToken, {
deviceId: 'test-assets-exist',
deviceAssetIds: ['invalid', 'INVALID'],
});
expect(response.existingIds).toHaveLength(0);
});
it('returns the IDs of existing assets', async () => {
await utils.createAsset(user1.accessToken, {
deviceId: 'test-assets-exist',
deviceAssetId: 'test-asset-0',
});
const response = await utils.checkExistingAssets(user1.accessToken, {
deviceId: 'test-assets-exist',
deviceAssetIds: ['test-asset-0'],
});
expect(response.existingIds).toEqual(['test-asset-0']);
});
});
});

View File

@@ -110,7 +110,7 @@ describe('/libraries', () => {
});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
});
it('should not create an external library with duplicate exclusion patterns', async () => {
@@ -125,7 +125,7 @@ describe('/libraries', () => {
});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
});
});
@@ -157,7 +157,7 @@ describe('/libraries', () => {
.send({ name: '' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['name should not be empty']));
expect(body).toEqual(errorDto.badRequest(['[name] Too small: expected string to have >=1 characters']));
});
it('should change the import paths', async () => {
@@ -181,7 +181,7 @@ describe('/libraries', () => {
.send({ importPaths: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in importPaths should not be empty']));
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array items must not be empty']));
});
it('should reject duplicate import paths', async () => {
@@ -191,7 +191,7 @@ describe('/libraries', () => {
.send({ importPaths: ['/path', '/path'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
});
it('should change the exclusion pattern', async () => {
@@ -215,7 +215,7 @@ describe('/libraries', () => {
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
});
it('should reject an empty exclusion pattern', async () => {
@@ -225,7 +225,7 @@ describe('/libraries', () => {
.send({ exclusionPatterns: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in exclusionPatterns should not be empty']));
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array items must not be empty']));
});
});

View File

@@ -109,7 +109,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lon=123')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
});
it('should throw an error if a lat is not a number', async () => {
@@ -117,7 +117,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=abc&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
});
it('should throw an error if a lat is out of range', async () => {
@@ -125,7 +125,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=91&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
expect(body).toEqual(errorDto.badRequest(['[lat] Too big: expected number to be <=90']));
});
it('should throw an error if a lon is not provided', async () => {
@@ -133,7 +133,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=75')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['lon must be a number between -180 and 180']));
expect(body).toEqual(errorDto.badRequest(['[lon] Invalid input: expected number, received NaN']));
});
const reverseGeocodeTestCases = [

View File

@@ -1,9 +1,10 @@
import { OAuthClient, OAuthUser } from '@immich/e2e-auth-server';
import { OAuthClient, OAuthUser, generateLogoutToken } from '@immich/e2e-auth-server';
import {
LoginResponseDto,
SystemConfigOAuthDto,
getConfigDefaults,
getMyUser,
getSessions,
startOAuth,
updateConfig,
} from '@immich/sdk';
@@ -76,6 +77,7 @@ const setupOAuth = async (token: string, dto: Partial<SystemConfigOAuthDto>) =>
...defaults.oauth,
buttonText: 'Login with Immich',
issuerUrl: `${authServer.internal}/.well-known/openid-configuration`,
allowInsecureRequests: true,
...dto,
};
await updateConfig({ systemConfigDto: { ...defaults, oauth: merged } }, options);
@@ -87,21 +89,23 @@ describe(`/oauth`, () => {
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
await setupOAuth(admin.accessToken, {
enabled: true,
clientId: OAuthClient.DEFAULT,
clientSecret: OAuthClient.DEFAULT,
buttonText: 'Login with Immich',
storageLabelClaim: 'immich_username',
});
});
describe('POST /oauth/authorize', () => {
beforeAll(async () => {
await setupOAuth(admin.accessToken, {
enabled: true,
clientId: OAuthClient.DEFAULT,
clientSecret: OAuthClient.DEFAULT,
buttonText: 'Login with Immich',
storageLabelClaim: 'immich_username',
});
});
it(`should throw an error if a redirect uri is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/authorize').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
expect(body).toEqual(errorDto.badRequest(['[redirectUri] Invalid input: expected string, received undefined']));
});
it('should return a redirect uri', async () => {
@@ -117,19 +121,56 @@ describe(`/oauth`, () => {
expect(params.get('redirect_uri')).toBe('http://127.0.0.1:2285/auth/login');
expect(params.get('state')).toBeDefined();
});
it('should not include the prompt parameter when not configured', async () => {
const { status, body } = await request(app)
.post('/oauth/authorize')
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
expect(status).toBe(201);
const params = new URL(body.url).searchParams;
expect(params.get('prompt')).toBeNull();
});
it('should include the prompt parameter when configured', async () => {
await setupOAuth(admin.accessToken, {
enabled: true,
clientId: OAuthClient.DEFAULT,
clientSecret: OAuthClient.DEFAULT,
prompt: 'select_account',
});
const { status, body } = await request(app)
.post('/oauth/authorize')
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
expect(status).toBe(201);
const params = new URL(body.url).searchParams;
expect(params.get('prompt')).toBe('select_account');
});
});
describe('POST /oauth/callback', () => {
beforeAll(async () => {
await setupOAuth(admin.accessToken, {
enabled: true,
clientId: OAuthClient.DEFAULT,
clientSecret: OAuthClient.DEFAULT,
buttonText: 'Login with Immich',
storageLabelClaim: 'immich_username',
});
});
it(`should throw an error if a url is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['url must be a string', 'url should not be empty']));
expect(body).toEqual(errorDto.badRequest(['[url] Invalid input: expected string, received undefined']));
});
it(`should throw an error if the url is empty`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['url should not be empty']));
expect(body).toEqual(errorDto.badRequest(['[url] Too small: expected string to have >=1 characters']));
});
it(`should throw an error if the state is not provided`, async () => {
@@ -158,10 +199,9 @@ describe(`/oauth`, () => {
it(`should throw an error if the codeVerifier doesn't match the challenge`, async () => {
const callbackParams = await loginWithOAuth('oauth-auto-register');
const { codeVerifier } = await loginWithOAuth('oauth-auto-register');
const { status, body } = await request(app)
const { status } = await request(app)
.post('/oauth/callback')
.send({ ...callbackParams, codeVerifier });
console.log(body);
expect(status).toBeGreaterThanOrEqual(400);
});
@@ -258,7 +298,7 @@ describe(`/oauth`, () => {
accessToken: expect.any(String),
isAdmin: false,
name: 'OAuth User',
userEmail: 'oauth-RS256-token@immich.app',
userEmail: 'oauth-rs256-token@immich.app',
userId: expect.any(String),
});
});
@@ -333,6 +373,50 @@ describe(`/oauth`, () => {
});
});
describe(`POST /oauth/backchannel-logout`, () => {
it(`should throw an error if the logout_token is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/backchannel-logout').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[logout_token] Invalid input: expected string, received undefined']));
});
it(`should throw an error if an invalid logout token is provided`, async () => {
const { status, body } = await request(app)
.post('/oauth/backchannel-logout')
.send({ logout_token: 'invalid token' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Error backchannel logout: token validation failed'));
});
it(`should logout user if a valid logout token is provided`, async () => {
await setupOAuth(admin.accessToken, {
enabled: true,
clientId: OAuthClient.DEFAULT,
clientSecret: OAuthClient.DEFAULT,
autoRegister: true,
signingAlgorithm: 'RS256',
buttonText: 'Login with Immich',
});
const callbackParams = await loginWithOAuth('backchannel-logout-user');
const { status: callbackStatus, body: callbackBody } = await request(app)
.post('/oauth/callback')
.send(callbackParams);
expect(callbackStatus).toBe(201);
await expect(getSessions({ headers: asBearerAuth(callbackBody.accessToken) })).resolves.toHaveLength(1);
const logoutToken = await generateLogoutToken('http://0.0.0.0:2286', 'backchannel-logout-user');
const { status, body } = await request(app).post('/oauth/backchannel-logout').send({ logout_token: logoutToken });
expect(status).toBe(200);
expect(body).toMatchObject({});
await expect(getSessions({ headers: asBearerAuth(callbackBody.accessToken) })).rejects.toMatchObject({
status: 401,
});
});
});
describe('mobile redirect override', () => {
beforeAll(async () => {
await setupOAuth(admin.accessToken, {
@@ -399,4 +483,23 @@ describe(`/oauth`, () => {
});
});
});
describe('allowInsecureRequests: false', () => {
beforeAll(async () => {
await setupOAuth(admin.accessToken, {
enabled: true,
clientId: OAuthClient.DEFAULT,
clientSecret: OAuthClient.DEFAULT,
allowInsecureRequests: false,
});
});
it('should reject OAuth discovery over HTTP', async () => {
const { status, body } = await request(app)
.post('/oauth/authorize')
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
expect(status).toBe(500);
expect(body).toMatchObject({ statusCode: 500 });
});
});
});

View File

@@ -74,7 +74,6 @@ describe('/search', () => {
const bytes = await readFile(join(testAssetDir, filename));
assets.push(
await utils.createAsset(admin.accessToken, {
deviceAssetId: `test-${filename}`,
assetData: { bytes, filename },
...dto,
}),
@@ -458,7 +457,7 @@ describe('/search', () => {
expect(Array.isArray(body)).toBe(true);
if (Array.isArray(body)) {
expect(body.length).toBeGreaterThan(10);
expect(body[0].name).toEqual(name);
expect(body[0].name).toEqual(expect.stringContaining(name));
expect(body[0].admin2name).toEqual(name);
}
});

View File

@@ -207,16 +207,6 @@ describe('/server', () => {
});
});
describe('GET /server/theme', () => {
it('should respond with the server theme', async () => {
const { status, body } = await request(app).get('/server/theme');
expect(status).toBe(200);
expect(body).toEqual({
customCss: '',
});
});
});
describe('GET /server/license', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/server/license');

View File

@@ -243,9 +243,21 @@ describe('/shared-links', () => {
});
it('should get data for correct password protected link', async () => {
const response = await request(app)
.post('/shared-links/login')
.send({ password: 'foo' })
.query({ key: linkWithPassword.key });
expect(response.status).toBe(201);
const cookies = response.get('Set-Cookie') ?? [];
expect(cookies).toHaveLength(1);
expect(cookies[0]).toContain('immich_shared_link_token');
const { status, body } = await request(app)
.get('/shared-links/me')
.query({ key: linkWithPassword.key, password: 'foo' });
.query({ key: linkWithPassword.key })
.set('Cookie', cookies);
expect(status).toBe(200);
expect(body).toEqual(

View File

@@ -309,7 +309,7 @@ describe('/tags', () => {
.get(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
it('should get tag details', async () => {
@@ -427,7 +427,7 @@ describe('/tags', () => {
.delete(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
it('should delete a tag', async () => {

View File

@@ -287,7 +287,8 @@ describe('/admin/users', () => {
it('should delete user', async () => {
const { status, body } = await request(app)
.delete(`/admin/users/${userToDelete.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({});
expect(status).toBe(200);
expect(body).toMatchObject({

View File

@@ -178,7 +178,9 @@ describe('/users', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['download.archiveSize must be an integer number']));
expect(body).toEqual(
errorDto.badRequest(['[download.archiveSize] Invalid input: expected int, received number']),
);
});
it('should update download archive size', async () => {
@@ -204,7 +206,9 @@ describe('/users', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['download.includeEmbeddedVideos must be a boolean value']));
expect(body).toEqual(
errorDto.badRequest(['[download.includeEmbeddedVideos] Invalid input: expected boolean, received number']),
);
});
it('should update download include embedded videos', async () => {

View File

@@ -1,6 +1,7 @@
import { LoginResponseDto } from '@immich/sdk';
import { test } from '@playwright/test';
import { utils } from 'src/utils';
import { expect, test } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { testAssetDir, utils } from 'src/utils';
test.describe('Album', () => {
let admin: LoginResponseDto;
@@ -22,4 +23,41 @@ test.describe('Album', () => {
await page.reload();
await page.getByRole('button', { name: 'Select photos' }).waitFor();
});
test('should keep map view open after viewing an asset from the map and going back', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
const imagePath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const mapAsset = await utils.createAsset(admin.accessToken, {
assetData: {
bytes: readFileSync(imagePath),
filename: 'thompson-springs.jpg',
},
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const mapAlbum = await utils.createAlbum(admin.accessToken, {
albumName: 'Map Test Album',
assetIds: [mapAsset.id],
});
await page.goto(`/albums/${mapAlbum.id}`);
const mapButton = page.getByRole('button', { name: 'Map' });
await expect(mapButton).toBeVisible();
await mapButton.click();
const mapModal = page.getByRole('dialog');
await expect(mapModal).toBeVisible();
const mapMarker = mapModal.getByRole('img', { name: /Map marker/i }).first();
await expect(mapMarker).toBeVisible();
await mapMarker.click();
await page.waitForSelector('#immich-asset-viewer');
await page.getByRole('button', { name: 'Go back' }).click();
await expect(page.locator('#immich-asset-viewer')).not.toBeVisible();
await expect(mapModal).toBeVisible();
});
});

View File

@@ -1,7 +1,9 @@
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import type { Socket } from 'socket.io-client';
import { utils } from 'src/utils';
import { testAssetDir, utils } from 'src/utils';
test.describe('Detail Panel', () => {
let admin: LoginResponseDto;
@@ -83,4 +85,42 @@ test.describe('Detail Panel', () => {
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
await expect(textarea).toHaveValue('new description');
});
test.describe('Date editor', () => {
test('displays inferred asset timezone', async ({ context, page }) => {
const test = {
filepath: 'metadata/dates/datetimeoriginal-gps.jpg',
expected: {
dateTime: '2025-12-01T11:30',
// Test with a timezone which is NOT the first among timezones with the same offset
// This is to check that the editor does not simply fall back to the first available timezone with that offset
// America/Denver (-07:00) is not the first among timezones with offset -07:00
timeZoneWithOffset: 'America/Denver (-07:00)',
},
};
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
bytes: await readFile(join(testAssetDir, test.filepath)),
filename: basename(test.filepath),
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
// asset viewer -> detail panel -> date editor
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
await page.getByRole('button', { name: 'Info' }).click();
await page.getByTestId('detail-panel-edit-date-button').click();
await page.waitForSelector('[role="dialog"]');
const datetime = page.locator('#datetime');
await expect(datetime).toHaveValue(test.expected.dateTime);
const timezone = page.getByRole('combobox', { name: 'Timezone' });
await expect(timezone).toHaveValue(test.expected.timeZoneWithOffset);
});
});
});

View File

@@ -16,8 +16,8 @@ test.describe('Duplicates Utility', () => {
test.beforeEach(async ({ context }) => {
[firstAsset, secondAsset] = await Promise.all([
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
utils.createAsset(admin.accessToken, {}),
utils.createAsset(admin.accessToken, {}),
]);
await updateAssets(

View File

@@ -77,18 +77,4 @@ test.describe('Photo Viewer', () => {
});
expect(tagAtCenter).toBe('IMG');
});
test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const initialSrc = await preview.getAttribute('src');
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
await utils.replaceAsset(admin.accessToken, asset.id);
await websocketEvent;
await expect(preview).not.toHaveAttribute('src', initialSrc!);
});
});

View File

@@ -3,6 +3,7 @@
*/
import {
AlbumUserRole,
AssetTypeEnum,
AssetVisibility,
UserAvatarColor,
@@ -315,11 +316,9 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
return {
id: asset.id,
deviceAssetId: `device-${asset.id}`,
ownerId: asset.ownerId,
owner: owner || defaultOwner,
libraryId: `library-${asset.ownerId}`,
deviceId: `device-${asset.ownerId}`,
type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
@@ -334,7 +333,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
isArchived: false,
isTrashed: asset.isTrashed,
visibility: asset.visibility,
duration: asset.duration || '0:00:00.00000',
duration: asset.duration,
exifInfo,
livePhotoVideoId: asset.livePhotoVideoId,
tags: [],
@@ -422,14 +421,11 @@ export function getAlbum(
albumThumbnailAssetId: album.thumbnailAssetId,
createdAt: album.createdAt,
updatedAt: album.updatedAt,
ownerId: albumOwner.id,
owner: albumOwner,
albumUsers: [], // Empty array for non-shared album
albumUsers: [{ user: albumOwner, role: AlbumUserRole.Owner }],
shared: false,
hasSharedLink: false,
isActivityEnabled: true,
assetCount: albumAssets.length,
assets: albumAssets,
startDate: albumAssets.length > 0 ? albumAssets.at(-1)?.fileCreatedAt : undefined,
endDate: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
lastModifiedAssetTimestamp: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,

View File

@@ -1,5 +1,5 @@
import { BrowserContext } from '@playwright/test';
import { playwrightHost } from 'playwright.config';
import { playwrightHost } from 'src/../playwright.config';
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
await context.addCookies([
@@ -173,6 +173,7 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
'.mpeg',
'.mpg',
'.mts',
'.ts',
'.vob',
'.webm',
'.wmv',
@@ -222,6 +223,7 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
'.jp2',
'.jpe',
'.jxl',
'.mpo',
'.svg',
'.tif',
'.tiff',

View File

@@ -16,7 +16,6 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
const now = new Date().toISOString();
return {
id: assetId,
deviceAssetId: `device-${assetId}`,
ownerId,
owner: {
id: ownerId,
@@ -27,7 +26,6 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
avatarColor: 'blue' as never,
},
libraryId: `library-${ownerId}`,
deviceId: `device-${ownerId}`,
type: AssetTypeEnum.Image,
originalPath: `/original/${assetId}.jpg`,
originalFileName: `${assetId}.jpg`,
@@ -42,7 +40,7 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
isArchived: false,
isTrashed: false,
visibility: AssetVisibility.Timeline,
duration: '0:00:00.00000',
duration: null,
exifInfo: {
make: null,
model: null,
@@ -69,7 +67,7 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
tags: [],
people: [],
unassignedFaces: [],
stack: null,
stack: undefined,
isOffline: false,
hasMetadata: true,
duplicateId: null,

View File

@@ -0,0 +1,55 @@
import { faker } from '@faker-js/faker';
import type { AssetOcrResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
export type MockOcrBox = {
text: string;
x1: number;
y1: number;
x2: number;
y2: number;
x3: number;
y3: number;
x4: number;
y4: number;
};
export const createMockOcrData = (assetId: string, boxes: MockOcrBox[]): AssetOcrResponseDto[] => {
return boxes.map((box) => ({
id: faker.string.uuid(),
assetId,
x1: box.x1,
y1: box.y1,
x2: box.x2,
y2: box.y2,
x3: box.x3,
y3: box.y3,
x4: box.x4,
y4: box.y4,
boxScore: 0.95,
textScore: 0.9,
text: box.text,
}));
};
export const setupOcrMockApiRoutes = async (
context: BrowserContext,
ocrDataByAssetId: Map<string, AssetOcrResponseDto[]>,
) => {
await context.route('**/assets/*/ocr', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const segments = url.pathname.split('/');
const assetIdIndex = segments.indexOf('assets') + 1;
const assetId = segments[assetIdIndex];
const ocrData = ocrDataByAssetId.get(assetId) ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
json: ocrData,
});
});
};

View File

@@ -0,0 +1,300 @@
import type { AssetOcrResponseDto, AssetResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { createMockOcrData, setupOcrMockApiRoutes } from 'src/ui/mock-network/ocr-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
const PRIMARY_OCR_BOXES = [
{ text: 'Hello World', x1: 0.1, y1: 0.1, x2: 0.4, y2: 0.1, x3: 0.4, y3: 0.15, x4: 0.1, y4: 0.15 },
{ text: 'Immich Photo', x1: 0.2, y1: 0.3, x2: 0.6, y2: 0.3, x3: 0.6, y3: 0.36, x4: 0.2, y4: 0.36 },
];
const SECONDARY_OCR_BOXES = [
{ text: 'Second Asset Text', x1: 0.15, y1: 0.2, x2: 0.55, y2: 0.2, x3: 0.55, y3: 0.26, x4: 0.15, y4: 0.26 },
];
test.describe('OCR bounding boxes', () => {
const fixture = setupAssetViewerFixture(920);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR bounding boxes appear when clicking OCR button', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
await expect(ocrBoxes).toHaveCount(2);
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
await expect(ocrBoxes.nth(1)).toContainText('Immich Photo');
});
test('OCR bounding boxes toggle off on second click', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await ocrButton.click();
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]').first()).toBeVisible();
await ocrButton.click();
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]')).toHaveCount(0);
});
});
test.describe('OCR with stacked assets', () => {
const fixture = setupAssetViewerFixture(921);
let mockStack: MockStack;
let primaryAssetDto: AssetResponseDto;
let secondAssetDto: AssetResponseDto;
test.beforeAll(async () => {
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
secondAssetDto = createMockStackAsset(fixture.adminUserId);
secondAssetDto.originalFileName = 'second-ocr-asset.jpg';
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
[secondAssetDto.id, createMockOcrData(secondAssetDto.id, SECONDARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('different OCR boxes shown for different stacked assets', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
await expect(ocrBoxes).toHaveCount(2);
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
await expect(stackThumbnails).toHaveCount(2);
await stackThumbnails.nth(1).click();
// refreshOcr() clears showOverlay when switching assets, so re-enable it
await expect(ocrBoxes).toHaveCount(0);
await expect(ocrButton).toBeVisible();
await ocrButton.click();
await expect(ocrBoxes).toHaveCount(1);
await expect(ocrBoxes.first()).toContainText('Second Asset Text');
});
});
test.describe('OCR boxes and zoom', () => {
const fixture = setupAssetViewerFixture(922);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR boxes scale with zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const initialBox = await ocrBox.boundingBox();
expect(initialBox).toBeTruthy();
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -3);
await expect(async () => {
const zoomedBox = await ocrBox.boundingBox();
expect(zoomedBox).toBeTruthy();
expect(zoomedBox!.width).toBeGreaterThan(initialBox!.width);
expect(zoomedBox!.height).toBeGreaterThan(initialBox!.height);
}).toPass({ timeout: 2000 });
});
});
test.describe('OCR text interaction', () => {
const fixture = setupAssetViewerFixture(923);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR text box has data-overlay-interactive attribute', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
await expect(ocrBox).toHaveAttribute('data-overlay-interactive');
});
test('OCR text box receives focus on click', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
await ocrBox.click();
await expect(ocrBox).toBeFocused();
});
test('dragging on OCR text box does not trigger image pan', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
const initialTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
const box = await ocrBox.boundingBox();
expect(box).toBeTruthy();
const centerX = box!.x + box!.width / 2;
const centerY = box!.y + box!.height / 2;
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + 50, centerY + 30, { steps: 5 });
await page.mouse.up();
const afterTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(afterTransform).toBe(initialTransform);
});
test('split touch gesture across zoom container does not trigger zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
const initialTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
const viewerContent = page.locator('[data-viewer-content]');
const viewerBox = await viewerContent.boundingBox();
expect(viewerBox).toBeTruthy();
// Dispatch a synthetic split gesture: one touch inside the viewer, one outside
await page.evaluate(
({ viewerCenterX, viewerCenterY, outsideY }) => {
const viewer = document.querySelector('[data-viewer-content]');
if (!viewer) {
return;
}
const createTouch = (id: number, x: number, y: number) => {
return new Touch({
identifier: id,
target: viewer,
clientX: x,
clientY: y,
});
};
const insideTouch = createTouch(0, viewerCenterX, viewerCenterY);
const outsideTouch = createTouch(1, viewerCenterX, outsideY);
const touchStartEvent = new TouchEvent('touchstart', {
touches: [insideTouch, outsideTouch],
targetTouches: [insideTouch],
changedTouches: [insideTouch, outsideTouch],
bubbles: true,
cancelable: true,
});
const touchMoveEvent = new TouchEvent('touchmove', {
touches: [createTouch(0, viewerCenterX, viewerCenterY - 30), createTouch(1, viewerCenterX, outsideY + 30)],
targetTouches: [createTouch(0, viewerCenterX, viewerCenterY - 30)],
changedTouches: [
createTouch(0, viewerCenterX, viewerCenterY - 30),
createTouch(1, viewerCenterX, outsideY + 30),
],
bubbles: true,
cancelable: true,
});
const touchEndEvent = new TouchEvent('touchend', {
touches: [],
targetTouches: [],
changedTouches: [insideTouch, outsideTouch],
bubbles: true,
cancelable: true,
});
viewer.dispatchEvent(touchStartEvent);
viewer.dispatchEvent(touchMoveEvent);
viewer.dispatchEvent(touchEndEvent);
},
{
viewerCenterX: viewerBox!.x + viewerBox!.width / 2,
viewerCenterY: viewerBox!.y + viewerBox!.height / 2,
outsideY: 10, // near the top of the page, outside the viewer
},
);
const afterTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(afterTransform).toBe(initialTransform);
});
});

View File

@@ -6,6 +6,7 @@ import {
generateTimelineData,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
@@ -30,6 +31,10 @@ test.describe('search gallery-viewer', () => {
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
@@ -44,7 +49,10 @@ test.describe('search gallery-viewer', () => {
await context.route('**/api/search/metadata', async (route, request) => {
if (request.method() === 'POST') {
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
const searchAssets = assets
.slice(0, 5)
.filter((asset) => !changes.assetDeletions.includes(asset.id))
.map((asset) => toAssetResponseDto(asset));
return route.fulfill({
status: 200,
contentType: 'application/json',

View File

@@ -349,7 +349,7 @@ test.describe('Timeline', () => {
expect(visibleMockAssetsYearMonths).toContain(month);
}
});
test('Deep link to last photo, scroll up', async ({ page }) => {
test.skip('Deep link to last photo, scroll up', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
@@ -361,7 +361,7 @@ test.describe('Timeline', () => {
await thumbnailUtils.expectInViewport(page, '14e5901f-fd7f-40c0-b186-4d7e7fc67968');
});
test('Deep link to first bucket, scroll down', async ({ page }) => {
test.skip('Deep link to first bucket, scroll down', async ({ page }) => {
const lastAsset = assets.at(0)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await timelineUtils.locator(page).hover();
@@ -440,7 +440,7 @@ test.describe('Timeline', () => {
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
});
test('Add photos to album', async ({ page }) => {
test.skip('Add photos to album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
await page.locator('nav button[aria-label="Add photos"]').click();
@@ -752,7 +752,7 @@ test.describe('Timeline', () => {
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
});
test('open /favorites, archive photo, unarchive photo', async ({ page }) => {
test.skip('open /favorites, archive photo, unarchive photo', async ({ page }) => {
await pageUtils.openFavorites(page);
const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();

View File

@@ -62,7 +62,7 @@ export const thumbnailUtils = {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
},
selectButton(page: Page, assetId: string) {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button[role="checkbox"]`);
},
selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container][data-selected]');

View File

@@ -3,7 +3,6 @@ import {
AssetMediaResponseDto,
AssetResponseDto,
AssetVisibility,
CheckExistingAssetsDto,
CreateAlbumDto,
CreateLibraryDto,
JobCreateDto,
@@ -20,7 +19,6 @@ import {
UserAdminCreateDto,
UserPreferencesUpdateDto,
ValidateLibraryDto,
checkExistingAssets,
createAlbum,
createApiKey,
createJob,
@@ -343,8 +341,6 @@ export const utils = {
},
) => {
const _dto = {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
...dto,
@@ -375,40 +371,6 @@ export const utils = {
return body as AssetMediaResponseDto;
},
replaceAsset: async (
accessToken: string,
assetId: string,
dto?: Partial<Omit<AssetMediaCreateDto, 'assetData'>> & { assetData?: FileData },
) => {
const _dto = {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
...dto,
};
const assetData = dto?.assetData?.bytes || makeRandomImage();
const filename = dto?.assetData?.filename || 'example.png';
if (dto?.assetData?.bytes) {
console.log(`Uploading ${filename}`);
}
const builder = request(app)
.put(`/assets/${assetId}/original`)
.attach('assetData', assetData, filename)
.set('Authorization', `Bearer ${accessToken}`);
for (const [key, value] of Object.entries(_dto)) {
void builder.field(key, String(value));
}
const { body } = await builder;
return body as AssetMediaResponseDto;
},
createImageFile: (path: string) => {
if (!existsSync(dirname(path))) {
mkdirSync(dirname(path), { recursive: true });
@@ -450,9 +412,6 @@ export const utils = {
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) =>
checkExistingAssets({ checkExistingAssetsDto }, { headers: asBearerAuth(accessToken) }),
searchAssets: async (accessToken: string, dto: MetadataSearchDto) => {
return searchAssets({ metadataSearchDto: dto }, { headers: asBearerAuth(accessToken) });
},
@@ -510,6 +469,9 @@ export const utils = {
createStack: (accessToken: string, assetIds: string[]) =>
createStack({ stackCreateDto: { assetIds } }, { headers: asBearerAuth(accessToken) }),
setAssetDuplicateId: (accessToken: string, assetId: string, duplicateId: string | null) =>
updateAssets({ assetBulkUpdateDto: { ids: [assetId], duplicateId } }, { headers: asBearerAuth(accessToken) }),
upsertTags: (accessToken: string, tags: string[]) =>
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }),

View File

@@ -14,8 +14,10 @@
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"paths": {
"src/*": ["./src/*"]
},
"esModuleInterop": true,
"baseUrl": "./"
},
"include": ["src/**/*.ts", "vitest*.config.ts"],
"exclude": ["dist", "node_modules"]

View File

@@ -178,6 +178,17 @@
"stop_motion_photo": "Stop bewegingsfoto",
"stop_photo_sharing": "Staak die deel van u fotos?",
"stop_photo_sharing_description": "{partner} sal nie meer toegang tot u fotos hê nie.",
"unnamed_share": "Naamlose deelskakel",
"unsaved_change": "Onbewaarde verandering",
"unselect_all": "Ontkies alles",
"unselect_all_duplicates": "Ontkies alle duplikate",
"unselect_all_in": "Ontkies alles in {group}",
"unstack": "Ontstapel",
"unstack_action_prompt": "{count} ongestapel",
"unstacked_assets_count": "{count, plural, one {# item} other {# items}} ontstapel",
"unsupported_field_type": "Onondersteunde veldtipe",
"unsupported_file_type": "Lêer {file} kan nie opgelaai word nie omdat die lêertipe {type} nie ondersteun word nie.",
"untagged": "Sonder etiket",
"untitled_workflow": "Naamlose werkvloei",
"up_next": "Volgende",
"update_location_action_prompt": "Werk die ligging van {count} gekose items by met:",
@@ -187,6 +198,7 @@
"upload_concurrency": "Aantal gelyktydige oplaaie",
"upload_details": "Oplaaidetails",
"upload_dialog_info": "Wil u n rugsteun maak van die gekose item(s) op die bediener?",
"upload_dialog_title": "Laai item op",
"upload_error_with_count": "Oplaaifout vir {count, plural, one {# item} other {# items}}",
"upload_errors": "Oplaai voltooi met {count, plural, one {# fout} other {# foute}}, verfris die blad om die nuwe items te sien.",
"upload_finished": "Klaar opgelaai",
@@ -257,6 +269,7 @@
"viewer_remove_from_stack": "Verwyder van stapel",
"viewer_stack_use_as_main_asset": "Gebruik as hoofitem",
"viewer_unstack": "Ontstapel",
"visibility": "Sigbaarheid",
"visibility_changed": "Sigbaarheid verander vir {count, plural, one {# mens} other {# mense}}",
"visual": "Visueel",
"visual_builder": "Visuele bouer",

View File

@@ -3,7 +3,7 @@
"account": "حساب",
"account_settings": "إعدادات الحساب",
"acknowledge": "أُدرك ذلك",
"action": "عملية",
"action": "إجراء",
"action_common_update": "تحديث",
"action_description": "مجموعة من الفعاليات التي ستنفذ على الأصول التي تم تصفيتها",
"actions": "عمليات",
@@ -61,8 +61,8 @@
"backup_onboarding_1_description": "نسخة خارج الموقع في موقع آخر.",
"backup_onboarding_2_description": "نسخ محلية على أجهزة مختلفة. يشمل ذلك الملفات الرئيسية ونسخة احتياطية محلية منها.",
"backup_onboarding_3_description": "إجمالي نُسخ بياناتك، بما في ذلك الملفات الأصلية. يشمل ذلك نسخةً واحدةً خارج الموقع ونسختين محليتين.",
"backup_onboarding_description": "يُنصح باتباع <backblaze-link>استراتيجية النسخ الاحتياطي 3-2-1</backblaze-link> لحماية بياناتك. احتفظ بنسخ احتياطية من صورك/فيديوهاتك المحمّلة، بالإضافة إلى قاعدة بيانات Immich، لضمان حل نسخ احتياطي شامل.",
"backup_onboarding_footer": "لمزيد من المعلومات حول النسخ الاحتياطي لـ Immich، يرجى الرجوع إلى <link> التعليمات </link>.",
"backup_onboarding_description": "يُنصح باتباع <backblaze-link>استراتيجية النسخ الاحتياطي 3-2- 1</backblaze-link> لحماية بياناتك. احتفظ بنسخ احتياطية من صورك/فيديوهاتك المحمّلة، بالإضافة إلى قاعدة بيانات Immich، لضمان حل نسخ احتياطي شامل.",
"backup_onboarding_footer": "لمزيد من المعلومات حول النسخ الاحتياطي لـ <link>Immich</link>، يرجى الرجوع إلى <link>الوثائق</link>.",
"backup_onboarding_parts_title": "يتضمن النسخ الاحتياطي 3-2-1 ما يلي:",
"backup_onboarding_title": "النسخ الاحتياطية",
"backup_settings": "إعدادات تفريغ قاعدة البيانات",
@@ -333,7 +333,7 @@
"storage_template_migration_description": "قم بتطبيق القالب الحالي <link>{template}</link> على المحتويات التي تم رفعها سابقًا",
"storage_template_migration_info": "تغييرات النموذج الخزني ستغير جميع الصيغ الى احرف صغيرة. تغييرات النموذج ستنطبق فقط على المحتويات الجديدة. لتطبيق النموذج على المحتويات التي تم رفعها سابقًا، قم بتشغيل <link>{job}</link>.",
"storage_template_migration_job": "وظيفة تهجير قالب التخزين",
"storage_template_more_details": "لمزيد من التفاصيل حول هذه الميزة، يرجى الرجوع إلى <template-link>Storage Template</template-link> و<implications-link>implications</implications-link>",
"storage_template_more_details": "لمزيد من التفاصيل حول هذه الميزة، يرجى الرجوع إلى <template-link>Storage Template</template-link> و <implications-link>implications</implications-link>.",
"storage_template_onboarding_description_v2": "عند التفعيل. هذه الخاصية ستقوم بالترتيب التلقائي للملفات بناء على نموذج معرف من قبل المستخدم. رجاء اطلع على <link>التوثيق</link>.",
"storage_template_path_length": "الحد التقريبي لطول المسار: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "قالب التخزين",
@@ -372,7 +372,7 @@
"transcoding_audio_codec": "كود الصوت",
"transcoding_audio_codec_description": "Opus هو الخيار ذو أعلى جودة، ولكنه يتمتع بتوافق أقل مع الأجهزة أو البرمجيات القديمة.",
"transcoding_bitrate_description": "مقاطع الفيديو التي يتجاوز معدل البت أقصى قيمة أو التي لا تكون في تنسيق مقبول",
"transcoding_codecs_learn_more": "لمعرفة المزيد حول المصطلحات المستخدمة هنا، يرجى الرجوع إلى وثائق FFmpeg لل<h264-link>H.264 codec</h264-link>, <hevc-link>HEVC codec</hevc-link> and <vp9-link>VP9 codec</vp9-link>.",
"transcoding_codecs_learn_more": "لمعرفة المزيد حول المصطلحات المستخدمة هنا، يرجى الرجوع إلى وثائق FFmpeg لـ <h264-link>H.264 codec</h264-link>، و <hevc-link>HEVC codec</hevc-link> و <vp9-link>VP9 codec</vp9-link>.",
"transcoding_constant_quality_mode": "وضع الجودة الثابتة",
"transcoding_constant_quality_mode_description": "ICQ أفضل من CQP، ولكن بعض أجهزة عتاد التسريع لا تدعم هذا الوضع. تعيين هذا الخيار يسجعل الأفضلية للوضع المحدد عند استخدام الترميز بناءً على الجودة. يتم تجاهله بواسطة NVENC لأنه لا يدعم ICQ.",
"transcoding_constant_rate_factor": "عامل معدل الجودة الثابت (-crf)",
@@ -441,7 +441,7 @@
"user_successfully_removed": "المستخدم {email} تمت ازالته بنجاح.",
"users_page_description": "صفحة ادارة المستخدمين",
"version_check_enabled_description": "تفعيل التحقق من الإصدارات الجديدة",
"version_check_implications": "تعتمد ميزة التحقق من الإصدار على التواصل الدوري مع github.com",
"version_check_implications": "تعتمد ميزة التحقق من الإصدار على التواصل الدوري مع {server}",
"version_check_settings": "التحقق من الإصدار",
"version_check_settings_description": "تفعيل/تعطيل الإشعار لإصدار جديد",
"video_conversion_job": "تحويل أشرطة الفيديو",
@@ -849,9 +849,12 @@
"create_link_to_share": "إنشاء رابط للمشاركة",
"create_link_to_share_description": "السماح لأي شخص لديه الرابط بمشاهدة الصورة (الصور) المحددة",
"create_new": "انشاء جديد",
"create_new_face": "إنشاء وجه جديد",
"create_new_person": "إنشاء شخص جديد",
"create_new_person_hint": "تعيين المحتويات المحددة لشخص جديد",
"create_new_user": "إنشاء مستخدم جديد",
"create_person": "إنشاء شخص",
"create_person_subtitle": "أضف اسماً للوجه المحدد لإنشاء الشخص الجديد والإشارة إليه",
"create_shared_album_page_share_add_assets": "إضافة الأصول",
"create_shared_album_page_share_select_photos": "حدد الصور",
"create_shared_link": "انشاء رابط مشترك",
@@ -866,6 +869,7 @@
"crop_aspect_ratio_fixed": "تم الاصلاح",
"crop_aspect_ratio_free": "حر",
"crop_aspect_ratio_original": "اصلي",
"crop_aspect_ratio_square": "مربع",
"curated_object_page_title": "أشياء",
"current_device": "الجهاز الحالي",
"current_pin_code": "رمز PIN الحالي",
@@ -880,7 +884,7 @@
"daily_title_text_date": "E ، MMM DD",
"daily_title_text_date_year": "E ، MMM DD ، yyyy",
"dark": "معتم",
"dark_theme": "تبديل المظهر الداكن",
"dark_theme": "تبديل المظهر إلى الداكن",
"date": "تاريخ",
"date_after": "التارخ بعد",
"date_and_time": "التاريخ و الوقت",
@@ -891,10 +895,8 @@
"day": "يوم",
"days": "ايام",
"deduplicate_all": "إلغاء تكرار الكل",
"deduplication_criteria_1": "حجم الصورة بوحدات البايت",
"deduplication_criteria_2": "عدد بيانات EXIF",
"deduplication_info": "معلومات إلغاء البيانات المكررة",
"deduplication_info_description": "لتحديد الأصول مسبقا تلقائيا وإزالة التكرارات بكميات كبيرة، ننظر إلى:",
"default_locale": "الإعدادات المحلية الافتراضية",
"default_locale_description": "تنسيق التواريخ والأرقام بناءً على الإعدادات المحلية للمتصفح",
"delete": "حذف",
"delete_action_confirmation_message": "هل انت متأكد من حذف هذا الملف؟ هذا سؤدي الى نقل الملف الى سلة مهملات الخادم وسيتم اشعارك ان كنت تريد حذفه على الجهاز",
"delete_action_prompt": "تم حذف {count}",
@@ -970,7 +972,7 @@
"downloading_media": "تنزيل الوسائط",
"drop_files_to_upload": "قم بإسقاط الملفات في أي مكان لرفعها",
"duplicates": "التكرارات",
"duplicates_description": "قم بحل كل مجموعة من خلال الإشارة إلى التكرارات، إن وجدت",
"duplicates_description": "قم بحل كل مجموعة من خلال الإشارة إلى التكرارات، إن وجدت.",
"duration": "المدة",
"edit": "تعديل",
"edit_album": "تعديل الألبوم",
@@ -1387,9 +1389,11 @@
"library_page_sort_title": "عنوان الألبوم",
"licenses": "رُخَص",
"light": "المضيئ",
"light_theme": "التبديل إلى المظهر الفاتح",
"like": "اعجاب",
"like_deleted": "تم حذف الإعجاب",
"link_motion_video": "رابط فيديو الحركة",
"link_to_docs": "لمزيد من المعلومات، يُرجى الرجوع إلى <link>الوثائق</link>.",
"link_to_oauth": "الربط مع OAuth",
"linked_oauth_account": "حساب مرتبط بـ OAuth",
"list": "قائمة",
@@ -1651,6 +1655,7 @@
"only_favorites": "المفضلة فقط",
"open": "فتح",
"open_calendar": "افتح الرزنامة",
"open_in_browser": "فتح في متصفح",
"open_in_map_view": "فتح في عرض الخريطة",
"open_in_openstreetmap": "فتح في OpenStreetMap",
"open_the_search_filters": "افتح مرشحات البحث",
@@ -2212,6 +2217,7 @@
"tag": "العلامة",
"tag_assets": "أصول العلامة",
"tag_created": "تم إنشاء العلامة: {tag}",
"tag_face": "علِّم الوجه",
"tag_feature_description": "تصفح الصور ومقاطع الفيديو المجمعة حسب مواضيع العلامات المنطقية",
"tag_not_found_question": "لا يمكن العثور على علامة؟ <link>قم بإنشاء علامة جديدة.</link>",
"tag_people": "علِّم الأشخاص",
@@ -2386,13 +2392,14 @@
"view_name": "عرض",
"view_next_asset": "عرض المحتوى التالي",
"view_previous_asset": "عرض المحتوى السابق",
"view_qr_code": "­عرض رمز الاستجابة السريعة",
"view_qr_code": "عرض رمز الاستجابة السريعة",
"view_similar_photos": "عرض صور مشابهة",
"view_stack": "عرض التكديس",
"view_user": "عرض المستخدم",
"viewer_remove_from_stack": "حذف من الكومه أو المجموعة",
"viewer_stack_use_as_main_asset": "استخدم كأصل رئيسي",
"viewer_unstack": "فك الكومه",
"visibility": "إمكانية الرؤية",
"visibility_changed": "الرؤية تغيرت لـ {count, plural, one {شخص واحد} other {# عدة أشخاص}}",
"visual": "مرئي",
"visual_builder": "اداة نشاء مرئية",
@@ -2404,14 +2411,14 @@
"welcome_to_immich": "مرحباً بك في Immich",
"width": "عُرض",
"wifi_name": "اسم شبكة Wi-Fi",
"workflow_delete_prompt": "هل أنت متأكد من حذف سير العمل هذا؟",
"workflow_delete_prompt": "متأكد من حذف سير العمل هذا؟",
"workflow_deleted": "تم حذف سير العمل",
"workflow_description": "وصف سير العمل",
"workflow_info": "معلومات سير العمل",
"workflow_json": "ملف JSON لسير العمل",
"workflow_json_help": "قم بتعديل إعدادات سير العمل بصيغة JSON. ستتم مزامنة التغييرات مع أداة الإنشاء المرئية.",
"workflow_name": "اسم سير العمل",
"workflow_navigation_prompt": "هل انت متاكد من المغادرة بدون حفظ التغييرات؟",
"workflow_navigation_prompt": "متاكد من المغادرة بدون حفظ التغييرات؟",
"workflow_summary": "ملخص سير العمل",
"workflow_update_success": "تم تحديث سير العمل بنجاح",
"workflow_updated": "تم تحديث سير العمل",

View File

@@ -239,7 +239,7 @@
"user_settings": "Налады карыстальніка",
"user_settings_description": "Кіраванне наладамі карыстальніка",
"version_check_enabled_description": "Уключыць праверку версіі",
"version_check_implications": "Функцыя праверкі версіі перыядычна звяртаецца да github.com",
"version_check_implications": "Функцыя праверкі версіі перыядычна звяртаецца да {server}",
"version_check_settings": "Праверка версіі",
"version_check_settings_description": "Уключыць/адключыць апавяшчэнні аб новай версіі"
},

View File

@@ -333,7 +333,7 @@
"storage_template_migration_description": "Прилагане на текущия <link>{template}</link> към предишно качените файлове",
"storage_template_migration_info": "Шаблона ще преобразува всички разширения на имената на файловете в долен регистър. Промените в шаблоните ще се прилагат само за нови елементи. За да приложите принудително шаблона към вече качени елементи, изпълнете <link>{job}</link>.",
"storage_template_migration_job": "Задача за миграция на шаблона за съхранение",
"storage_template_more_details": "За повече подробности относно тази функция се обърнете към шаблона <template-link>Storage Template</template-link> и неговите <implications-link> последствия </implications-link>",
"storage_template_more_details": "За повече подробности относно тази функция се обърнете към шаблона <template-link>Storage Template</template-link> и неговите <implications-link>последствия</implications-link>",
"storage_template_onboarding_description_v2": "Когато е разрешена, тази функция ще организира автоматично файловете, според шаблон, дефиниран от потребителя. За допълнителна информация, моля вижте <link>документацията</link>.",
"storage_template_path_length": "Ограничение на дължината на пътя: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "Шаблон за съхранение",
@@ -441,7 +441,7 @@
"user_successfully_removed": "Потребител {email} е успешно премахнат.",
"users_page_description": "Страница за администриране на потребители",
"version_check_enabled_description": "Активирай проверка на версията",
"version_check_implications": "Функцията за проверка на версията разчита на периодична комуникация с github.com",
"version_check_implications": "Функцията за проверка на версията разчита на периодична комуникация с {server}",
"version_check_settings": "Проверка на версията",
"version_check_settings_description": "Активирайте/деактивирайте известието за нова версия",
"video_conversion_job": "Транскодиране на видеоклиповете",
@@ -849,9 +849,12 @@
"create_link_to_share": "Създаване на линк за споделяне",
"create_link_to_share_description": "Позволете на всеки, който има линк, да види избраната(ите) снимка(и)",
"create_new": "СЪЗДАЙ НОВ",
"create_new_face": "Създай ново лице",
"create_new_person": "Създаване на ново лице",
"create_new_person_hint": "Присвойте избраните файлове на нов човек",
"create_new_user": "Създаване на нов потребител",
"create_person": "Създай човек",
"create_person_subtitle": "Добави име към избраното лице за да създадеш и да сложиш етикет на новия човек",
"create_shared_album_page_share_add_assets": "ДОБАВИ ОБЕКТИ",
"create_shared_album_page_share_select_photos": "Избери снимки",
"create_shared_link": "Създай линк за споделяне",
@@ -866,6 +869,7 @@
"crop_aspect_ratio_fixed": "Фиксиран",
"crop_aspect_ratio_free": "Свободен",
"crop_aspect_ratio_original": "Оригинален",
"crop_aspect_ratio_square": "Квадрат",
"curated_object_page_title": "Неща",
"current_device": "Текущо устройство",
"current_pin_code": "Сегашен PIN код",
@@ -880,7 +884,7 @@
"daily_title_text_date": "E, dd MMM",
"daily_title_text_date_year": "E, dd MMM yyyy",
"dark": "Тъмен",
"dark_theme": "Тъмна тема",
"dark_theme": "Премини към тъмна тема",
"date": "Дата",
"date_after": "Дата след",
"date_and_time": "Дата и час",
@@ -891,10 +895,8 @@
"day": "Ден",
"days": "Дни",
"deduplicate_all": "Дедупликиране на всички",
"deduplication_criteria_1": "Размер на снимката в байтове",
"deduplication_criteria_2": "Брой EXIF данни",
"deduplication_info": "Информация за дедупликацията",
"deduplication_info_description": "За автоматично предварително избиране на ресурси и премахване на дубликати на едро, разглеждаме:",
"default_locale": "Език по подразбиране",
"default_locale_description": "Формат на дата и числа според езиковата настройка на браузъра",
"delete": "Изтрий",
"delete_action_confirmation_message": "Сигурни ли сте, че искате да изтриете този обект? Следва преместване на обекта в коша за отпадъци на сървъра и ще получите предложение обекта да бъде изтрит локално",
"delete_action_prompt": "{count} са изтрити",
@@ -970,7 +972,7 @@
"downloading_media": "Изтегляне на медия",
"drop_files_to_upload": "Пуснете файловете, за да ги качите",
"duplicates": "Дубликати",
"duplicates_description": "Изберете всяка група, като посочите кои, ако има такива, са дубликати",
"duplicates_description": "Изберете всяка група, като посочите кои, ако има такива, са дубликати.",
"duration": "Продължителност",
"edit": "Редактиране",
"edit_album": "Редактиране на албум",
@@ -1387,9 +1389,11 @@
"library_page_sort_title": "Заглавие на албума",
"licenses": "Лицензи",
"light": "Светло",
"light_theme": "Премини към светла тема",
"like": "Харесайте",
"like_deleted": "Като изтрит",
"link_motion_video": "Линк към видео",
"link_to_docs": "За повече информация вижте <link>документацията</link>.",
"link_to_oauth": "Линк към OAuth",
"linked_oauth_account": "Свързан OAuth акаунт",
"list": "Лист",
@@ -1651,13 +1655,14 @@
"only_favorites": "Само любими",
"open": "Отвори",
"open_calendar": "Отвори календар",
"open_in_browser": "Отвори в браузър",
"open_in_map_view": "Отвори изглед на карта",
"open_in_openstreetmap": "Отвори в OpenStreetMap",
"open_the_search_filters": "Отвари филтрите за търсене",
"options": "Настройки",
"or": "или",
"organize_into_albums": "Organitzar per àlbums",
"organize_into_albums_description": "Posar les fotos existents dins dels àlbums fent servir la configuració de sincronització",
"organize_into_albums": "Подредете в албуми",
"organize_into_albums_description": "Добавете наличните снимки в албуми, като използвате текущите настройки за синхронизиране",
"organize_your_library": "Организиране на вашата библиотека",
"original": "оригинал",
"other": "Други",
@@ -1805,7 +1810,7 @@
"purchase_server_description_2": "Статус на поддръжник",
"purchase_server_title": "Сървър",
"purchase_settings_server_activated": "Продуктовият ключ на сървъра се управлява от администратора",
"query_asset_id": "Buscar item per ID",
"query_asset_id": "Търсене на елемент по ID",
"queue_status": "В опашка {count} от {total}",
"rate_asset": "Задаване на рейтинг",
"rating": "Оценка със звезди",
@@ -2212,6 +2217,7 @@
"tag": "Таг",
"tag_assets": "Тагни елементи",
"tag_created": "Създаден етикет: {tag}",
"tag_face": "Отбележи лице",
"tag_feature_description": "Разглеждане на снимки и видеоклипове, групирани по теми с логически тагове",
"tag_not_found_question": "Не можете да намерите етикет? <link>Създайте нов етикет.</link>",
"tag_people": "Отбележи Хора",
@@ -2393,6 +2399,7 @@
"viewer_remove_from_stack": "Премахване от опашката",
"viewer_stack_use_as_main_asset": "Използвай като основен",
"viewer_unstack": "Премахни от опашката",
"visibility": "Видимост",
"visibility_changed": "Видимостта е променена за {count, plural, one {# човек} other {# човека}}",
"visual": "Визуален",
"visual_builder": "Визуален конструктор",

View File

@@ -231,6 +231,8 @@
"metadata_settings_description": "মেটাডেটা সেটিংস পরিচালনা করুন (Manage metadata settings)",
"migration_job": "মাইগ্রেশন (Migration)",
"migration_job_description": "অ্যাসেট এবং ফেস থাম্বনেইলগুলোকে সর্বশেষ ফোল্ডার স্ট্রাকচারে মাইগ্রেট করুন। (Migrate thumbnails for assets and faces to the latest folder structure)",
"nightly_tasks_cluster_faces_setting_description": "নতুন শনাক্ত হওয়া মুখগুলিতে ফেসিয়াল রিকগনিশন চালান",
"nightly_tasks_cluster_new_faces_setting": "নতুন মুখগুলোর গুচ্ছ",
"nightly_tasks_database_cleanup_setting": "ডেটাবেস ক্লিনআপ টাস্কসমূহ (Database cleanup tasks)",
"nightly_tasks_database_cleanup_setting_description": "ডেটাবেস থেকে পুরোনো এবং মেয়াদোত্তীর্ণ ডেটা মুছে ফেলুন",
"nightly_tasks_generate_memories_setting": "মেমোরিজ তৈরি করুন (Generate memories)",
@@ -257,6 +259,20 @@
"notification_email_secure": "SMTPS (স্মার্ট মেইল ট্রান্সফার প্রোটোকল সিকিউর)",
"notification_email_secure_description": "SMTPS (SMTP over TLS) ব্যবহার করুন",
"notification_email_sent_test_email_button": "টেস্ট ইমেল পাঠান এবং সেভ করুন",
"notification_email_setting_description": "ইমেল নোটিফিকেশন পাঠানোর সেটিংস",
"notification_email_test_email": "পরীক্ষামূলক ইমেইল পাঠান",
"notification_email_test_email_failed": "পরীক্ষামূলক ইমেল পাঠানো সম্ভব হয়নি, আপনার সেটিংস যাচাই করুন",
"notification_email_test_email_sent": "{email}-এ একটি পরীক্ষামূলক ইমেল পাঠানো হয়েছে। অনুগ্রহ করে আপনার ইনবক্স দেখুন।",
"notification_email_username_description": "ইমেল সার্ভারে ভেরিফিকেসনের জন্য ব্যবহৃত ইউজারনেম",
"notification_enable_email_notifications": "ইমেল নোটিফিকেসন সক্রিয় করুন",
"notification_settings": "নোটিফিকেসন সেটিংস",
"notification_settings_description": "ইমেইল সহ নোটিফিকেশন সেটিংস পরিচালনা করুন",
"oauth_auto_launch": "অটো লঞ্চ",
"oauth_auto_launch_description": "লগইন পেজে প্রবেশ করার সাথে সাথে OAuth লগইন প্রক্রিয়াটি স্বয়ংক্রিয়ভাবে শুরু করুন",
"oauth_auto_register": "সয়ংক্রিয়ভাবে রেজিস্টার করুন",
"oauth_auto_register_description": "OAuth দিয়ে সাইন ইন করার পর নতুন ব্যবহারকারীদের স্বয়ংক্রিয়ভাবে নিবন্ধন করুন",
"oauth_button_text": "বাটন টেক্সট",
"oauth_client_secret_description": "গোপনীয় ক্লায়েন্টের জন্য প্রয়োজন, অথবা যদি পাবলিক ক্লায়েন্টের জন্য PKCE (Proof Key for Code Exchange) সমর্থিত না হয়।",
"oauth_enable_description": "OAuth-এর মাধ্যমে লগইন করুন",
"oauth_mobile_redirect_uri": "মোবাইল রিডাইরেক্ট ইউআরআই (URI)",
"oauth_mobile_redirect_uri_override": "মোবাইল রিডাইরেক্ট ইউআরআই (URI) ওভাররাইড",
@@ -323,6 +339,20 @@
"storage_template_settings": "স্টোরেজ টেমপ্লেট (Storage Template)",
"storage_template_settings_description": "আপলোড করা অ্যাসেটের ফোল্ডার স্ট্রাকচার এবং ফাইল নেম ম্যানেজ করুন",
"storage_template_user_label": "<code>{label}</code> হলো ব্যবহারকারীর স্টোরেজ লেবেল (Storage Label)",
"system_settings": "সিস্টেম সেটিংস",
"tag_cleanup_job": "ট্যাগ মুছে ফেলা",
"template_email_available_tags": "আপনি আপনার টেমপ্লেটে নিম্নলিখিত ভেরিয়েবলগুলো ব্যবহার করতে পারেন: {tags}",
"template_email_if_empty": "টেমপ্লেটটি খালি থাকলে ডিফল্ট ইমেল ব্যবহার করা হবে।",
"template_email_invite_album": "ইনভাইট অ্যালবাম টেমপ্লেট",
"template_email_preview": "প্রিভিউ",
"template_email_settings": "ইমেইল টেমপ্লেট",
"template_email_update_album": "অ্যালবাম টেমপ্লেট আপডেট করুন",
"template_email_welcome": "স্বাগতম ইমেইল টেমপ্লেট",
"template_settings": "নোটিফিকেশন টেমপ্লেট",
"template_settings_description": "নোটিফিকেশনের জন্য কাস্টম টেমপ্লেট পরিচালনা করুন",
"theme_custom_css_settings": "কাস্টম CSS",
"theme_custom_css_settings_description": "ক্যাসকেডিং স্টাইল শীট ব্যবহার করে Immich এর ডিজাইন কাস্টমাইজ করা যায়।",
"theme_settings": "থীম সেটিংস",
"theme_settings_description": "ইমিচ (Immich) ওয়েব ইন্টারফেসের কাস্টমাইজেশন ম্যানেজ করুন",
"thumbnail_generation_job": "থাম্বনেইল তৈরি করুন (Generate Thumbnails)",
"thumbnail_generation_job_description": "প্রতিটি অ্যাসেটের জন্য বড়, ছোট এবং ব্লার (অস্পষ্ট) থাম্বনেইল তৈরি করুন, সেই সাথে প্রতিটি ব্যক্তির জন্যও থাম্বনেইল তৈরি করুন।",
@@ -334,8 +364,281 @@
"transcoding_acceleration_vaapi": "VA-API (ভিডিও অ্যাক্সিলারেশন এপিআই)",
"transcoding_accepted_audio_codecs": "গ্রহণযোগ্য অডিও কোডেকসমূহ (Accepted audio codecs)",
"transcoding_accepted_audio_codecs_description": "কোন অডিও কোডেকগুলো ট্রানসকোড করার প্রয়োজন নেই তা নির্বাচন করুন। এটি শুধুমাত্র নির্দিষ্ট ট্রানসকোড পলিসির (transcode policies) জন্য ব্যবহৃত হয়।",
"transcoding_accepted_containers": "গ্রহণযোগ্য কন্টেইনারসমূহ (Accepted containers)"
"transcoding_accepted_containers": "গ্রহণযোগ্য কন্টেইনারসমূহ (Accepted containers)",
"transcoding_accepted_containers_description": "কোন কন্টেইনার ফরম্যাটগুলোকে MP4-এ রিমুক্স করার প্রয়োজন নেই তা নির্বাচন করুন। শুধুমাত্র নির্দিষ্ট ট্রান্সকোড পলিসির জন্য ব্যবহৃত হয়।",
"transcoding_accepted_video_codecs": "সমর্থিত ভিডিও কোডেকগুলো",
"transcoding_accepted_video_codecs_description": "কোন ভিডিও কোডেকগুলো ট্রান্সকোড করার প্রয়োজন নেই তা নির্বাচন করুন। শুধুমাত্র নির্দিষ্ট ট্রান্সকোড নীতির জন্য ব্যবহৃত হয়।",
"transcoding_advanced_options_description": "বেশিরভাগ ব্যবহারকারীর পরিবর্তন করার প্রয়োজন নেই এমন অপশনসমূহ",
"transcoding_audio_codec": "অডিও কোডেক",
"transcoding_audio_codec_description": "Opus সর্বোচ্চ মানের অপশন, তবে পুরোনো ডিভাইস বা সফটওয়্যারের সাথে এর সামঞ্জস্য কম।",
"transcoding_bitrate_description": "সর্বোচ্চ বিটরেটের চেয়ে বেশি বা সমর্থিত ফরম্যাটে নয় এমন ভিডিও",
"transcoding_codecs_learn_more": "এখানে ব্যবহৃত পরিভাষা সম্পর্কে আরও জানতে FFmpeg ডকুমেন্টেশন দেখুন, <h264-link>H.264 কোডেক</h264-link>, <hevc-link>HEVC কোডেক</hevc-link> এবং <vp9-link>VP9 কোডেক</vp9-link>।",
"transcoding_constant_quality_mode": "নির্দিষ্ট মান মোড",
"transcoding_constant_quality_mode_description": "ICQ, CQP-এর চেয়ে ভালো মান দেয়, কিন্তু সব হার্ডওয়্যার অ্যাক্সেলারেশন ডিভাইসে কাজ করে না। এই অপশন চালু থাকলে কোয়ালিটি-ভিত্তিক এনকোডিংয়ে এটি প্রাধান্য পাবে। NVENC এটি সমর্থন করে না, তাই এটি উপেক্ষা করা হবে।",
"transcoding_constant_rate_factor": "নির্দিষ্ট রেট ফ্যাক্টর (-crf)",
"transcoding_constant_rate_factor_description": "ভিডিওর গুণমানের স্তর। সাধারণ মানগুলো হলো H.264-এর জন্য ২৩, HEVC-এর জন্য ২৮, VP9-এর জন্য ৩১ এবং AV1-এর জন্য ৩৫। মান যত কম হবে, ভিডিওর গুণমান তত উন্নত হবে, তবে ফাইলের আকার তত বড় হবে।",
"transcoding_disabled_description": "কোনো ভিডিও ট্রান্সকোড করবেন না, এতে কিছু ক্লায়েন্টে প্লেব্যাক নষ্ট হতে পারে",
"transcoding_encoding_options": "এনকোডিং এর অপশনগুলি",
"transcoding_encoding_options_description": "এনকোড করা ভিডিওগুলির জন্য কোডেক, রেজোলিউশন, কোয়ালিটি এবং অন্যান্য অপশন সেট করুন",
"transcoding_hardware_acceleration": "হার্ডওয়্যার এক্সিলারেসন (Acceleration)",
"transcoding_hardware_acceleration_description": "পরীক্ষামূলক: দ্রুততর ট্রান্সকোডিং, কিন্তু একই বিটরেটে গুণমান হ্রাস পেতে পারে",
"transcoding_hardware_decoding": "হার্ডওয়্যার ডিকোডিং",
"transcoding_hardware_decoding_setting_description": "শুধু এনকোডিং অ্যাক্সিলারেশন করার পরিবর্তে এটি এন্ড-টু-এন্ড অ্যাক্সিলারেশন সক্ষম করে। সব ভিডিওতে কাজ নাও করতে পারে।",
"transcoding_max_b_frames": "সর্বোচ্চ বি-ফ্রেম (B-frames)",
"transcoding_max_b_frames_description": "মান যত বেশি হবে, কমপ্রেশন তত ভালো হবে কিন্তু এনকোডিং ধীরে চলবে। পুরোনো ডিভাইসে হার্ডওয়্যার অ্যাক্সেলারেশন কাজ নাও করতে পারে। দিলে B-frames বন্ধ থাকবে, -১ দিলে এটি নিজে থেকেই ঠিক হবে।",
"transcoding_max_bitrate": "সর্বোচ্চ বিটরেট",
"transcoding_max_bitrate_description": "সর্বোচ্চ বিটরেট নির্ধারণ করলে ফাইলের আকার আরও অনুমানযোগ্য হতে পারে, তবে এর ফলে কোয়ালিটির কিছুটা অবনতি ঘটে। 720p-তে, VP9 বা HEVC-এর জন্য সাধারণ মান হলো 2600 kbit/s, অথবা H.264-এর জন্য 4500 kbit/s।এর মান 0 সেট করা হলে এটি বন্ধ থাকে। যখন কোনো একক নির্দিষ্ট করা থাকে না, তখন k (kbit/s-এর জন্য) ধরে নেওয়া হয়; তাই 5000, 5000k, এবং 5M (Mbit/s-এর জন্য) সমতুল্য।",
"transcoding_max_keyframe_interval": "সর্বোচ্চ কীফ্রেম ব্যবধান",
"transcoding_max_keyframe_interval_description": "কীফ্রেমের মধ্যে সর্বোচ্চ ফ্রেম দূরত্ব নির্ধারণ করে। মান কম হলে কমপ্রেশন দক্ষতা কমে, তবে ভিডিওতে খুঁজে বের করা দ্রুত হয় এবং দ্রুত চলমান দৃশ্যে মানও কিছুটা ভালো হতে পারে। দিলে এই মান স্বয়ংক্রিয়ভাবে নির্ধারিত হয়।",
"transcoding_optimal_description": "নির্দিষ্ট রেজোলিউশনের চেয়ে বড় বা সমর্থিত ফরম্যাটে নয় এমন ভিডিও",
"transcoding_policy": "ট্রান্সকোড নীতি",
"transcoding_policy_description": "ভিডিও কখন ট্রান্সকোড করা হবে তা সেট করুন",
"transcoding_preferred_hardware_device": "পছন্দের হার্ডওয়্যার ডিভাইস",
"transcoding_preferred_hardware_device_description": "শুধুমাত্র VAAPI এবং QSV-এর ক্ষেত্রে প্রযোজ্য। হার্ডওয়্যার ট্রান্সকোডিংয়ের জন্য ব্যবহৃত dri নোড নির্ধারণ করে।",
"transcoding_preset_preset": "প্রিসেট (-preset)",
"transcoding_preset_preset_description": "কম্প্রেশন স্পিড। ধীরগতির প্রিসেটগুলো ছোট ফাইল তৈরি করে এবং একটি নির্দিষ্ট বিটরেট লক্ষ্য করার সময় গুণমান বৃদ্ধি করে। VP9 'faster'-এর চেয়ে বেশি গতি উপেক্ষা করে।",
"transcoding_reference_frames": "রেফারেন্স ফ্রেম",
"transcoding_reference_frames_description": "একটি ফ্রেম কম্প্রেস করার সময় কতটি ফ্রেমকে রেফারেন্স হিসেবে নেওয়া হবে। মান যত বেশি হবে, কমপ্রেশন দক্ষতা তত ভালো হবে, তবে এনকোডিং ধীর হবে। দিলে এই মান স্বয়ংক্রিয়ভাবে নির্ধারিত হবে।",
"transcoding_required_description": "শুধুমাত্র অনুমোদিত ফরম্যাটে নেই এমন ভিডিও",
"transcoding_settings": "ভিডিও ট্রান্সকোডিং সেটিংস",
"transcoding_settings_description": "নির্ধারণ করুন কোন ভিডিওগুলোকে ট্রান্সকোড করতে হবে এবং কিভাবে প্রক্রিয়া করতে হবে",
"transcoding_target_resolution": "টার্গেট রেজোলিউশন",
"transcoding_target_resolution_description": "উচ্চ রেজোলিউশন বেশি বিস্তারিত রাখে, কিন্তু এনকোডিং ধীরে হয়, ফাইল বড় হয়, এবং অ্যাপ ধীর প্রতিক্রিয়া করতে পারে।",
"transcoding_temporal_aq": "টেম্পোরাল AQ",
"transcoding_temporal_aq_description": "শুধুমাত্র NVENC-এর ক্ষেত্রে প্রযোজ্য। টেম্পোরাল অ্যাডাপটিভ কোয়ান্টাইজেশন (Adaptive Quantization) উচ্চ-বিস্তারিত ও স্বল্প-গতির দৃশ্যের মান বৃদ্ধি করে। পুরোনো ডিভাইসগুলোর সাথে সামঞ্জস্যপূর্ণ নাও হতে পারে।",
"transcoding_threads": "থ্রেড",
"transcoding_threads_description": "উচ্চ মানে এনকোডিং দ্রুত হয়, কিন্তু সার্ভার কম কাজ করতে পারে। CPU কোরের বেশি মান দেওয়া উচিত নয়। দিলে সর্বাধিক ব্যবহার হবে।",
"transcoding_tone_mapping": "টোন-ম্যাপিং",
"transcoding_tone_mapping_description": "এইচডিআর (HDR) ভিডিওকে এসডিআর (SDR)-এ রূপান্তর করার সময় এর বাহ্যিক রূপ অক্ষুণ্ণ রাখার চেষ্টা করা হয়। প্রতিটি অ্যালগরিদম রঙ, ডিটেইল এবং উজ্জ্বলতার জন্য ভিন্ন ভিন্ন সমন্বয় করে। হেবল ডিটেইল, মোবিয়াস রঙ এবং রাইনহার্ড উজ্জ্বলতা অক্ষুণ্ণ রাখে।",
"transcoding_transcode_policy": "ট্রান্সকোড নীতি",
"transcoding_transcode_policy_description": "কখন একটি ভিডিও ট্রান্সকোড করা হবে তার নীতিমালা। HDR ভিডিও এবং YUV 4:2:0 ব্যতীত অন্য পিক্সেল ফরম্যাটের ভিডিও সর্বদা ট্রান্সকোড করা হবে (যদি না ট্রান্সকোডিং বন্ধ করা থাকে)।",
"transcoding_two_pass_encoding": "টু-পাস এনকোডিং",
"transcoding_two_pass_encoding_setting_description": "আরও উন্নত মানের এনকোডেড ভিডিও তৈরি করতে দুই ধাপে ট্রান্সকোড করুন। যখন সর্বোচ্চ বিটরেট সক্রিয় করা হয় (যা H.264 এবং HEVC-এর সাথে কাজ করার জন্য আবশ্যক), তখন এই মোডটি সর্বোচ্চ বিটরেটের উপর ভিত্তি করে একটি বিটরেট রেঞ্জ ব্যবহার করে এবং CRF উপেক্ষা করে। VP9-এর ক্ষেত্রে, সর্বোচ্চ বিটরেট নিষ্ক্রিয় থাকলেও CRF ব্যবহার করা যেতে পারে।",
"transcoding_video_codec": "ভিডিও কোডেক",
"transcoding_video_codec_description": "VP9 উচ্চ কর্মদক্ষতা সম্পন্ন এবং ওয়েবের সাথে সামঞ্জস্যপূর্ণ, কিন্তু ট্রান্সকোড করতে বেশি সময় লাগে। HEVC-এর কর্মক্ষমতাও প্রায় একই রকম, কিন্তু এর ওয়েব সামঞ্জস্যতা কম। H.264 ব্যাপকভাবে সামঞ্জস্যপূর্ণ এবং দ্রুত ট্রান্সকোড করা যায়, কিন্তু এটি অনেক বড় ফাইল তৈরি করে। AV1 সবচেয়ে কর্মদক্ষ কোডেক, কিন্তু পুরোনো ডিভাইসগুলোতে এর সমর্থন নেই।",
"trash_enabled_description": "ট্র্যাশ ফিচার চালু করুন",
"trash_number_of_days": "দিনের সংখ্যা",
"trash_number_of_days_description": "ট্র্যাশে থাকা অ্যাসেটগুলো স্থায়ীভাবে মুছে ফেলার আগে রাখার দিন সংখ্যা",
"trash_settings": "ট্র্যাশ সেটিংস",
"trash_settings_description": "ট্র্যাশ সেটিংস পরিচালনা করুন",
"unlink_all_oauth_accounts": "সকল OAuth অ্যাকাউন্ট আনলিঙ্ক করুন",
"unlink_all_oauth_accounts_description": "নতুন প্রোভাইডারে মাইগ্রেট করার আগে সব OAuth অ্যাকাউন্ট আনলিঙ্ক করুন।",
"unlink_all_oauth_accounts_prompt": "আপনি কি সব OAuth অ্যাকাউন্ট আনলিঙ্ক করতে নিশ্চিত? এটি প্রতিটি ব্যবহারকারীর OAuth আইডি রিসেট করে দেবে এবং এটি আর পূর্বাবস্থায় ফেরানো যাবে না।",
"user_cleanup_job": "ইউজার ক্লিনআপ",
"user_delete_delay": "<b>{user}</b>-এর অ্যাকাউন্ট এবং অ্যাসেট {delay, plural, one {# day} other {# days}} পর স্থায়ীভাবে মুছে ফেলার জন্য নির্ধারিত হবে।",
"user_delete_delay_settings": "মুছে ফেলার সময় বিলম্ব",
"user_delete_delay_settings_description": "অ্যাকাউন্ট এবং অ্যাসেট মুছে ফেলার পর কত দিনের মধ্যে স্থায়ীভাবে মুছে ফেলা হবে। ব্যবহারকারী মুছে ফেলার কাজ মধ্যরাতে চালানো হয় এবং দেখা হয় কোন ব্যবহারকারী স্থায়ীভাবে মুছে ফেলার জন্য প্রস্তুত। এই সেটিং পরিবর্তন করলে পরবর্তী এক্সিকিউশনের সময় তা প্রযোজ্য হবে।",
"user_delete_immediately": "<b>{user}</b>-এর অ্যাকাউন্ট এবং অ্যাসেট স্থায়ীভাবে মুছে ফেলার জন্য <b>immediately</b> কিউতে অন্তর্ভুক্ত করা হবে।",
"user_delete_immediately_checkbox": "ব্যবহারকারী ও অ্যাসেট তৎক্ষণাৎ মুছে ফেলার জন্য কিউ",
"user_details": "ব্যবহারকারী তথ্য",
"user_management": "ব্যবহারকারী ম্যানেজমেন্ট",
"user_password_has_been_reset": "ব্যবহারকারীর পাসওয়ার্ড রিসেট করা হয়েছে:",
"user_password_reset_description": "দয়া করে ব্যবহারকারীর জন্য সাময়িক পাসওয়ার্ড দিন এবং জানিয়ে দিন যে তারা পরবর্তী লগইনে পাসওয়ার্ড পরিবর্তন করবেন।",
"user_restore_description": "<b>{user}</b> এর অ্যাকাউন্ট পুনরুদ্ধার করা হবে।",
"user_restore_scheduled_removal": "ব্যবহারকারী পুনরুদ্ধার করুন - মুছে ফেলার জন্য নির্ধারিত তারিখ:{date, date, long}",
"user_settings": "ব্যবহারকারী সেটিংস",
"user_settings_description": "ব্যবহারকারী সেটিংস ম্যানেজ করুন",
"user_successfully_removed": "সফলভাবে ইউজার {email}-কে সরিয়ে দেওয়া হয়েছে।",
"version_check_enabled_description": "ভার্সন যাচাই চালু করুন",
"version_check_implications": "ভার্সন চেক ফিচারটি github.com-এর সঙ্গে নিয়মিত সংযোগের ওপর নির্ভরশীল",
"version_check_settings": "ভার্সন যাচাই",
"version_check_settings_description": "নতুন ভার্সনের নোটিফিকেশন চালু/বন্ধ করুন",
"video_conversion_job": "ভিডিও ট্রান্সকোড করুন",
"video_conversion_job_description": "ব্রাউজার এবং ডিভাইসে আরও ভালোভাবে চলার জন্য ভিডিও ট্রান্সকোড করুন"
},
"admin_email": "অ্যাডমিনের ইমেইল",
"admin_password": "অ্যাডমিনের পাসওয়ার্ড",
"administration": "অ্যাডমিন",
"advanced": "অ্যাডভান্সড",
"age_months": "বয়স {months, plural, one {# month} other {# months}}",
"age_year_months": "বয়স ১ বছর, {months, plural, one {# month} other {# months}}",
"album_added": "অ্যালবাম যুক্ত করা হয়েছে",
"album_added_notification_setting_description": "শেয়ার করা অ্যালবামে যুক্ত হলে ইমেইল নোটিফিকেশন পান",
"album_cover_updated": "অ্যালবামের কভার আপডেট হয়েছে",
"album_delete_confirmation": "আপনি কি সত্যিই অ্যালবাম {album} মুছে ফেলতে চান?",
"album_delete_confirmation_description": "অ্যালবামটি শেয়ার করা থাকলেও অন্য ব্যবহারকারীরা আর এটি অ্যাক্সেস করতে পারবেন না।",
"album_info_updated": "অ্যালবামের তথ্য আপডেট করা হয়েছে",
"album_leave": "অ্যালবাম থেকে বেরিয়ে যেতে চান ?",
"album_leave_confirmation": "আপনি কি নিশ্চিত যে আপনি {album} ছেড়ে যেতে চান?",
"album_name": "অ্যালবামের নাম",
"album_options": "অ্যালবামের অপশনসমূহ",
"album_remove_user": "ব্যবহারকারী সরাতে চান?",
"album_remove_user_confirmation": "আপনি কি নিশ্চিত যে আপনি {user}-কে সরাতে চান?",
"album_share_no_users": "এই অ্যালবামটি সব ব্যবহারকারীর সঙ্গে শেয়ার করা হয়েছে, বা শেয়ার করার জন্য কোনো ব্যবহারকারী নেই।",
"album_updated": "অ্যালবাম আপডেট করা হয়েছে",
"album_updated_setting_description": "নতুন অ্যাসেট যুক্ত হলে শেয়ার করা অ্যালবামের জন্য ইমেইল নোটিফিকেশন পান",
"album_user_left": "বাম {album}",
"album_user_removed": "{user} কে সরানো হয়েছে",
"album_with_link_access": "লিঙ্ক থাকা যে কেউ এই অ্যালবামের ছবি ও মানুষজনকে দেখতে পারবে।",
"albums": "অ্যালবামসমূহ",
"all": "সব",
"all_albums": "সকল অ্যালবামসমূহ",
"all_people": "সব ব্যবহারকারী",
"all_videos": "সব ভিডিও",
"allow_dark_mode": "ডার্ক মোড চালু করুন",
"allow_edits": "এডিটের অনুমতি দিন",
"allow_public_user_to_download": "সাধারণ ব্যবহারকারী ডাউনলোড করতে পারবে",
"allow_public_user_to_upload": "সাধারণ ব্যবহারকারী আপলোড করতে পারবে",
"anti_clockwise": "বিপরীত দিক",
"api_key": "API কী",
"api_key_description": "এই মান একবারই দেখানো হবে। উইন্ডো বন্ধ করার আগে অবশ্যই এটি কপি করুন।",
"api_key_empty": "API কী-এর নাম খালি রাখা যাবে না",
"api_keys": "API কী সমূহ",
"app_settings": "অ্যাপ সেটিংস",
"appears_in": "v1.106.4 থেকে, অ্যাসেট সাইডবারে ব্যবহার হয় [albums]-এ উপস্থিত’ বোঝাতে",
"archive": "আর্কাইভ",
"archive_or_unarchive_photo": "ফটো আর্কাইভ অথবা আনআর্কাইভ করুন",
"archive_size": "আর্কাইভ সাইজ",
"archive_size_description": "ডাউনলোডের আর্কাইভ সাইজ নির্ধারণ করুন (GiB)",
"are_these_the_same_person": "এরা কি একই ব্যক্তি?",
"are_you_sure_to_do_this": "আপনি কি নিশ্চিত যে আপনি এটি করতে চান?",
"asset_added_to_album": "অ্যালবামে যুক্ত করা হয়েছে",
"asset_adding_to_album": "অ্যালবামে যুক্ত করা হচ্ছে…",
"asset_description_updated": "অ্যাসেটের বিবরণ আপডেট করা হয়েছে",
"asset_filename_is_offline": "{filename} অ্যাসেটটি বর্তমানে অফলাইন",
"asset_has_unassigned_faces": "অ্যাসেটটির কিছু মুখ অনির্ধারিত ফেস রয়েছে",
"asset_hashing": "হ্যাশিং চলছে…",
"asset_offline": "অ্যাসেট বর্তমানে অফলাইন",
"asset_offline_description": "এই এক্সটার্নাল অ্যাসেটটি এখন ডিস্কে নেই। সহায়তার জন্য Immich অ্যাডমিনিস্ট্রেটরের সাথে যোগাযোগ করুন।",
"asset_skipped": "এড়ানো হয়েছে",
"asset_skipped_in_trash": "ট্র্যাশে",
"asset_uploaded": "আপলোড সম্পন্ন",
"asset_uploading": "আপলোড চলছে…",
"assets": "অ্যাসেটসমূহ",
"assets_added_to_album_count": "অ্যালবামে {count, plural, one {# asset} other {# assets}} যুক্ত করা হয়েছে",
"assets_moved_to_trash_count": "{count, plural, one {# asset} other {# assets}} ট্র্যাশে সরানো হয়েছে",
"assets_permanently_deleted_count": "{count, plural, one {# asset} other {# assets}} স্থায়ীভাবে মুছে ফেলা হয়েছে",
"assets_removed_count": "{count, plural, one {# asset} other {# assets}} সরানো হয়েছে",
"assets_restore_confirmation": "আপনি কি সত্যিই আপনার সব ট্র্যাশ করা অ্যাসেট পুনরুদ্ধার করতে চান? এটি পূর্বাবস্থায় ফিরানো যাবে না। তবে অফলাইন অ্যাসেট এইভাবে পুনরুদ্ধার হবে না।",
"assets_restored_count": "{count, plural, one {# asset} other {# assets}} পুনরুদ্ধার করা হয়েছে",
"assets_trashed_count": "{count, plural, one {# asset} other {# assets}} ট্র্যাশে পাঠানো হয়েছে",
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} আগেই অ্যালবামে যুক্ত ছিল",
"authorized_devices": "অনুমোদিত ডিভাইস",
"back": "ফিরে যান",
"back_close_deselect": "ফিরে যান, বন্ধ করুন বা নির্বাচন বাতিল করুন",
"backward": "পিছনে",
"birthdate_saved": "জন্ম তারিখ সংরক্ষণ সম্পন্ন",
"birthdate_set_description": "একটি ছবির সময়ে ব্যক্তির বয়স গণনার জন্য জন্ম তারিখ ব্যবহার করা হয়।",
"blurred_background": "ব্লারড ব্যাকগ্রাউন্ড",
"bugs_and_feature_requests": "বাগ ও ফিচার রিকোয়েস্ট",
"build": "বিল্ড",
"build_image": "বিল্ড ইমেজ",
"bulk_delete_duplicates_confirmation": "আপনি কি সত্যিই {count, plural, one {# duplicate asset} other {# duplicate assets}} একসাথে মুছে ফেলতে চান? প্রতিটি গ্রুপের সবচেয়ে বড় অ্যাসেট রাখা হবে, বাকিগুলো স্থায়ীভাবে মুছে যাবে। এটি পূর্বাবস্থায় ফিরানো যাবে না!",
"bulk_keep_duplicates_confirmation": "আপনি কি সত্যিই {count, plural, one {# duplicate asset} other {# duplicate assets}} রাখতে চান? সব ডুপ্লিকেট গ্রুপ ঠিক করা হবে, কোনো কিছু মুছে ফেলা হবে না।",
"bulk_trash_duplicates_confirmation": "আপনি কি সত্যিই {count, plural, one {# duplicate asset} other {# duplicate assets}} একসাথে ট্র্যাশ করতে চান? প্রতিটি গ্রুপের সবচেয়ে বড় অ্যাসেট রাখা হবে, বাকিগুলো ট্র্যাশে যাবে।",
"buy": "Immich ক্রয় করুন",
"camera": "ক্যামেরা",
"camera_brand": "ক্যামেরা ব্র্যান্ড",
"camera_model": "ক্যামেরা মডেল",
"cancel": "বাতিল",
"cancel_search": "সার্চ বন্ধ করুন",
"cannot_merge_people": "ব্যক্তিদের একত্র করা সম্ভব নয়",
"cannot_undo_this_action": "এই কাজ পূর্বাবস্থায় ফেরানো যাবে না!",
"cannot_update_the_description": "বিবরণ পরিবর্তন সম্ভব নয়",
"change_date": "তারিখ পরিবর্তন",
"change_expiration_time": "মেয়াদ শেষের সময় পরিবর্তন",
"change_location": "লোকেশন পরিবর্তন",
"change_name": "নাম পরিবর্তন করুন",
"change_name_successfully": "নাম সফলভাবে পরিবর্তন হয়েছে",
"change_password": "পাসওয়ার্ড পরিবর্তন করুন",
"change_password_description": "আপনি হয়তো প্রথমবার লগইন করছেন বা পাসওয়ার্ড পরিবর্তনের অনুরোধ করেছেন। নিচে নতুন পাসওয়ার্ড দিন।",
"change_your_password": "আপনার পাসওয়ার্ড পরিবর্তন করুন",
"changed_visibility_successfully": "ভিসিবিলিটি সফলভাবে পরিবর্তন হয়েছে",
"check_logs": "লগ দেখুন",
"choose_matching_people_to_merge": "একত্র করার জন্য মিল থাকা ব্যক্তিদের নির্বাচন করুন",
"city": "শহর",
"clear": "মুছুন",
"clear_all": "সব মুছুন",
"clear_all_recent_searches": "সাম্প্রতিক সব অনুসন্ধান পরিষ্কার করুন",
"clear_message": "মেসেজ পরিষ্কার করুন",
"clear_value": "ভ্যালু মুছুন",
"clockwise": "ঘড়ির কাঁটার দিকে",
"close": "বন্ধ",
"collapse": "সংকুচিত করুন",
"collapse_all": "সব সংকুচিত",
"color": "রং",
"color_theme": "কালার থিম",
"comment_deleted": "মন্তব্য মুছে ফেলা হয়েছে",
"comment_options": "মন্তব্য অপশন",
"comments_and_likes": "মন্তব্য ও লাইক",
"comments_are_disabled": "মন্তব্য বন্ধ করা হয়েছে",
"confirm": "নিশ্চিত",
"confirm_admin_password": "অ্যাডমিন পাসওয়ার্ড পুনরায় লিখুন",
"confirm_delete_shared_link": "আপনি কি নিশ্চিত যে আপনি এই শেয়ার করা লিঙ্কটি মুছে ফেলতে চান?",
"confirm_keep_this_delete_others": "স্ট্যাকের এই অ্যাসেট ছাড়া সব অন্যান্য অ্যাসেট মুছে যাবে। আপনি কি নিশ্চিত যে আপনি চালিয়ে যেতে চান?",
"confirm_password": "পাসওয়ার্ড পুনরায় লিখুন",
"contain": "মাপমত",
"context": "প্রসঙ্গ",
"continue": "এগিয়ে যান",
"copied_image_to_clipboard": "ছবি ক্লিপবোর্ডে কপি হয়েছে।",
"copied_to_clipboard": "ক্লিপবোর্ডে কপি হয়েছে!",
"copy_error": "Error-টি কপি করুন",
"copy_file_path": "ফাইল পাথ কপি",
"copy_image": "ছবি কপি",
"copy_link": "লিঙ্ক কপি",
"copy_link_to_clipboard": "ক্লিপবোর্ডে লিঙ্ক কপি করুন",
"copy_password": "পাসওয়ার্ড কপি করুন",
"copy_to_clipboard": "ক্লিপবোর্ডে কপি করুন",
"country": "দেশ",
"cover": "সম্পূর্ণভাবে",
"covers": "কভারস",
"create": "তৈরি করুন",
"create_album": "অ্যালবাম তৈরি",
"create_library": "লাইব্রেরি তৈরি",
"create_link": "লিঙ্ক তৈরি",
"create_link_to_share": "শেয়ার লিঙ্ক তৈরি",
"create_link_to_share_description": "লিঙ্কের মাধ্যমে সবাই নির্বাচিত ছবি দেখতে পারবে",
"create_new_person": "নতুন ব্যক্তি যোগ করুন",
"create_new_person_hint": "নির্বাচিত অ্যাসেট নতুন ব্যক্তির সঙ্গে যুক্ত করুন",
"create_new_user": "নতুন ব্যবহারকারী যোগ করুন",
"create_tag": "ট্যাগ তৈরি",
"create_tag_description": "নতুন ট্যাগ তৈরি করুন। নেস্টেড ট্যাগের ক্ষেত্রে সম্পূর্ণ পাথ - ফরওয়ার্ড স্ল্যাশসহ দিন।",
"create_user": "ব্যবহারকারী যোগ করুন",
"created": "যোগ করা হয়েছে",
"current_device": "চলতি ডিভাইস",
"custom_locale": "কাস্টম লোকেল",
"custom_locale_description": "নির্বাচিত ভাষা এবং অঞ্চলের ভিত্তিতে তারিখ, সময় এবং সংখ্যা ফরম্যাট করুন",
"dark": "ডার্ক",
"date_after": "এর পরের তারিখ",
"date_and_time": "তারিখ এবং সময়",
"date_before": "এর আগের তারিখ",
"date_of_birth_saved": "জন্ম তারিখ সফলভাবে সংরক্ষণ করা হয়েছে",
"delete": "মুছুন",
"delete_album": "অ্যালবাম মুছুন",
"delete_api_key_prompt": "আপনি কি সত্যিই এই API key মুছে ফেলতে চান?",
"delete_duplicates_confirmation": "আপনি কি সত্যিই এই ডুপ্লিকেটগুলো স্থায়ীভাবে মুছতে চান?",
"delete_key": "key মুছুন",
"delete_library": "লাইব্রেরি মুছুন",
"delete_link": "লিঙ্ক মুছুন",
"delete_others": "বাকিগুলো মুছুন",
"delete_shared_link": "শেয়ার করা লিঙ্ক মুছুন",
"delete_tag": "ট্যাগ মুছুন",
"delete_tag_confirmation_prompt": "আপনি কি নিশ্চিতভাবে {tagName} ট্যাগটি মুছতে চান?",
"delete_user": "ইউজার মুছুন",
"deleted_shared_link": "শেয়ার করা লিঙ্কটি মুছুন",
"deletes_missing_assets": "ডিস্ক থেকে হারানো অ্যাসেটগুলো মুছে",
"description": "বিবরন",
"details": "বিস্তারিত",
"direction": "দিকনির্দেশনা",
"disabled": "নিষ্ক্রিয়",
"disallow_edits": "সম্পাদনা করার অনুমতি দেবেন না",
"discord": "ডিসকর্ড",
"discover": "ডিসকভার",
"dismiss_all_errors": "সব ত্রুটি বাতিল করুন",
"dismiss_error": "ত্রুটি বাতিল করুন",
"display_options": "ডিসপ্লে অপশন",
"display_order": "ডিসপ্লে অর্ডার",
"display_original_photos": "অরিজিনাল ছবি দেখান",
"display_original_photos_setting_description": "অরিজিনাল অ্যাসেটটি ওয়েব-সামঞ্জস্যপূর্ণ (web-compatible) হলে অ্যাসেট দেখার সময় থাম্বনেইলের পরিবর্তে মূল ফটোটি প্রদর্শন করতে অগ্রাধিকার দিন। এর ফলে ফটো প্রদর্শনের গতি কিছুটা ধীর হতে পারে।",
"do_not_show_again": "এই মেসেজটি আর দেখাবেন না",
"documentation": "সহায়ক নির্দেশিকা",
"done": "সম্পন্ন",
"download": "ডাউনলোড",
"download_include_embedded_motion_videos": "এমবেডেড ভিডিও",
"download_include_embedded_motion_videos_description": "মোশন ফটোর (motion photos) মধ্যে থাকা ভিডিওগুলোকে আলাদা ফাইল হিসেবে অন্তর্ভুক্ত করুন",
"download_settings": "ডাউনলোড",
"download_settings_description": "অ্যাসেট ডাউনলোডের সেটিংস পরিচালনা করুন",
"open_in_browser": "ব্রাউজারে ওপেন করুন",
"user_usage_stats": "অ্যাকাউন্ট ব্যবহারের পরিসংখ্যান",
"user_usage_stats_description": "অ্যাকাউন্ট ব্যবহারের পরিসংখ্যান দেখুন",
"yes": "হ্যাঁ",

View File

@@ -372,7 +372,7 @@
"transcoding_audio_codec": "Còdec d'àudio",
"transcoding_audio_codec_description": "Opus és l'opció de màxima qualitat, però té menor compatibilitat amb dispositius o programari antics.",
"transcoding_bitrate_description": "Vídeos superiors a la taxa de bits màxima o que no tenen un format acceptat",
"transcoding_codecs_learn_more": "Per obtenir més informació sobre la terminologia utilitzada, consulteu la documentació de FFmpeg per al <h264-link> còdec H.264</h264-link>, <hevc-link> còdec HEVC</hevc-link> i <vp9-link> còdec VP9</vp9-link>.",
"transcoding_codecs_learn_more": "Per obtenir més informació sobre la terminologia utilitzada, consulteu la documentació de FFmpeg per al <h264-link>còdec H.264</h264-link>, <hevc-link>còdec HEVC</hevc-link> i <vp9-link>còdec VP9</vp9-link>.",
"transcoding_constant_quality_mode": "Mode de qualitat constant",
"transcoding_constant_quality_mode_description": "ICQ és millor que CQP, però alguns dispositius d'acceleració de maquinari no admeten aquest mode. Establir aquesta opció preferirà el mode especificat quan utilitzeu la codificació basada en la qualitat. Ignorat per NVENC perquè no és compatible amb ICQ.",
"transcoding_constant_rate_factor": "Factor de taxa constant (-crf)",
@@ -441,7 +441,7 @@
"user_successfully_removed": "L'usuari {email} s'ha eliminat correctament.",
"users_page_description": "Pàgina d'usuaris de l'administrador",
"version_check_enabled_description": "Activa la comprovació de la versió",
"version_check_implications": "La funció de comprovació de versions depèn de comunicacions periòdiques amb github.com",
"version_check_implications": "La funció de comprovació de versions depèn de comunicacions periòdiques amb {server}",
"version_check_settings": "Comprovació de versió",
"version_check_settings_description": "Activa/desactiva la notificació de nova versió",
"video_conversion_job": "Transcodificació de vídeos",
@@ -849,9 +849,12 @@
"create_link_to_share": "Crear enllaç per compartir",
"create_link_to_share_description": "Deixa que qualsevol persona amb l'enllaç vegi les fotos seleccionades",
"create_new": "CREAR NOU",
"create_new_face": "Crea una nova cara",
"create_new_person": "Crea una nova persona",
"create_new_person_hint": "Assigna els elements seleccionats a una persona nova",
"create_new_user": "Crea un usuari nou",
"create_person": "Crea una persona",
"create_person_subtitle": "Afegeix un nom a la cara seleccionada per crear i etiquetar la nova persona",
"create_shared_album_page_share_add_assets": "AFEGEIX ELEMENTS",
"create_shared_album_page_share_select_photos": "Escull fotografies",
"create_shared_link": "Crea un enllaç compartit",
@@ -866,6 +869,7 @@
"crop_aspect_ratio_fixed": "Fixat",
"crop_aspect_ratio_free": "Lliure",
"crop_aspect_ratio_original": "Original",
"crop_aspect_ratio_square": "Quadrat",
"curated_object_page_title": "Coses",
"current_device": "Dispositiu actual",
"current_pin_code": "Codi PIN actual",
@@ -880,7 +884,7 @@
"daily_title_text_date": "E, dd MMM",
"daily_title_text_date_year": "E, dd MMM, yyyy",
"dark": "Fosc",
"dark_theme": "Canviar a tema fosc",
"dark_theme": "Canvia a tema fosc",
"date": "Data",
"date_after": "Data posterior a",
"date_and_time": "Data i hora",
@@ -891,10 +895,8 @@
"day": "Dia",
"days": "Dies",
"deduplicate_all": "Desduplica-ho tot",
"deduplication_criteria_1": "Mida d'imatge en bytes",
"deduplication_criteria_2": "Quantitat de dades EXIF",
"deduplication_info": "Informació de deduplicació",
"deduplication_info_description": "Per preseleccionar recursos automàticament i eliminar els duplicats de manera massiva, ens fixem en:",
"default_locale": "Configuració regional predeterminada",
"default_locale_description": "Format de dades i números en funció de la configuració local",
"delete": "Esborrar",
"delete_action_confirmation_message": "Segur que vols eliminar aquest recurs? Aquesta acció el mourà a la paperera del servidor, i et preguntarà si el vols eliminar localment",
"delete_action_prompt": "{count} eliminats",
@@ -970,7 +972,7 @@
"downloading_media": "Descàrrega multimèdia",
"drop_files_to_upload": "Deixeu els fitxers a qualsevol lloc per pujar-los",
"duplicates": "Duplicats",
"duplicates_description": "Resol cada grup indicant, si n'hi ha, quins són duplicats",
"duplicates_description": "Resol cada grup indicant, si n'hi ha, quins són duplicats.",
"duration": "Durada",
"edit": "Editar",
"edit_album": "Edita l'àlbum",
@@ -992,7 +994,7 @@
"edit_location_dialog_title": "Ubicació",
"edit_name": "Edita el nom",
"edit_people": "Edita la gent",
"edit_tag": "Editar etiqueta",
"edit_tag": "Edita etiqueta",
"edit_title": "Edita títol",
"edit_user": "Edita l'usuari",
"edit_workflow": "Edita el flux de treball",
@@ -1007,6 +1009,8 @@
"editor_edits_applied_success": "Les modificacions s'han aplicat correctament",
"editor_flip_horizontal": "Capgira horitzontalment",
"editor_flip_vertical": "Capgira verticalment",
"editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} cantó per agafar",
"editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} cantó per agafar",
"editor_orientation": "Orientació",
"editor_reset_all_changes": "Reiniciar canvis",
"editor_rotate_left": "Rota 90º al contrari de les agulles",
@@ -1168,7 +1172,7 @@
"exif_bottom_sheet_description_error": "No s'ha pogut actualitzar la descripció",
"exif_bottom_sheet_details": "DETALLS",
"exif_bottom_sheet_location": "UBICACIÓ",
"exif_bottom_sheet_no_description": "Sense descrioció",
"exif_bottom_sheet_no_description": "Sense descripció",
"exif_bottom_sheet_people": "PERSONES",
"exif_bottom_sheet_person_add_person": "Afegir nom",
"exit_slideshow": "Surt de la presentació de diapositives",
@@ -1385,9 +1389,11 @@
"library_page_sort_title": "Títol de l'àlbum",
"licenses": "Llicències",
"light": "Llum",
"light_theme": "Canviar a tema clar",
"like": "M'agrada",
"like_deleted": "M'agrada suprimit",
"link_motion_video": "Enllaçar vídeo en moviment",
"link_to_docs": "Per més informació, mirar la <link>documentation</link>.",
"link_to_oauth": "Enllaç a OAuth",
"linked_oauth_account": "Compte OAuth enllaçat",
"list": "Llista",
@@ -1649,6 +1655,7 @@
"only_favorites": "Només preferits",
"open": "Obrir",
"open_calendar": "Obrir el calendari",
"open_in_browser": "Obre al navegador",
"open_in_map_view": "Obrir a la vista del mapa",
"open_in_openstreetmap": "Obre a OpenStreetMap",
"open_the_search_filters": "Obriu els filtres de cerca",
@@ -2210,6 +2217,7 @@
"tag": "Etiqueta",
"tag_assets": "Etiquetar actius",
"tag_created": "Etiqueta creada: {tag}",
"tag_face": "Etiqueta una cara",
"tag_feature_description": "Exploreu fotos i vídeos agrupats per temes d'etiquetes lògiques",
"tag_not_found_question": "No trobeu una etiqueta? <link>Crear una nova etiqueta.</link>",
"tag_people": "Etiquetar personas",
@@ -2391,6 +2399,7 @@
"viewer_remove_from_stack": "Elimina de la pila",
"viewer_stack_use_as_main_asset": "Fes servir com a element principal",
"viewer_unstack": "Desapila",
"visibility": "Visibilitat",
"visibility_changed": "La visibilitat ha canviat per {count, plural, one {# persona} other {# persones}}",
"visual": "Visual",
"visual_builder": "Constructor visual",

View File

@@ -441,7 +441,7 @@
"user_successfully_removed": "Uživatel {email} byl úspěšně odstraněn.",
"users_page_description": "Stránka správců",
"version_check_enabled_description": "Povolit kontrolu verzí",
"version_check_implications": "Kontrola verze je založena na pravidelné komunikaci s github.com",
"version_check_implications": "Kontrola verze je založena na pravidelné komunikaci s {server}",
"version_check_settings": "Kontrola verze",
"version_check_settings_description": "Povolení/zakázání oznámení o nové verzi",
"video_conversion_job": "Překódování videí",
@@ -849,9 +849,12 @@
"create_link_to_share": "Vytvořit odkaz pro sdílení",
"create_link_to_share_description": "Umožnit každému, kdo má odkaz, zobrazit vybrané fotografie",
"create_new": "VYTVOŘIT NOVÉ",
"create_new_face": "Vytvořit nový obličej",
"create_new_person": "Vytvořit novou osobu",
"create_new_person_hint": "Přiřadit vybrané položky nové osobě",
"create_new_user": "Vytvořit nového uživatele",
"create_person": "Vytvořit osobu",
"create_person_subtitle": "Přidejte jméno ke zvolenému obličeji pro vytvoření a označení nové osoby",
"create_shared_album_page_share_add_assets": "PŘIDAT POLOŽKY",
"create_shared_album_page_share_select_photos": "Vybrat fotografie",
"create_shared_link": "Vytvořit sdílený odkaz",
@@ -866,6 +869,7 @@
"crop_aspect_ratio_fixed": "Pevný",
"crop_aspect_ratio_free": "Volný",
"crop_aspect_ratio_original": "Původní",
"crop_aspect_ratio_square": "Čtverec",
"curated_object_page_title": "Věci",
"current_device": "Současné zařízení",
"current_pin_code": "Aktuální PIN kód",
@@ -880,7 +884,7 @@
"daily_title_text_date": "EEEE, d. MMMM",
"daily_title_text_date_year": "EEEE, d. MMMM y",
"dark": "Tmavý",
"dark_theme": "Přepnout tmavý motiv",
"dark_theme": "Přepnout na tmavý motiv",
"date": "Datum",
"date_after": "Datum po",
"date_and_time": "Datum a čas",
@@ -891,10 +895,8 @@
"day": "Den",
"days": "Dnů",
"deduplicate_all": "Odstranit všechny duplicity",
"deduplication_criteria_1": "Velikost obrázku v bajtech",
"deduplication_criteria_2": "Počet EXIF dat",
"deduplication_info": "Informace o deduplikaci",
"deduplication_info_description": "Pro automatický předvýběr položek a hromadné odstranění duplicit se zohledňuje:",
"default_locale": "Výchozí národní prostředí",
"default_locale_description": "Formátování datumu a čísel podle místního nastavení prohlížeče",
"delete": "Smazat",
"delete_action_confirmation_message": "Opravdu chcete odstranit tuto položku? Tato akce přesune položku do serverového koše a zeptá se vás, zda ji chcete odstranit lokálně",
"delete_action_prompt": "{count} smazáno",
@@ -970,7 +972,7 @@
"downloading_media": "Stahování média",
"drop_files_to_upload": "Pro nahrání sem přetáhněte soubory",
"duplicates": "Duplicity",
"duplicates_description": "Vyřešte každou skupinu tak, že uvedete, které skupiny jsou duplicitní",
"duplicates_description": "Vyřešte každou skupinu tak, že uvedete, které skupiny jsou duplicitní.",
"duration": "Doba trvání",
"edit": "Upravit",
"edit_album": "Upravit album",
@@ -1387,9 +1389,11 @@
"library_page_sort_title": "Podle názvu alba",
"licenses": "Licence",
"light": "Světlý",
"light_theme": "Přepnout na světlý motiv",
"like": "Líbí se mi",
"like_deleted": "Oblíbení smazáno",
"link_motion_video": "Připojit pohyblivé video",
"link_to_docs": "Další informace najdete v <link>dokumentaci</link>.",
"link_to_oauth": "Propojit s OAuth",
"linked_oauth_account": "Propojený OAuth účet",
"list": "Seznam",
@@ -2213,6 +2217,7 @@
"tag": "Značka",
"tag_assets": "Přiřadit značku",
"tag_created": "Vytvořena značka: {tag}",
"tag_face": "Označit obličej",
"tag_feature_description": "Procházení fotografií a videí seskupených podle témat logických značek",
"tag_not_found_question": "Nemůžete najít značku? <link>Vytvořte novou.</link>",
"tag_people": "Označit lidi",
@@ -2394,6 +2399,7 @@
"viewer_remove_from_stack": "Odstranit ze seskupení",
"viewer_stack_use_as_main_asset": "Použít jako hlavní položku",
"viewer_unstack": "Zrušit seskupení",
"visibility": "Viditelnost",
"visibility_changed": "Viditelnost změněna u {count, plural, one {# osoby} few {# osob} other {# lidí}}",
"visual": "Vizuální",
"visual_builder": "Vizuální návrhář",

View File

@@ -441,7 +441,7 @@
"user_successfully_removed": "Bruger {email} er blevet fjernet med succes.",
"users_page_description": "Admin-brugere side",
"version_check_enabled_description": "Aktivér versionstjek",
"version_check_implications": "Funktionen til versionstjek er afhængig af periodisk kommunikation med github.com",
"version_check_implications": "Funktionen til versionstjek er afhængig af periodisk kommunikation med {server}",
"version_check_settings": "Versionstjek",
"version_check_settings_description": "Aktiver/deaktiverer notifikation for den nye version",
"video_conversion_job": "Transkod videoer",
@@ -849,9 +849,12 @@
"create_link_to_share": "Opret link for at dele",
"create_link_to_share_description": "Tillad alle med linket at se de(t) valgte billede(r)",
"create_new": "OPRET NY",
"create_new_face": "Opret nyt ansigt",
"create_new_person": "Opret ny person",
"create_new_person_hint": "Tildel valgte aktiver til en ny person",
"create_new_user": "Opret ny bruger",
"create_person": "Opret person",
"create_person_subtitle": "Tilføj et navn til det valgte ansigt for at oprette og tagge den nye person",
"create_shared_album_page_share_add_assets": "TILFØJ ELEMENT",
"create_shared_album_page_share_select_photos": "Vælg Billeder",
"create_shared_link": "Opret delt link",
@@ -863,9 +866,10 @@
"created_at": "Oprettet",
"creating_linked_albums": "Opretter sammenkædede albums...",
"crop": "Beskær",
"crop_aspect_ratio_fixed": "Fikset",
"crop_aspect_ratio_free": "Gratis",
"crop_aspect_ratio_fixed": "Fast",
"crop_aspect_ratio_free": "Fri",
"crop_aspect_ratio_original": "Original",
"crop_aspect_ratio_square": "Kvadrat",
"curated_object_page_title": "Ting",
"current_device": "Nuværende enhed",
"current_pin_code": "Nuværende PIN kode",
@@ -890,11 +894,9 @@
"date_range": "Datointerval",
"day": "Dag",
"days": "Dage",
"deduplicate_all": "Kopier alle",
"deduplication_criteria_1": "Billedstørrelse i bytes",
"deduplication_criteria_2": "Antal EXIF-data",
"deduplication_info": "Deduplikerings info",
"deduplication_info_description": "For automatisk at forudvælge emner og fjerne dubletter i bulk ser vi på:",
"deduplicate_all": "Dedubliker alle",
"default_locale": "Standard sprog",
"default_locale_description": "Formatér datoer og tal baseret på din browsers landestandard",
"delete": "Slet",
"delete_action_confirmation_message": "Er du sikker på, at du vil slette dette objekt? Denne handling vil flytte objektet til serverens papirkurv, og vil spørge dig, om du vil slette den lokalt",
"delete_action_prompt": "{count} slettet",
@@ -970,7 +972,7 @@
"downloading_media": "Download medier",
"drop_files_to_upload": "Slip filer hvor som helst for at uploade dem",
"duplicates": "Duplikater",
"duplicates_description": "Løs hver gruppe ved at angive, hvilke, hvis nogen, er dubletter",
"duplicates_description": "Løs hver gruppe ved at angive hvilke, hvis nogen, er dubletter",
"duration": "Varighed",
"edit": "Rediger",
"edit_album": "Redigér album",
@@ -1007,6 +1009,8 @@
"editor_edits_applied_success": "Redigeringer gemt",
"editor_flip_horizontal": "Vend horisontalt",
"editor_flip_vertical": "Flip vertikal",
"editor_handle_corner": "{corner, select, top_left {Øverst venstre} top_right {Øverst højre} bottom_left {Nederst venstre} bottom_right {Nederst højre} other {A}} hjørnehåndtag",
"editor_handle_edge": "{edge, select, top {Øverst} bottom {Nederst} left {Venstre} right {Højre} other {Et}} kanthåndtag",
"editor_orientation": "Orientering",
"editor_reset_all_changes": "Nulstil ændringer",
"editor_rotate_left": "Rotér 90° mod uret",
@@ -1017,7 +1021,7 @@
"empty_trash": "Tøm papirkurv",
"empty_trash_confirmation": "Er du sikker på, at du vil tømme papirkurven? Dette vil fjerne alle objekter i papirkurven permanent fra Immich.\nDu kan ikke fortryde denne handling!",
"enable": "Aktivér",
"enable_backup": "Aktiver backup",
"enable_backup": "Aktivér backup",
"enable_biometric_auth_description": "Indtast din PIN kode for at slå biometrisk adgangskontrol til",
"enabled": "Aktiveret",
"end_date": "Slutdato",
@@ -1072,7 +1076,7 @@
"failed_to_update_notification_status": "Kunne ikke uploade notifikations status",
"incorrect_email_or_password": "Forkert email eller kodeord",
"library_folder_already_exists": "Denne import sti findes allerede.",
"page_not_found": "Siden blev ikke fundet :/",
"page_not_found": "Siden blev ikke fundet",
"paths_validation_failed": "{paths, plural, one {# sti} other {# stier}} slog fejl ved validering",
"profile_picture_transparent_pixels": "Profilbilleder kan ikke have gennemsigtige pixels. Zoom venligst ind og/eller flyt billedet.",
"quota_higher_than_disk_size": "Du har sat en kvote der er større end disken",
@@ -1385,9 +1389,11 @@
"library_page_sort_title": "Albumtitel",
"licenses": "Licenser",
"light": "Lys",
"light_theme": "Skift til lyst tema",
"like": "Synes om",
"like_deleted": "Ligesom slettet",
"link_motion_video": "Link bevægelsesvideo",
"link_to_docs": "For yderligere information, se <link>dokumentationen</link>.",
"link_to_oauth": "Link til OAuth",
"linked_oauth_account": "Tilsluttet OAuth-konto",
"list": "Liste",
@@ -1649,6 +1655,7 @@
"only_favorites": "Kun favoritter",
"open": "Åben",
"open_calendar": "Åbn kalender",
"open_in_browser": "Åbn i browser",
"open_in_map_view": "Åben i kortvisning",
"open_in_openstreetmap": "Åben i OpenStreetMap",
"open_the_search_filters": "Åbn søgefiltre",
@@ -2210,6 +2217,7 @@
"tag": "Tag",
"tag_assets": "Tag mediefiler",
"tag_created": "Oprettet tag: {tag}",
"tag_face": "Tag ansigt",
"tag_feature_description": "Gennemse billeder og videoer grupperet efter logiske tag-emner",
"tag_not_found_question": "Kan du ikke finde et tag? <link>Opret et nyt tag.</link>",
"tag_people": "Tag personer",
@@ -2391,6 +2399,7 @@
"viewer_remove_from_stack": "Fjern fra stak",
"viewer_stack_use_as_main_asset": "Brug som hovedelement",
"viewer_unstack": "Fjern fra stak",
"visibility": "Synlighed",
"visibility_changed": "Synlighed ændret for {count, plural, one {# person} other {# personer}}",
"visual": "Visuel",
"visual_builder": "Visuel builder",

View File

@@ -1,5 +1,5 @@
{
"about": "Über Immich",
"about": "Über",
"account": "Konto",
"account_settings": "Kontoeinstellungen",
"acknowledge": "Verstanden",
@@ -8,7 +8,7 @@
"action_description": "Eine Reihe von Aktionen, die an den gefilterten Assets ausgeführt werden sollen",
"actions": "Aktionen",
"active": "Aktiv",
"active_count": "Aktive:{count}",
"active_count": "Aktive: {count}",
"activity": "Aktivität",
"activity_changed": "Aktivität ist {enabled, select, true {aktiviert} other {deaktiviert}}",
"add": "Hinzufügen",
@@ -59,7 +59,7 @@
"backup_database_enable_description": "Datenbank regelmäßig sichern",
"backup_keep_last_amount": "Anzahl der aufzubewahrenden früheren Sicherungen",
"backup_onboarding_1_description": "Offsite-Kopie in der Cloud oder an einem anderen physischen Ort.",
"backup_onboarding_2_description": "lokale Kopien auf verschiedenen Geräten. Dazu gehören die Hauptdateien und eine lokale Sicherung dieser Dateien.",
"backup_onboarding_2_description": "Lokale Kopien auf verschiedenen Geräten. Dazu gehören die Hauptdateien und eine lokale Sicherung dieser Dateien.",
"backup_onboarding_3_description": "Kopien deiner Daten inklusive Originaldateien. Dies umfasst 1 Kopie an einem anderen Ort und 2 lokale Kopien.",
"backup_onboarding_description": "Eine <backblaze-link>3-2-1 Sicherungsstrategie</backblaze-link> wird empfohlen, um deine Daten zu schützen. Du solltest sowohl Kopien deiner hochgeladenen Fotos/Videos als auch der Immich-Datenbank aufbewahren, um eine umfassende Sicherungslösung zu haben.",
"backup_onboarding_footer": "Weitere Informationen zum Sichern von Immich findest du in der <link>Dokumentation</link>.",
@@ -309,7 +309,7 @@
"reset_settings_to_recent_saved": "Einstellungen auf die zuletzt gespeicherten Einstellungen zurücksetzen",
"scanning_library": "Bibliothek scannen",
"search_jobs": "Suchaufgaben…",
"send_welcome_email": "Begrüssungsmail senden",
"send_welcome_email": "Begrüßungsmail senden",
"server_external_domain_settings": "Externe Domain",
"server_external_domain_settings_description": "Für externe Links verwendete Domäne",
"server_public_users": "Öffentliche Benutzer",
@@ -441,7 +441,7 @@
"user_successfully_removed": "Der Benutzer {email} wurde erfolgreich entfernt.",
"users_page_description": "Administrator-Benutzerseite",
"version_check_enabled_description": "Versionsprüfung aktivieren",
"version_check_implications": "Die Funktion zur Versionsprüfung basiert auf regelmäßiger Kommunikation mit GitHub.com",
"version_check_implications": "Die Funktion zur Versionsprüfung basiert auf regelmäßiger Kommunikation mit {server}",
"version_check_settings": "Versionsprüfung",
"version_check_settings_description": "Aktivieren/Deaktivieren der Benachrichtigung über neue Versionen",
"video_conversion_job": "Videos transkodieren",
@@ -472,7 +472,7 @@
"advanced_settings_troubleshooting_title": "Fehlersuche",
"age_months": "Alter {months, plural, one {# Monat} other {# Monate}}",
"age_year_months": "Alter 1 Jahr, {months, plural, one {# Monat} other {# Monate}}",
"age_years": "Alter {years, plural, one {# Jahr} other {# Jahre}}",
"age_years": "{years, plural, other {Alter #}}",
"album": "Album",
"album_added": "Album hinzugefügt",
"album_added_notification_setting_description": "Erhalte eine E-Mail-Benachrichtigung, wenn du zu einem freigegebenen Album hinzugefügt wurdest",
@@ -541,7 +541,7 @@
"app_settings": "App-Einstellungen",
"app_stores": "App Stores",
"app_update_available": "App Update verfügbar",
"appears_in": "Erscheint in",
"appears_in": "Enthalten in",
"apply_count": "Anwenden ({count, number})",
"archive": "Archiv",
"archive_action_prompt": "{count} zum Archiv hinzugefügt",
@@ -580,7 +580,7 @@
"asset_restored_successfully": "Datei erfolgreich wiederhergestellt",
"asset_skipped": "Übersprungen",
"asset_skipped_in_trash": "Im Papierkorb",
"asset_trashed": "Datei Gelöscht",
"asset_trashed": "Datei gelöscht",
"asset_troubleshoot": "Datei Fehlerbehebung",
"asset_uploaded": "Hochgeladen",
"asset_uploading": "Hochladen…",
@@ -610,14 +610,14 @@
"assets_were_part_of_album_count": "{count, plural, one {# Datei ist} other {# Dateien sind}} bereits im Album vorhanden",
"assets_were_part_of_albums_count": "{count, plural, one {Datei war} other {Dateien waren}} bereits in den Alben",
"authorized_devices": "Verwendete Geräte",
"automatic_endpoint_switching_subtitle": "Verbinden Sie sich lokal über ein bestimmtes WiFi, wenn es verfügbar ist, und verwenden Sie andere Verbindungsmöglichkeiten",
"automatic_endpoint_switching_subtitle": "Verbinden Sie sich lokal über ein bestimmtes WLAN-Netz, wenn es verfügbar ist, und verwenden Sie ansonsten andere Verbindungsmöglichkeiten",
"automatic_endpoint_switching_title": "Automatische URL-Umschaltung",
"autoplay_slideshow": "Automatische Diashow",
"back": "Zurück",
"back_close_deselect": "Zurück, Schließen oder Abwählen",
"background_backup_running_error": "Sicherung läuft im Hintergrund. Manuelle Sicherung kann nicht gestartet werden",
"background_location_permission": "Hintergrund Standortfreigabe",
"background_location_permission_content": "Um im Hintergrund zwischen den Netzwerken wechseln zu können, muss Immich *immer* Zugriff auf den genauen Standort haben, damit die App den Namen des WiFi-Netzwerks ermitteln kann",
"background_location_permission_content": "Um im Hintergrund zwischen den Netzwerken wechseln zu können, muss Immich *immer* Zugriff auf den genauen Standort haben, damit die App den Namen des WLAN-Netzwerks ermitteln kann",
"background_options": "Hintergrund Optionen",
"backup": "Sicherung",
"backup_album_selection_page_albums_device": "Alben auf dem Gerät ({count})",
@@ -652,7 +652,7 @@
"backup_controller_page_background_is_on": "Automatische Sicherung im Hintergrund ist aktiviert",
"backup_controller_page_background_turn_off": "Hintergrundservice ausschalten",
"backup_controller_page_background_turn_on": "Hintergrundservice einschalten",
"backup_controller_page_background_wifi": "Nur im WiFi",
"backup_controller_page_background_wifi": "Nur im WLAN",
"backup_controller_page_backup": "Sicherung",
"backup_controller_page_backup_selected": "Ausgewählt: ",
"backup_controller_page_backup_sub": "Gesicherte Fotos und Videos",
@@ -687,7 +687,7 @@
"backup_options_page_title": "Sicherungsoptionen",
"backup_setting_subtitle": "Verwaltung der Upload-Einstellungen im Hintergrund und im Vordergrund",
"backup_settings_subtitle": "Upload-Einstellungen verwalten",
"backup_upload_details_page_more_details": "Tippen für weitere Details",
"backup_upload_details_page_more_details": "Tippe für weitere Details",
"backward": "Rückwärts",
"biometric_auth_enabled": "Biometrische Authentifizierung aktiviert",
"biometric_locked_out": "Du bist von der biometrischen Authentifizierung ausgeschlossen",
@@ -697,8 +697,8 @@
"birthdate_set_description": "Das Geburtsdatum wird verwendet, um das Alter dieser Person zum Zeitpunkt eines Fotos zu berechnen.",
"blurred_background": "Unscharfer Hintergrund",
"bugs_and_feature_requests": "Fehler & Verbesserungsvorschläge",
"build": "Erstelle",
"build_image": "Bild erstellen",
"build": "Build",
"build_image": "Abbildversion",
"bulk_delete_duplicates_confirmation": "Bist du sicher, dass du {count, plural, one {# duplizierte Datei} other {# duplizierte Dateien gemeinsam}} löschen möchtest? Dabei wird die größte Datei jeder Gruppe behalten und alle anderen Duplikate endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden!",
"bulk_keep_duplicates_confirmation": "Bist du sicher, dass du {count, plural, one {# duplizierte Datei} other {# duplizierte Dateien}} behalten möchtest? Dies wird alle Duplikat-Gruppen auflösen ohne etwas zu löschen.",
"bulk_trash_duplicates_confirmation": "Bist du sicher, dass du {count, plural, one {# duplizierte Datei} other {# duplizierte Dateien gemeinsam}} in den Papierkorb verschieben möchtest? Dies wird die größte Datei jeder Gruppe behalten und alle anderen Duplikate in den Papierkorb verschieben.",
@@ -728,7 +728,7 @@
"cannot_undo_this_action": "Diese Aktion kann nicht rückgängig gemacht werden!",
"cannot_update_the_description": "Beschreibung kann nicht aktualisiert werden",
"cast": "Übertragen",
"cast_description": "Konfiguration verfügbarer Ziele",
"cast_description": "Verfügbare Cast-Ziele konfigurieren",
"change_date": "Datum ändern",
"change_description": "Beschreibung anpassen",
"change_display_order": "Anzeigereihenfolge ändern",
@@ -739,7 +739,7 @@
"change_password": "Passwort ändern",
"change_password_description": "Dies ist entweder das erste Mal, dass du dich im System anmeldest, oder es wurde eine Anfrage zur Änderung deines Passworts gestellt. Bitte gib unten dein neues Passwort ein.",
"change_password_form_confirm_password": "Passwort bestätigen",
"change_password_form_description": "Hallo {name}\n\nDas ist entweder das erste Mal dass du dich einloggst oder es wurde eine Anfrage zur Änderung deines Passwortes gestellt. Bitte gib das neue Passwort ein.",
"change_password_form_description": "Hallo {name}\n\nDas ist entweder das erste Mal, dass du dich einloggst oder es wurde eine Anfrage zur Änderung deines Passwortes gestellt. Bitte gib das neue Passwort ein.",
"change_password_form_log_out": "Von allen Geräte abmelden",
"change_password_form_log_out_description": "Es wird empfohlen, alle anderen Geräte abzumelden",
"change_password_form_new_password": "Neues Passwort",
@@ -754,7 +754,7 @@
"charging_requirement_mobile_backup": "Backup im Hintergrund erfordert Aufladen des Geräts",
"check_corrupt_asset_backup": "Auf beschädigte Asset-Backups überprüfen",
"check_corrupt_asset_backup_button": "Überprüfung durchführen",
"check_corrupt_asset_backup_description": "Führe diese Prüfung nur mit aktivierten WiFi durch, nachdem alle Dateien gesichert worden sind. Dieser Vorgang kann ein paar Minuten dauern.",
"check_corrupt_asset_backup_description": "Führe diese Prüfung nur mit aktivierten WLAN durch, nachdem alle Dateien gesichert worden sind. Dieser Vorgang kann ein paar Minuten dauern.",
"check_logs": "Logs prüfen",
"checksum": "Prüfsumme",
"choose_matching_people_to_merge": "Wähle passende Personen zum Zusammenführen",
@@ -807,13 +807,13 @@
"completed": "Abgeschlossen",
"confirm": "Bestätigen",
"confirm_admin_password": "Administrator Passwort bestätigen",
"confirm_delete_face": "Bist du sicher dass du das Gesicht von {name} aus der Datei entfernen willst?",
"confirm_delete_face": "Bist du sicher, dass du das Gesicht von {name} aus der Datei entfernen willst?",
"confirm_delete_shared_link": "Bist du sicher, dass du diesen geteilten Link löschen willst?",
"confirm_keep_this_delete_others": "Alle anderen Dateien im Stapel bis auf diese werden gelöscht. Bist du sicher, dass du fortfahren möchten?",
"confirm_new_pin_code": "Neuen PIN-Code bestätigen",
"confirm_password": "Passwort bestätigen",
"confirm_tag_face": "Wollen Sie dieses Gesicht mit {name} markieren?",
"confirm_tag_face_unnamed": "Möchten Sie dieses Gesicht markieren?",
"confirm_tag_face": "Wollen Sie dieses Gesicht mit {name} taggen?",
"confirm_tag_face_unnamed": "Möchten Sie dieses Gesicht taggen?",
"connected_device": "Verbundenes Gerät",
"connected_to": "Verbunden mit",
"contain": "Vollständig",
@@ -849,9 +849,12 @@
"create_link_to_share": "Link zum Teilen erstellen",
"create_link_to_share_description": "Lass jeden mit dem Link die ausgewählten Fotos sehen",
"create_new": "NEUES ERSTELLEN",
"create_new_face": "Neues Gesicht erstellen",
"create_new_person": "Neue Person anlegen",
"create_new_person_hint": "Ausgewählte Dateien einer neuen Person zuweisen",
"create_new_user": "Neuen Nutzer erstellen",
"create_person": "Person anlegen",
"create_person_subtitle": "Gib dem gewählten Gesicht einen Namen um die neue Person zu erstellen und zu taggen",
"create_shared_album_page_share_add_assets": "INHALTE HINZUFÜGEN",
"create_shared_album_page_share_select_photos": "Fotos auswählen",
"create_shared_link": "Geteilten Link erstellen",
@@ -866,6 +869,7 @@
"crop_aspect_ratio_fixed": "Fixiert",
"crop_aspect_ratio_free": "Frei",
"crop_aspect_ratio_original": "Original",
"crop_aspect_ratio_square": "Quadratisch",
"curated_object_page_title": "Dinge",
"current_device": "Aktuelles Gerät",
"current_pin_code": "Aktueller PIN-Code",
@@ -880,7 +884,7 @@
"daily_title_text_date": "E, dd MMM",
"daily_title_text_date_year": "E, dd MMM, yyyy",
"dark": "Dunkel",
"dark_theme": "Dunkle Ansicht umschalten",
"dark_theme": "Auf dunkle Ansicht umschalten",
"date": "Datum",
"date_after": "Datum nach",
"date_and_time": "Datum und Zeit",
@@ -891,10 +895,8 @@
"day": "Tag",
"days": "Tage",
"deduplicate_all": "Alle Duplikate entfernen",
"deduplication_criteria_1": "Bildgröße in Bytes",
"deduplication_criteria_2": "Anzahl der EXIF-Daten",
"deduplication_info": "Deduplizierungsinformationen",
"deduplication_info_description": "Für die automatische Datei-Vorauswahl und das Deduplizieren aller Dateien berücksichtigen wir:",
"default_locale": "Standardgebietsschema",
"default_locale_description": "Datumsangaben und Zahlen werden entsprechend Ihrer Browsereinstellungen formatiert",
"delete": "Löschen",
"delete_action_confirmation_message": "Bist du sicher, dass du dieses Objekt löschen willst? Diese Aktion wird das Objekt in den Papierkorb des Servers verschieben und fragen, ob du es lokal löschen willst",
"delete_action_prompt": "{count} gelöscht",
@@ -970,7 +972,7 @@
"downloading_media": "Medien werden heruntergeladen",
"drop_files_to_upload": "Lade Dateien hoch, indem du sie hierhin ziehst",
"duplicates": "Duplikate",
"duplicates_description": "Löse jede Gruppe auf, indem du angibst, welche, wenn überhaupt, Duplikate sind",
"duplicates_description": "Löse jede Gruppe auf, indem du angibst, welche, wenn überhaupt, Duplikate sind.",
"duration": "Dauer",
"edit": "Bearbeiten",
"edit_album": "Album bearbeiten",
@@ -1024,7 +1026,7 @@
"enabled": "Aktiviert",
"end_date": "Enddatum",
"enqueued": "Eingereiht",
"enter_wifi_name": "WiFi-Name eingeben",
"enter_wifi_name": "WLAN-Name eingeben",
"enter_your_pin_code": "PIN-Code eingeben",
"enter_your_pin_code_subtitle": "Gib deinen PIN-Code ein, um auf den gesperrten Ordner zuzugreifen",
"error": "Fehler",
@@ -1036,7 +1038,7 @@
"error_loading_partners": "Fehler beim Laden der Partner: {error}",
"error_retrieving_asset_information": "Fehler beim Abruf der Dateiinformationen",
"error_saving_image": "Fehler: {error}",
"error_tag_face_bounding_box": "Fehler beim Markieren des Gesichts - Begrenzungen können nicht abgerufen werden",
"error_tag_face_bounding_box": "Fehler beim Taggen des Gesichts - Begrenzungen können nicht abgerufen werden",
"error_title": "Fehler - Etwas ist schief gelaufen",
"error_while_navigating": "Fehler beim Navigieren zur Datei",
"errors": {
@@ -1081,9 +1083,9 @@
"something_went_wrong": "Ein Fehler ist eingetreten",
"unable_to_add_album_users": "Benutzer konnten nicht zum Album hinzugefügt werden",
"unable_to_add_assets_to_shared_link": "Datei konnte nicht zum geteilten Link hinzugefügt werden",
"unable_to_add_comment": "Es kann kein Kommentar hinzufügt werden",
"unable_to_add_comment": "Es kann kein Kommentar hinzugefügt werden",
"unable_to_add_exclusion_pattern": "Ausschlussmuster konnte nicht hinzugefügt werden",
"unable_to_add_partners": "Es können keine Partner hinzufügt werden",
"unable_to_add_partners": "Es können keine Partner hinzugefügt werden",
"unable_to_add_remove_archive": "Datei konnte nicht {archived, select, true {aus dem Archiv entfernt} other {zum Archiv hinzugefügt}} werden",
"unable_to_add_remove_favorites": "Datei konnte nicht {favorite, select, true {von den Favoriten entfernt} other {zu den Favoriten hinzugefügt}} werden",
"unable_to_archive_unarchive": "Konnte nicht {archived, select, true {archivieren} other {entarchivieren}}",
@@ -1240,7 +1242,7 @@
"geolocation_instruction_location": "Klicke auf eine Datei mit GPS Koordinaten um diesen Standort zu verwenden oder wähle einen Standort direkt auf der Karte",
"get_help": "Hilfe erhalten",
"get_people_error": "Fehler beim Laden der Personen",
"get_wifiname_error": "WiFi-Name konnte nicht ermittelt werden. Vergewissere dich, dass die erforderlichen Berechtigungen erteilt wurden und du mit einem WiFi-Netzwerk verbunden bist",
"get_wifiname_error": "Das WLAN-Netz konnte nicht ermittelt werden. Vergewissere dich, dass die erforderlichen Berechtigungen erteilt wurden und du mit einem WLAN-Netzwerk verbunden bist",
"getting_started": "Erste Schritte",
"go_back": "Zurück",
"go_to_folder": "Gehe zu Ordner",
@@ -1279,7 +1281,7 @@
"home_page_add_to_album_err_local": "Es können lokale Elemente noch nicht zu Alben hinzugefügt werden, überspringen",
"home_page_add_to_album_success": "{added} Elemente zu {album} hinzugefügt.",
"home_page_album_err_partner": "Inhalte von Partnern können derzeit nicht zu Alben hinzugefügt werden",
"home_page_archive_err_local": "Kann lokale Elemente nicht archvieren, überspringen",
"home_page_archive_err_local": "Kann lokale Elemente nicht archivieren, überspringen",
"home_page_archive_err_partner": "Inhalte von Partnern können nicht archiviert werden",
"home_page_building_timeline": "Zeitachse wird erstellt",
"home_page_delete_err_partner": "Inhalte von Partnern können nicht gelöscht werden, überspringe",
@@ -1387,9 +1389,11 @@
"library_page_sort_title": "Titel des Albums",
"licenses": "Lizenzen",
"light": "Hell",
"light_theme": "Auf helle Ansicht umschalten",
"like": "Gefällt mir",
"like_deleted": "Like gelöscht",
"link_motion_video": "Bewegungsvideo verknüpfen",
"link_to_docs": "Weitere Informationen finden Sie in der <link>Dokumentation</link>.",
"link_to_oauth": "Mit OAuth verknüpfen",
"linked_oauth_account": "Verknüpftes OAuth-Konto",
"list": "Liste",
@@ -1404,7 +1408,7 @@
"local_network_sheet_info": "Die App stellt über diese URL eine Verbindung zum Server her, wenn sie das angegebene WLAN-Netzwerk verwendet",
"location": "Standort",
"location_permission": "Standort Genehmigung",
"location_permission_content": "Um die automatische Umschaltfunktion nutzen zu können, benötigt Immich genaue Standortberechtigung, damit es den Namen des aktuellen WiFi-Netzwerks ermitteln kann",
"location_permission_content": "Um die automatische Umschaltfunktion nutzen zu können, benötigt Immich genaue Standortberechtigung, damit es den Namen des aktuellen WLAN-Netzwerks ermitteln kann",
"location_picker_choose_on_map": "Auf der Karte auswählen",
"location_picker_latitude_error": "Gültigen Breitengrad eingeben",
"location_picker_latitude_hint": "Breitengrad eingeben",
@@ -1562,7 +1566,7 @@
"name_or_nickname": "Name oder Nickname",
"name_required": "Name ist erforderlich",
"navigate": "Navigation",
"navigate_to_time": "Navigiere zu Zeit",
"navigate_to_time": "Zu Zeitpunkt navigieren",
"network_requirement_photos_upload": "Mobile Daten verwenden, um Fotos zu sichern",
"network_requirement_videos_upload": "Mobile Daten verwenden, um Videos zu sichern",
"network_requirements": "Anforderungen ans Netzwerk",
@@ -1665,7 +1669,7 @@
"other_devices": "Andere Geräte",
"other_entities": "Andere Entitäten",
"other_variables": "Sonstige Variablen",
"owned": "Eigenes",
"owned": "Eigene",
"owner": "Besitzer",
"page": "Seite",
"partner": "Partner",
@@ -1872,7 +1876,7 @@
"repair": "Reparatur",
"repair_no_results_message": "Nicht auffindbare und fehlende Dateien werden hier angezeigt",
"replace_with_upload": "Durch Upload ersetzen",
"repository": "Repositorium",
"repository": "Repository",
"require_password": "Passwort erforderlich",
"require_user_to_change_password_on_first_login": "Benutzer muss das Passwort beim ersten Login ändern",
"rescan": "Erneut scannen",
@@ -2011,7 +2015,7 @@
"selected_count": "{count, plural, other {# ausgewählt}}",
"selected_gps_coordinates": "Ausgewählte GPS-Koordinaten",
"send_message": "Nachricht senden",
"send_welcome_email": "Begrüssungsmail senden",
"send_welcome_email": "Begrüßungsmail senden",
"server_endpoint": "Server-Endpunkt",
"server_info_box_app_version": "App-Version",
"server_info_box_server_url": "Server-URL",
@@ -2171,7 +2175,7 @@
"sort_people_by_similarity": "Personen nach Ähnlichkeit sortieren",
"sort_recent": "Neuestes Foto",
"sort_title": "Titel",
"source": "Quellcode",
"source": "Quelle",
"stack": "Stapel",
"stack_action_prompt": "{count} gestapelt",
"stack_duplicates": "Duplikate stapeln",
@@ -2213,6 +2217,7 @@
"tag": "Tag",
"tag_assets": "Dateien taggen",
"tag_created": "Tag erstellt: {tag}",
"tag_face": "Gesicht taggen",
"tag_feature_description": "Durchsuchen von Fotos und Videos, gruppiert nach logischen Tag-Themen",
"tag_not_found_question": "Kein Tag vorhanden? <link>Erstelle einen neuen Tag.</link>",
"tag_people": "Personen taggen",
@@ -2316,7 +2321,7 @@
"untagged": "Ohne Tag",
"untitled_workflow": "Unbenannter Workflow",
"up_next": "Weiter",
"update_location_action_prompt": "Aktualsiere den Ort von {count} ausgewählten Dateien mit:",
"update_location_action_prompt": "Aktualisiere den Ort von {count} ausgewählten Dateien mit:",
"updated_at": "Aktualisiert",
"updated_password": "Passwort aktualisiert",
"upload": "Hochladen",
@@ -2339,7 +2344,7 @@
"url": "URL",
"usage": "Verwendung",
"use_biometric": "Biometrie verwenden",
"use_browser_locale": "Benutze lokalen Browser",
"use_browser_locale": "Gebietsschema des Browsers verwenden",
"use_browser_locale_description": "Datum, Uhrzeit und Zahlen werden entsprechend den Einstellungen Ihres Browsers formatiert",
"use_current_connection": "Aktuelle Verbindung verwenden",
"use_custom_date_range": "Stattdessen einen benutzerdefinierten Datumsbereich verwenden",
@@ -2394,6 +2399,7 @@
"viewer_remove_from_stack": "Aus Stapel entfernen",
"viewer_stack_use_as_main_asset": "An Stapelanfang",
"viewer_unstack": "Stapel aufheben",
"visibility": "Sichtbarkeit",
"visibility_changed": "Sichtbarkeit für {count, plural, one {# Person} other {# Personen}} geändert",
"visual": "Visuell",
"visual_builder": "Visueller Editor",
@@ -2404,7 +2410,7 @@
"welcome": "Willkommen",
"welcome_to_immich": "Willkommen bei Immich",
"width": "Breite",
"wifi_name": "WiFi-Name",
"wifi_name": "WLAN-Netzwerk",
"workflow_delete_prompt": "Bist du sicher, dass du diesen Workflow löschen willst?",
"workflow_deleted": "Workflow gelöscht",
"workflow_description": "Workflow-Beschreibung",
@@ -2423,7 +2429,7 @@
"years_ago": "Vor {years, plural, one {einem Jahr} other {# Jahren}}",
"yes": "Ja",
"you_dont_have_any_shared_links": "Du hast keine geteilten Links",
"your_wifi_name": "Dein WiFi-Name",
"your_wifi_name": "Dein WLAN-Netzwerk",
"zero_to_clear_rating": "drücke 0 um die Dateibewertung zurückzusetzen",
"zoom_image": "Bild vergrößern",
"zoom_to_bounds": "Auf Grenzen zoomen"

View File

@@ -1,132 +1,132 @@
{
"about": "Über",
"account": "Konto",
"account_settings": "Konto Istelligä",
"acknowledge": "Bestätige",
"account_settings": "Konto Einstellungen",
"acknowledge": "Bestätigä",
"action": "Aktion",
"action_common_update": "Update",
"action_description": "Es paar Aktione, wo a de gfilterete Assets usgführt wärde sölled",
"actions": "Aktione",
"action_description": "Aktionä, wo uf de gefilterti Mediä ausgführt werdä solled",
"actions": "Aktionen",
"active": "Aktiv",
"active_count": "Aktivi: {count}",
"active_count": "Aktiv: {count}",
"activity": "Aktivität",
"activity_changed": "Aktivität isch {enabled, select, true {aktiviert} other {deaktiviert}}",
"add": "Hinzuefüe",
"add_a_description": "Beschriibig hinzueege",
"add_a_location": "Standort hinzuefüege",
"add_a_name": "Name hinzuefüege",
"add_a_title": "Titel hinzuefüege",
"add_action": "Aktion hinzuefüege",
"add_action_description": "Aklicke um en Aktion dure zfüehre",
"add_assets": "Assets hinzufüege",
"add_birthday": "Geburtstag hinzuefüege",
"activity_changed": "Aktivität ist {enabled, select, true {aktiviert} other {deaktiviert}}",
"add": "Hinzuefüge",
"add_a_description": "Beschreibung hinzufügen",
"add_a_location": "Standort hinzuefü",
"add_a_name": "Namä hinzefü",
"add_a_title": "Titel hinzufeügä",
"add_action": "Aktion hinzuefü",
"add_action_description": "Klick do zum e Aktion hinzuefüge",
"add_assets": "Mediä hinzuefüge",
"add_birthday": "Geburtstag hinzuefüge",
"add_endpoint": "Endpunkt hinzuefüge",
"add_exclusion_pattern": "Uuschlussmuster hinzueege",
"add_filter": "Filter hinzuefüge",
"add_filter_description": "Klicke, um e Filterbedingig hinzuezfüege",
"add_location": "Standort hinzueege",
"add_more_users": "Meh Benutzer hinzueege",
"add_partner": "Partner hinzueege",
"add_path": "Pfad hinzueege",
"add_photos": "Föteli hinzueege",
"add_tag": "Tag hinzueege",
"add_to": "Hinzueege zu …",
"add_to_album": "Zum Album hinzueege",
"add_to_album_bottom_sheet_added": "Zu {album} hinzuegfüegt",
"add_to_album_bottom_sheet_already_exists": "Scho in {album}",
"add_to_album_bottom_sheet_some_local_assets": "Es hend es paar lokali Dateie nöd chöne im Album hinzuegfüegt werde",
"add_to_album_toggle": "Uuswahl umschalte für {album}",
"add_to_albums": "Zu Albe hinzueege",
"add_to_albums_count": "Zu Albe hinzueege ({count})",
"add_to_bottom_bar": "Hinzueege zu",
"add_to_shared_album": "Zum teilte Album hinzueege",
"add_upload_to_stack": "Upload zum Stack hinzueege",
"add_url": "URL hinzueege",
"add_workflow_step": "Workflow-Schritt hinzueege",
"added_to_archive": "Is Archiv verschobe",
"added_to_favorites": "Zu dine Favoritä hinzuegfüegt",
"added_to_favorites_count": "{count, number} zu Favorite hinzuegfüegt",
"add_exclusion_pattern": "Ausschlussmuster hinzufügen",
"add_filter": "Filter hinzufügen",
"add_filter_description": "Klicke hier um eine Filterbedingung hinzuzufügen",
"add_location": "Standort hinzufügen",
"add_more_users": "Mehr Benutzer hinzufügen",
"add_partner": "Partner hinzufügen",
"add_path": "Pfad hinzufügen",
"add_photos": "Fotos hinzufügen",
"add_tag": "Tag hinzufügen",
"add_to": "Hinzufügen zu…",
"add_to_album": "Zu Album hinzufügen",
"add_to_album_bottom_sheet_added": "Zu {album} hinzugefügt",
"add_to_album_bottom_sheet_already_exists": "Bereits in {album}",
"add_to_album_bottom_sheet_some_local_assets": "Einige lokale Dateien konnten nicht zum Album hinzugefügt werden",
"add_to_album_toggle": "Auswahl umschalten für {album}",
"add_to_albums": "Zu Alben hinzufügen",
"add_to_albums_count": "Zu Alben hinzufügen ({count})",
"add_to_bottom_bar": "Hinzufügen zu",
"add_to_shared_album": "Zu geteiltem Album hinzufügen",
"add_upload_to_stack": "Upload zum Stapel hinzufügen",
"add_url": "URL hinzufügen",
"add_workflow_step": "Workflow-Schritt hinzufügen",
"added_to_archive": "Zum Archiv hinzugefügt",
"added_to_favorites": "Zu Favoriten hinzugefügt",
"added_to_favorites_count": "{count, number} zu Favoriten hinzugefügt",
"admin": {
"add_exclusion_pattern_description": "Uusschlussmuster hinzuefüge. Platzhalter, wie *, **, und ? wärded understützt. Zum all Dateie i eim Verzeichnis namens „Raw\" ignoriere, „**/Raw/**“ verwände. Zum all Dateien ignorieren, wo uf „.tif“ änded, „**/*.tif“ verwände. Zum en absolute Pfad ignoriere, „/pfad/zum/ignoriere/**“ verwände.",
"admin_user": "Admin Benutzer",
"asset_offline_description": "Die Datei vonere externe Bibliothek isch nümme uf de Festplatte und isch in Papierchorb verschobe worde. Falls die Datei innerhalb vo de Bibliothek verschoben worde isch, überprüf dini Ziitleiste uf die neui entsprechendi Datei. Zum die Datei wiederherstelle, stell bitte sicher, dass Immich uf de unde stehendi Dateipfad chan zuegriife und scann d'Bibliothek.",
"authentication_settings": "Authentifizierigs Iistellige",
"authentication_settings_description": "Passwort, OAuth und anderi Authentifizierigseinstellige verwalte",
"authentication_settings_disable_all": "Bisch sicher, dass du alli Login-Methodä wotsch deaktivierä? S Login isch denn komplett deaktiviert.",
"authentication_settings_reenable": "Bruuch ein <link>Server-Befehl</link> zum reaktiviere.",
"background_task_job": "Hintergrund Ufgabä",
"backup_database": "Datenbank-Dump aalege",
"backup_database_enable_description": "Datenbank-Dumps aktiviere",
"backup_keep_last_amount": "Aazahl vo de vorherige Dumps, wo bhalte werde sölle",
"backup_onboarding_1_description": "Offsite-Kopie i dä Cloud oder amene andere physische Standort.",
"backup_onboarding_2_description": "Lokali Kopie uf verschiedene Grät. Das beinhaltet d Hauptdateie und e lokali Sicherig vo dene Dateie.",
"backup_onboarding_3_description": "Total aazahl vo dine Dateikopie, inklusiv d Originaldateie. Das beinhaltet 1 Offsite-Kopie und 2 lokali Kopie.",
"backup_onboarding_description": "E <backblaze-link>3-2-1-Backup-Strategie</backblaze-link> wird empfohle, zum dini Dateie z schütze. Du söttsch sowohl Kopie vo dine ufgeladene Fotos/Videos wie au d Immich-Datenbank bhalte, für e rundum sauberi Backup-Lösig.",
"backup_onboarding_footer": "Für meh Infos zum Backup vo Immich lueg bitte i d <link>Dokumentation</link>.",
"backup_onboarding_parts_title": "Es 3-2-1-Backup beinhaltet:",
"add_exclusion_pattern_description": "Ausschlussmuster hinzufügen. Platzhalter, wie *, **, und ? werden unterstützt. Um alle Dateien in einem Verzeichnis namens „Raw“ zu ignorieren, „**/Raw/**“ verwenden. Um alle Dateien zu ignorieren, die auf „.tif“ enden, „**/*.tif“ verwenden. Um einen absoluten Pfad zu ignorieren, „/pfad/zum/ignorieren/**“ verwenden.",
"admin_user": "Administrator",
"asset_offline_description": "Diese Datei einer externen Bibliothek befindet sich nicht mehr auf der Festplatte und wurde in den Papierkorb verschoben. Falls die Datei innerhalb der Bibliothek verschoben wurde, überprüfe deine Zeitleiste auf die neue entsprechende Datei. Um diese Datei wiederherzustellen, stelle bitte sicher, dass Immich auf den unten stehenden Dateipfad zugreifen kann und scanne die Bibliothek.",
"authentication_settings": "Authentifizierungseinstellungen",
"authentication_settings_description": "Passwort-, OAuth- und andere Authentifizierungseinstellungen verwalten",
"authentication_settings_disable_all": "Bist du sicher, dass du alle Loginmethoden deaktivieren willst? Die Anmeldung wird vollständig deaktiviert.",
"authentication_settings_reenable": "Nutze einen <link>Server-Befehl</link> zur Reaktivierung.",
"background_task_job": "Hintergrundaufgaben",
"backup_database": "Datenbanksicherung erstellen",
"backup_database_enable_description": "Datenbank regelmässig sichern",
"backup_keep_last_amount": "Anzahl der aufzubewahrenden früheren Sicherungen",
"backup_onboarding_1_description": "Offsite-Kopie in der Cloud oder an einem anderen physischen Ort.",
"backup_onboarding_2_description": "lokale Kopien auf verschiedenen Geräten. Dazu gehören die Hauptdateien und eine lokale Sicherung dieser Dateien.",
"backup_onboarding_3_description": "Kopien deiner Daten inklusive Originaldateien. Dies umfasst 1 Kopie an einem anderen Ort und 2 lokale Kopien.",
"backup_onboarding_description": "Eine <backblaze-link>3-2-1 Sicherungsstrategie</backblaze-link> wird empfohlen, um deine Daten zu schützen. Du solltest sowohl Kopien deiner hochgeladenen Fotos/Videos als auch der Immich-Datenbank aufbewahren, um eine umfassende Sicherungslösung zu haben.",
"backup_onboarding_footer": "Weitere Informationen zum Sichern von Immich findest du in der <link>Dokumentation</link>.",
"backup_onboarding_parts_title": "Eine 3-2-1-Sicherung umfasst:",
"backup_onboarding_title": "Backups",
"backup_settings": "Iistellige für Datenbank-Dumps",
"backup_settings_description": "Datenbank-Dump-Iistellige verwalte.",
"cleared_jobs": "Jobs glöscht für: {job}",
"config_set_by_file": "D Konfiguration isch aktuell dur e Konfigurationsdatei gsetzt",
"confirm_delete_library": "Bisch sicher, dass du d Bibliothek {library} wotsch lösche?",
"confirm_delete_library_assets": "Bisch sicher, dass du die Bibliothek wotsch lösche? Das löscht {count, plural, one {# enthaltenes Asset} other {alli # enthaltene Assets}} us Immich und chan nöd rückgängig gmacht werde. D Dateie bliibed uf em Dateträger.",
"confirm_email_below": "Zum bestätige bitte \"{email}\" une iitippe",
"confirm_reprocess_all_faces": "Bisch sicher, dass du alli Gsichter neu verarbeite wotsch? Däbii werde au benannti Persone glöscht.",
"confirm_user_password_reset": "Bisch sicher, dass du s Passwort für {user} möchtisch zruggsetze?",
"confirm_user_pin_code_reset": "Bisch sicher, dass du de PIN-Code vo {user} möchtisch zruggsetze?",
"copy_config_to_clipboard_description": "Kopier die aktuelli Systemkonfiguration als JSON-Objekt i d'Zwüschenablage",
"create_job": "Uufgabe erstelle",
"cron_expression": "Cron-Ziitagabe",
"cron_expression_description": "Setz s Scanintervall im Cron-Format. Hilf mit däm Format bütet z. B. der <link>Crontab Guru</link>",
"cron_expression_presets": "Vorlage für Cron-Uusdruck",
"disable_login": "Login deaktiviere",
"duplicate_detection_job_description": "Die Uufgab füehrt s maschinelle Lärne für jedi Datei us, zum Duplikat finde. Die Uufgabe berueht uf de intelligente Suechi",
"exclusion_pattern_description": "Mit Uusschlussmuster chönnd Dateie und Ordner bim Scanne vo dinere Bibliothek ignoriert wärde. Das isch nützlich, wenn du Ordner häsch, wo Dateien drin händ, wo d nöd wotsch importiere, wie z. B. RAW-Dateie.",
"export_config_as_json_description": "Lad die aktuelli Systemkonfiguration als JSON-Datei abe",
"external_libraries_page_description": "Externi Bibliothekssiite für Administratore",
"face_detection": "Gsichtserkennig",
"face_detection_description": "Die Uufgab erfasst Gsichter in Dateien dur maschinells Lerne. Bi Video wird nur d'Miniaturasicht brucht. „Aktualisiere“ verarbeitet all Dateie neu. „Zruggsetze“ setzt au no all Gsichter zrugg. „Fehlendistellt nur nöd verarbeiteti Dateie in d'Warteschlange. Erfassti Gsichter wärdet zur Gsichtsidentifizierig in diWarteschlange gstellt, damit sie i bestehendi oder neui Persone z'gruppiere.",
"facial_recognition_job_description": "Die Uufgabe gruppiert im Anschluss an d'Gsichtserfassig die erfasste Gsichter zu Persone. „Zruggsetze“ gruppiert alli Gsichter neu und mit „Fehlendi“ werdet Gsichter ohni Zuordnig i d'Warteschlange gstellt.",
"failed_job_command": "Befehl {command} t für d'Uufgabe {job} nöd funktioniert",
"force_delete_user_warning": "WARNIG: Die Aktion löscht Benutzer und all sini Dateie. Das chann nöd rückgängig gmacht wärde und d'Dateie chönnd nöd wiederhergstellt wärde.",
"backup_settings": "Einstellungen für Datenbanksicherung",
"backup_settings_description": "Einstellungen zur regelmässigen Sicherung der Datenbank.",
"cleared_jobs": "Folgende Aufgaben zurückgesetzt: {job}",
"config_set_by_file": "Die Konfiguration ist aktuell durch eine Konfigurationsdatei gsetzt",
"confirm_delete_library": "Bist du sicher, dass du die Bibliothek {library} löschen willst?",
"confirm_delete_library_assets": "Bist du sicher, dass du diese Bibliothek löschen willst? Dies löscht {count, plural, one {# enthaltene Datei} other {alle # enthaltenen Dateien}} aus Immich und kann nicht rückgängig gemacht werden. Die Dateien bleiben auf der Festplatte erhalten.",
"confirm_email_below": "Zum Bestätigen, tippe unten \"{email}\" ein",
"confirm_reprocess_all_faces": "Bist du sicher, dass du alle Gesichter erneut verarbeiten möchtest? Dies löscht auch alle bereits benannten Personen.",
"confirm_user_password_reset": "Bist du sicher, dass du das Passwort für {user} zurücksetzen möchtest?",
"confirm_user_pin_code_reset": "Bist du sicher, dass du den PIN-Code von {user} zurücksetzen möchtest?",
"copy_config_to_clipboard_description": "Aktuelle Systemkonfiguration als JSON-Objekt in die Zwischenablage kopieren",
"create_job": "Aufgabe erstellen",
"cron_expression": "Cron-Ausdruck",
"cron_expression_description": "Setze das Scanintervall im Cron-Format. Für mehr Informationen, siehe z. B. <link>Crontab Guru</link>",
"cron_expression_presets": "Vorlagen für Cron-Ausdrücke",
"disable_login": "Login deaktivieren",
"duplicate_detection_job_description": "Verwendet maschinelles Lernen auf den Dateien, um Duplikate zu finden. Baut auf der intelligenten Suche auf",
"exclusion_pattern_description": "Mit Ausschlussmustern können Dateien und Ordner beim Scannen deiner Bibliothek ignoriert werden. Dies ist nützlich, wenn du Ordner hast, die Dateien enthalten, die du nicht importieren möchtest, wie z. B. RAW-Dateien.",
"export_config_as_json_description": "Aktuelle Systemkonfiguration als JSON-Datei herunterladen",
"external_libraries_page_description": "Externe Bibliotheksseite für Administratoren",
"face_detection": "Gesichtserkennung",
"face_detection_description": "Diese Aufgabe erkennt mit maschinellem Lernen Gesichter in Dateien. Bei Videos wird nur das Vorschaubild verwendet. „Aktualisieren“ verarbeitet alle Dateien neu. „Zurücksetzen“ setzt zusätzlich alle Gesichter zurück. „Fehlendefügt nur nicht verarbeitete Dateien in die Warteschlange ein. Erfasste Gesichter werden zur Gesichtsidentifizierung in die Warteschlange eingefügt, um sie in bestehende oder neue Personen zu gruppieren.",
"facial_recognition_job_description": "Diese Aufgabe gruppiert im Anschluss an die Gesichtserkennung die erkannten Gesichter zu Personen. „Zurücksetzen“ gruppiert alle Gesichter neu, während „Fehlende“ Gesichter ohne Zuordnung in die Warteschlange stellt.",
"failed_job_command": "Befehl {command} ist für Aufgabe {job} fehlgeschlagen",
"force_delete_user_warning": "WARNUNG: Diese Aktion löscht sofort den Benutzer und all seine Dateien. Dies kann nicht rückgängig gemacht werden und die Dateien können nicht wiederhergestellt werden.",
"image_format": "Format",
"image_format_description": "WebP erzeugt chlineri Dateie we JPEG, isch aber es bitz langsamer i de Erstellig.",
"image_fullsize_description": "Hochuflösends Bild mit glöschte Metadate, wo bim Zoome brucht wird",
"image_fullsize_enabled": "Hochuflösendi Vorschaubilder aktiviere",
"image_fullsize_enabled_description": "Generiere hochauflösende Vorschaubilder in Originalauflösung für nicht web-kompatibel Formate. Wenn \"Eingebettete Vorschau bevorzugen\" aktiviert ist, werden eingebettete Vorschaubilder direkt verwendet. Hat keinen Einfluss auf web-kompatible Formate wie JPEG.",
"image_format_description": "WebP erzeugt kleinere Dateien als JPEG, ist aber etwas langsamer in der Erstellung.",
"image_fullsize_description": "Hochauflösendes Bild mit entfernten Metadaten, das beim Zoomen verwendet wird",
"image_fullsize_enabled": "Hochauflösende Vorschaubilder aktivieren",
"image_fullsize_enabled_description": "Generiere Vorschaubilder in Originalauflösung für nicht web-kompatible Formate. Wenn \"Eingebettete Vorschau bevorzugen\" aktiviert ist, werden eingebettete Vorschaubilder direkt verwendet. Hat keinen Einfluss auf web-kompatible Formate wie JPEG.",
"image_fullsize_quality_description": "Qualität der hochauflösenden Vorschaubilder von 1-100. Höher ist besser, erzeugt aber grössere Dateien.",
"image_fullsize_title": "Hochauflösende Vorschaueinstellungen",
"image_prefer_embedded_preview": "Eingebettete Vorschau bevorzugen",
"image_prefer_embedded_preview_setting_description": "Verwende eingebettete Vorschaubilder in RAW-Fotos als Grundlage für die Bildverarbeitung, sofern diese zur Verfügung stehen. Dies kann bei einigen Bildern genauere Farben erzeugen, allerdings ist die Qualität der Vorschau kameraabhängig und das Bild kann mehr Kompressionsartefakte aufweisen.",
"image_prefer_wide_gamut": "Breites Spektrum bevorzugen",
"image_prefer_wide_gamut_setting_description": "Bruuch Display P3 für Vorschaubildli. Das erhaltet d'Vitalität von Bildli mit grossem Farbruum besser. Uf alte Grät mit alte Browser chann das aber andersch uusgseh. sRGB-Bildli wärdet als sRGB bhalte zum Farbänderige vermiide.",
"image_preview_description": "Mittelgrossi Bildli ohni Metadate, bruuchts für Einzelaasichte und fürs maschinelle Lärne",
"image_preview_quality_description": "Vorschauqualität vo 1-100. Höcher isch besser, git aber grösseri Dateie und chan d'App Schwuppdizität reduziere. Z tüffi Wert chönnd s maschinelle Lärne beiträchtige.",
"image_preview_title": "Vorschauiistellige",
"image_prefer_wide_gamut_setting_description": "Display P3 (DCI-P3) für Vorschaubilder verwenden. Dadurch bleibt die Lebendigkeit von Bildern mit breiten Farbräumen besser erhalten, aber die Bilder können auf älteren Geräten mit einer älteren Browserversion etwas anders aussehen. sRGB-Bilder werden im sRGB-Format belassen, um Farbverschiebungen zu vermeiden.",
"image_preview_description": "Mittelgrosses Bild mit entfernten Metadaten, das bei der Betrachtung einer einzelnen Datei und für maschinelles Lernen verwendet wird",
"image_preview_quality_description": "Vorschauqualität von 1-100. Ein höherer Wert ist besser, erzeugt dadurch aber grössere Dateien und kann die Reaktionsfähigkeit der App beeinträchtigen. Ein niedriger Wert kann dafür aber die Qualität des maschinellen Lernens beeinträchtigen.",
"image_preview_title": "Vorschaueinstellungen",
"image_progressive": "Fortlaufend",
"image_progressive_description": "Codier fortlaufendi JPEG-Bildi: Sie wärdet bim Lade aufbauend aazeiget. Das hät kei Würkig uf WebP-Bildi.",
"image_progressive_description": "JPEG-Bilder schrittweise kodieren, um ein stufenweises Laden zu ermöglichen. Dies hat keine Auswirkungen auf WebP-Bilder.",
"image_quality": "Qualität",
"image_resolution": "Uuflösig",
"image_resolution_description": "Höcheri Uuflösig erhaltet meh Detail, gaht aber länger zum codiere, macht grösseri Dateie und chan d'App Schuppdizität reduziere.",
"image_settings": "Bild-Iistellige",
"image_settings_description": "Qualität und Uuflösig von erstellte Bildli verwalte",
"image_thumbnail_description": "Chlini Vorschaubildli ohni Metadate, bruuchts für Aasichte mit Gruppe vo Föteli wie i de Hauptziitachse",
"image_thumbnail_quality_description": "Vorschauqualität vo 1-100. Höcher isch besser, git aber grösseri Dateie und chan d'App Schwuppdizität reduziere.",
"image_thumbnail_title": "Iistellige für Vorschaubildli",
"import_config_from_json_description": "Systemkonfiguration importiere durs Ufelade vonere JSON-Datei",
"job_concurrency": "{job} Näbeläufigkeit",
"job_created": "Uufgab erstellt",
"job_not_concurrency_safe": "Die Uufgabe ist nöd für Paralleluusführig gmacht.",
"job_settings": "Uufgabe-Iistellige",
"job_settings_description": "Uufgabe-Näbeläufigkeit verwalte",
"jobs_over_time": "Uufgabe in ziitliche Verlauf",
"image_resolution": "Auflösung",
"image_resolution_description": "Höhere Auflösungen können mehr Details erhalten, benötigen aber mehr Zeit für die Kodierung, haben grössere Dateigrössen und können die Reaktionsfähigkeit der App beeinträchtigen.",
"image_settings": "Bildeinstellungen",
"image_settings_description": "Qualität und Auflösung der generierten Bilder verwalten",
"image_thumbnail_description": "Kleines Vorschaubild mit entfernten Metadaten, die bei der Anzeige von Sammlungen von Fotos wie der Zeitleiste verwendet wird",
"image_thumbnail_quality_description": "Qualität der Vorschaubilder von 1-100. Höher ist besser, erzeugt aber grössere Dateien und kann die Reaktionsfähigkeit der App beeinträchtigen.",
"image_thumbnail_title": "Einstellungen für Vorschaubilder",
"import_config_from_json_description": "Systemkonfiguration von hochgeladener JSON-Konfigurationsdatei importieren",
"job_concurrency": "{job} (Anzahl gleichzeitig laufende Prozesse)",
"job_created": "Aufgabe erstellt",
"job_not_concurrency_safe": "Diese Aufgabe kann nicht mehrmals parallel laufen gelassen werden.",
"job_settings": "Aufgabeneinstellungen",
"job_settings_description": "Gleichzeitige Ausführung von Aufgaben verwalten",
"jobs_over_time": "Jobs im Laufe der Zeit",
"library_created": "Bibliothek erstellt: {library}",
"library_deleted": "Bibliothek glöscht",
"library_details": "Bibliotheks-Details",
"library_folder_description": "Gib en Order zum Importiere a. Dä Order mit sine Underordner wird nach Bildli und Videos durchsucht.",
"library_remove_exclusion_pattern_prompt": "Bisch sicher, dass das Uuschluss-Muster wotsch lösche?",
"library_remove_folder_prompt": "Bisch sicher, dass dä Import-Ordner wotsch lösche?",
"library_scanning": "Regelmässigi Überprüefig"
"library_deleted": "Bibliothek gelöscht",
"library_details": "Bibliotheksdetails",
"library_folder_description": "Wähle einen Ordner zum Importieren. Dieser Ordner wird inklusive Unterordnern nach Bildern und Videos durchsucht.",
"library_remove_exclusion_pattern_prompt": "Bilst du sicher, dass du dieses Ausschlussmuster entfernen möchtest?",
"library_remove_folder_prompt": "Bist du sicher, dass du diesen Import-Ordner entfernen möchtest?",
"library_scanning": "Regelmässiges Scannen"
}
}

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