Compare commits

...
Author SHA1 Message Date
Codex d3571f788e test(web): include modal manager in local query mocks 2026-11-07 21:44:40 +03:00
Codex e09159b389 chore: prepare local.9 web and Android release 2026-11-07 21:38:01 +03:00
Santo Shakil 6cce8efed8 fix(mobile): handle links to locked and trashed photos (#33034)
* fix(mobile): handle links to locked and trashed photos

* use a real timeline service in the deep link test

(cherry picked from commit 87ef895041e160aef4f61f2d7af1ad3f8e38827d)
2026-11-07 21:31:45 +03:00
Santo Shakil 8b7c9d7328 fix(mobile): ignore taps on a button while its action runs (#33029)
(cherry picked from commit 3c9d190f4c6c4112f67a3cbdfabc8ff8a3d4a527)
2026-11-07 21:31:45 +03:00
Jason Rasmussen 499433a2c6 refactor: web mutation confirm (#33055)
(cherry picked from commit 511c3a9883458ae2e675ec82c3153d1e0620c45a)
2026-11-07 21:31:45 +03:00
Jason Rasmussen 55df2bb2bf chore: add tests for confirm flows (#33054)
(cherry picked from commit 71e37b154e359328a61d1952c0da046cc5cc93c2)
2026-11-07 21:31:45 +03:00
Jason Rasmussen 48cfafd1c1 refactor: web tag queries (#33053)
(cherry picked from commit c5603ac987c13ad5e5888bac0bd144b31149afbb)
2026-11-07 21:31:44 +03:00
Jason Rasmussen 03ff181af3 fix(web): tag navigation from a missing path (#33052)
(cherry picked from commit f42c393d05a26aa5d0f8890aa8fc474f85a73fd8)
2026-11-07 21:31:44 +03:00
Jason Rasmussen cca2dd7ece chore: add regression tests for tags (#33051)
(cherry picked from commit 7b7b5b8c5a1c13f65bb5696e7e06908b41a00e59)
2026-11-07 21:31:44 +03:00
Jason Rasmussen 12b2c6ac0a refactor: web search queries (#33050)
(cherry picked from commit e4f120e4bbc5e98b074a340b75fa02fd6d6d9468)
2026-11-07 21:31:44 +03:00
Jason Rasmussen 8b6e4940f4 fix(web): search for unknown city (#33047)
(cherry picked from commit ce21de370a516b92afcee5188fda6b9055949b98)
2026-11-07 21:31:44 +03:00
Jason Rasmussen 865da219c9 chore: add regression tests for search (#33044)
* chore: add regression tests for search

* chore: add search filter and people rename tests

* chore: add search, explore, location and person tests

(cherry picked from commit 635f908c84d0f7b471d85f60aa4ead2cc8580316)
2026-11-07 21:31:44 +03:00
Jason Rasmussen d68c988981 refactor: web queue queries (#33042)
(cherry picked from commit 0cb2ab23b8c1669cc0a39e2247c62bdaceb0150d)
2026-11-07 21:31:44 +03:00
Codex c8c6fc3837 fix(android): keep native decoder within local texture bound 2026-11-06 22:41:20 +03:00
Codex f378567c6c test: make backup paths portable and wait for duplicate navigation 2026-11-06 22:03:34 +03:00
Codex c08c9d9205 chore: prepare local.8 and preserve local defaults 2026-11-06 21:48:15 +03:00
Jason Rasmussen 6520f7820b refactor: web activity queries (#33038) 2026-11-06 21:37:35 +03:00
Jason Rasmussen a8786e5f17 chore: test activity of trashed assets (#33036) 2026-11-06 21:37:34 +03:00
Paul Makles 457776898c feat(server): use Immich database for backups state (#33028)
* feat(server): run FUTO Backups on the Immich database

Assisted-by: AI

* chore(server): ignore FUTO Backups tables in schema checks

Assisted-by: AI

* feat(web): finish maintenance once FUTO Backups restores the database

Assisted-by: AI

* chore: bump backups

* fix: restore is part of backups now
2026-11-06 21:37:34 +03:00
Jason Rasmussen 8077424a53 chore: add regression tests for activity (#33035) 2026-11-06 21:36:57 +03:00
Jason Rasmussen 4138ce749d refactor: web stack queries (#33031)
* refactor: web stack queries

* fix: unstack bug
2026-11-06 21:36:57 +03:00
Jason Rasmussen 094d65c4bc chore: add regression tests for recent stack bugs (#33020) 2026-11-06 21:36:57 +03:00
Santo Shakil 06c41fdcac fix(mobile): show memories right after the first login (#33030) 2026-11-06 21:36:57 +03:00
shenlongandshenlong-tanwen d25a353d94 chore: pump flutter to 3.47.9 (#33006)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-11-06 21:36:57 +03:00
Kevin Park 11eb6eaff3 feat(server): count finished jobs per queue in telemetry (#33025) 2026-11-06 21:35:53 +03:00
Jason Rasmussen 57c45dbb72 fix(web): isolate unit test storage (#33021) 2026-11-06 21:35:53 +03:00
Stellan Sun a5678e0d24 fix(mobile): prevent spam taps in memories viewer (#32869)
* fix(mobile): improve memory tap navigation

* fix(mobile): preserve memory animations and simplify tap locking
2026-11-06 21:35:53 +03:00
Santo Shakilandmertalev b3414d7595 feat(mobile): avif decoder (#32809)
* feat(mobile): avif decoder

* decode avif in the core first on android, tone-map hdr like jxl and support premultiplied alpha

* decode avif with rav1d's safe api

* use rav1d's assembly

* thread avif decodes by their tile count

* convert colour-managed avif in bands

* use the codec dependencies' simd kernels

* free avif pictures before resizing

* map avif alpha through a table

* premultiply avif while writing alpha

* drop the avif container after decoding

* use core avif decoder on ios

* fall back to imageio on ios when the core can't decode an avif

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-11-06 21:35:53 +03:00
Jason Rasmussen 012eef2a80 fix: add name to .github package.json (#33019)
Workspace tooling requires every workspace package to have a name.
2026-11-06 21:35:07 +03:00
Jason Rasmussen 7fb3de7bde ci: retry registry rate limit (#33018) 2026-11-06 21:35:07 +03:00
Jason Rasmussen c0170f4abf chore: add tests for admin and map page regressions (#33017) 2026-11-06 21:35:07 +03:00
Jason Rasmussen 4fdc9b69fa chore: add web workflow regression tests (#33016) 2026-11-06 21:35:07 +03:00
Jason Rasmussen 7459863a87 chore: tests for web regressions (#33015) 2026-11-06 21:35:07 +03:00
Jason Rasmussen 2196435a9e chore: never gc public queries (#33014) 2026-11-06 21:35:07 +03:00
renovate[bot] ef96d6cb84 chore(mise): update tool restic (0.19.0 → 0.19.1) (#32892)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 21:35:07 +03:00
Santo Shakil a3d2cf5b04 fix(mobile): delete leftover share files when the app starts (#32989)
* fix(mobile): delete leftover share files when the app starts

A share's downloaded file is deleted when the share sheet closes. If the
app is killed with the sheet still open, that never runs and the file
stays in the temp folder for good.

* clean up share downloads from the downloader's records

* build the share folder list with Future.wait
2026-11-06 21:35:07 +03:00
Jason Rasmussen b3d8c1bc43 refactor: sync response check (#33010) 2026-11-06 21:35:07 +03:00
Daniel Dietzler d569e28d01 fix: web import ordering (#32995) 2026-11-06 21:34:27 +03:00
renovate[bot]andDaniel Dietzler 768dbe8259 chore(deps): update node.js to v26.13.0 (#32914)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-11-06 21:33:59 +03:00
renovate[bot]andDaniel Dietzler 27ce131134 chore(npm): update node (major) (#32918)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-11-06 21:33:21 +03:00
renovate[bot] f16e6c753a chore(deps): update machine-learning (#32885)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 21:33:04 +03:00
Codex 3248d24d28 style: format browser date helper 2026-11-05 21:40:23 +03:00
Codex 197f575673 test: isolate browser preferences and normalize date input 2026-11-05 21:32:12 +03:00
Codex 3b157096fd test: align passkey environment and browser regression checks 2026-11-05 21:05:51 +03:00
Codex 062ae648d7 feat: support local Android passkeys and prepare local.7 2026-11-05 21:00:26 +03:00
Jason Rasmussen 8e77a8c6f8 refactor: web tests (#32983)
* refactor: web tests

* fix: e2e ratelimits

* fix: prefer data-testid

* chore: pr feedback
2026-11-05 20:36:37 +03:00
Jason Rasmussen 9776c0f4f4 refactor: storage medium tests (#33007) 2026-11-05 20:35:55 +03:00
Daniel Dietzler 0908d1af7b chore: kysely safe null comparison plugin (#32990) 2026-11-05 20:35:38 +03:00
renovate[bot] 08d9ef733c chore(npm): update dependency exiftool-vendored (39.0.1 → 39.1.0) (#32899)
* chore(npm): update dependency exiftool-vendored (39.0.1 → 39.1.0)

* fix: lockfile

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
# Conflicts:
#	pnpm-lock.yaml
2026-11-05 20:35:38 +03:00
renovate[bot] 6851091403 chore(mise): update tool java (21.0.2 → 21.0.13+9.0.lts) (#32891)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 20:35:17 +03:00
Santo Shakil 11e9a4b4da fix(mobile): clear the app's cookies on logout (#32965) 2026-11-05 20:35:16 +03:00
renovate[bot] 9b5e1aae5b chore(container): update image grafana/grafana (13.2.4-ubuntu → 13.3.0-ubuntu) (#32896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 20:35:16 +03:00
Santo Shakil 7aba6c3f88 feat(mobile): delete passkeys (#32785)
* feat(mobile): delete passkeys

* fix(mobile): refresh the passkey list from the store after a delete
2026-11-05 20:35:16 +03:00
Santo Shakil f727e8edb4 feat(mobile): rename passkeys (#32784)
* feat(mobile): rename passkeys

* fix(mobile): refresh the passkey list from the store after a rename

* fix(mobile): skip the name dialog after creating a passkey
2026-11-05 20:35:16 +03:00
Santo Shakil cfa416e612 feat(mobile): list passkeys (#32783)
* feat(mobile): list passkeys

* fix(mobile): show the passkey list as the server's passkeys

* fix(mobile): reword and align the passkey list, refresh it from the store
2026-11-05 20:35:16 +03:00
Santo Shakil 22e7b20d3e feat(mobile): login with passkey (#32782)
* feat(mobile): login with passkey

* feat(mobile): use the server feature level on the login page

* refactor(mobile): share the steps after a login

* password, OAuth and passkey logins end with one finishLogin
* OAuth now also connects the websocket, like the password login

* fix(mobile): passkey login through the shared login steps

* fix(mobile): build the login response through its constructor
2026-11-05 20:35:16 +03:00
Santo Shakil 28a52948a9 feat(mobile): passkeys page with new passkey (#32781)
* feat(mobile): passkeys page with new passkey

* feat(mobile): passkeys entry under settings

* show it in settings when logged in and passkeys are on
* remove it from the profile menu

* feat(mobile): use the server feature level for passkeys

* hide passkeys below the passkeys feature level, without asking the server
* keep the server's feature level on ServerVersion

* fix(mobile): passkeys as a settings section through the store

* fix(mobile): filter the settings sections in SettingSection

* fix(mobile): remove the passkeys description and use the settings padding
2026-11-05 20:35:16 +03:00
Santo Shakil 63df351cad feat(mobile): passkey bridge for ios (#32780)
* feat(mobile): passkey bridge for ios

* fix(mobile): ios passkey bridge in one file

* merge Passkeys.swift into PasskeyHostApiImpl.swift
* set up the host api on the main engine only
* map the json with Codable structs, with base64url in the coder data strategies
* throw on a missing attestation object or an unexpected credential type
* need iOS 17.4, where a passkey registration can exclude the account's passkeys

* fix(mobile): ios passkeys from ios 16

Passkeys are on from iOS 16 again. The platform registration only takes
excludedCredentials from iOS 17.4, so that check keeps its own comment.
2026-11-05 20:35:01 +03:00
Santo Shakil f2f2093e41 feat(mobile): passkey bridge for android (#32779)
* feat(mobile): passkey bridge for android

* fix(mobile): passkey plugin on the main engine only

* register it in configureFlutterEngine and cancel its scope on detach
* pass coroutine cancellation through instead of treating it as an error
* check for Play services 23.08+ before Android 14 in isSupported
* explain why NotAllowedError counts as a cancel
* share one perform function between register and authenticate
2026-11-05 20:35:01 +03:00
renovate[bot] 99cda00260 chore(mise): update tool terragrunt (1.2.0 → 1.2.1) (#32893)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 20:34:43 +03:00
renovate[bot] a1a47d909c chore(container): update image node (24.22.1 → 24.22.2) (#32887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 20:34:43 +03:00
Daniel Dietzler 50cb08f35d chore: migrate server import sorting back to prettier (#32994) 2026-11-05 20:34:42 +03:00
Santo Shakil 17ad3a2e8a fix(mobile): show view in timeline for photos opened from a deep link (#33001) 2026-11-05 20:34:15 +03:00
Adam Gastineau faaf0e410b chore(android): Build Flutter config before Fastlane builds (#33000) 2026-11-05 20:34:15 +03:00
Ashwani AgarwalandDaniel Dietzler 558ed71b7d feat: keyboard shortcuts for person page (#31679)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-11-05 20:34:14 +03:00
bo0tzz 2c4d581495 fix(ml): include the error when a model fails to load (#32985) 2026-11-05 20:34:14 +03:00
Daniel Dietzler b7b361bed1 fix: asset viewer reactivity (#32978) 2026-11-05 20:34:14 +03:00
Thomas Günther 25d368790f fix(web): smoother asset viewer gradients (#32946)
* Smoother asset viewer gradients

* Use Tailwind utilities for the gradient masks
2026-11-05 20:34:14 +03:00
Codex 93f60cd2dc chore: prepare local.6 and regenerate API clients 2026-11-04 21:11:35 +03:00
Mees Frensel beb49e8005 chore(web): refactor help and support modal (#32835) 2026-11-04 20:47:09 +03:00
bo0tzz a5c8af3791 fix: revert terraform cloudflare update to v5 (#32973) 2026-11-04 20:47:09 +03:00
Santo Shakil fae436a03c fix(mobile): log out before resetting the local database (#32928)
* fix(mobile): log out before resetting the local database

* fix(mobile): use a stateful widget for the reset state instead of hooks

* fix(mobile): run the reset tests' db work on the real clock
2026-11-04 20:47:09 +03:00
Daniel Dietzler 87aed8305a refactor(web): migrate from use: to attachments (#32968) 2026-11-04 20:47:09 +03:00
Daniel Dietzler c3cec81b0a fix(web): scroll memory (#32964) 2026-11-04 20:47:09 +03:00
Mert 6dbc3b4cb3 fix(server): build sharp in dev stack (#32961)
build sharp in dev stack
2026-11-04 20:47:08 +03:00
bo0tzz 9ec66cbe79 fix(server): validate motion photo video range before extraction (#32931) 2026-11-04 20:47:08 +03:00
Snowknight26 1d6947b512 fix(server): read only valid ISO values (#32938)
* fix(server): read only valid ISO values

* Add an extra test case

* Add extra test cases with undefined values
2026-11-04 20:47:08 +03:00
Michael Kentandmertalev 607a18ffff fix(ml): disable onnxruntime telemetry (#32956)
* fix(ml): disable onnxruntime telemetry

* cleanup

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-11-04 20:47:08 +03:00
Jason Rasmussen 3a5d842ac1 fix: medium test flake (#32944) 2026-11-04 20:47:08 +03:00
Jason Rasmussen 9632838132 fix: rate limits (#32943) 2026-11-04 20:47:08 +03:00
Daniel Dietzler 4c4ed38353 fix: people page websockets (#32940) 2026-11-04 20:46:45 +03:00
Daniel Dietzler 657f24550c fix: web error handler (#32935) 2026-11-04 20:46:45 +03:00
Jason Rasmussen 4f009feec5 fix: backups codegen (#32839) 2026-11-04 20:46:45 +03:00
Daniel Dietzler f3f56bb5e8 fix: oauth regressions (#32934) 2026-11-04 20:46:33 +03:00
Codex b5da1f06fb test(web): isolate browser preferences and stabilize date input 2026-11-03 20:13:03 +03:00
Codex d3d5374a10 chore: integrate upstream fixtures and refresh dependency lock 2026-11-03 19:57:10 +03:00
renovate[bot] eb20a2e05a chore(github-action): update github-actions (#32898) 2026-11-03 19:40:25 +03:00
renovate[bot] 78a0982999 chore(container): pin image ghcr.io/immich-app/futo-backups-restic-proxy to 04a9313 (#32883) 2026-11-03 19:39:55 +03:00
renovate[bot] d8cc86ca42 chore(container): update image docker.io/valkey/valkey (418652c → 3add1a3) (#32884) 2026-11-03 19:39:55 +03:00
renovate[bot] 94c825a92a chore(container): update image ghcr.io/jdx/mise (2026.10.20 → 2026.10.25) (#32886) 2026-11-03 19:39:39 +03:00
renovate[bot]andDaniel Dietzler a8032b2e6d chore(npm): update typescript-projects (#32894)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-11-03 19:39:38 +03:00
Jason Rasmussen e14fcd0a52 feat: web medium tests (#32817)
* feat: web medium tests

* chore: pr feedback

* fix: merge
2026-11-03 19:39:30 +03:00
Codex b10d600e23 fix(web): restore fonts, upload worker and album layout updates
Apply upstream fixes from 5ebda1f0b9b109c9098026866a354f0abb4823a8, 8d662d75791f6a67effacbf9e54cbefc92e8d313 and a9c4e0ed65757279fdade2b0126c10a018286901.
2026-11-02 20:46:50 +03:00
Codex 18e5e1f75d fix: preserve OCR search and align album sort labels 2026-11-01 14:50:46 +03:00
Codex 2800192c76 feat: add private file storage for web and Android 2026-10-31 23:07:09 +03:00
Codex 916718b558 fix(web): normalize maintenance backup test formatting 2026-10-31 07:55:35 +03:00
Codex 8a5ef4c96e Prepare local 3.4.0 web and Android release 2026-10-31 07:42:26 +03:00
Santo Shakil a3a52a43ca fix(mobile): show 10-bit heic and avif originals on ios (#32821)
(cherry picked from commit ca25e8becc4bfb18d63ca7920d26a984eae0e16f)
2026-10-31 07:23:57 +03:00
Jason Rasmussen e15bdf9af3 fix(ci): release notes script (#32827)
(cherry picked from commit f47b02f91d6d55e0b0281609c37e6b1676626115)
2026-10-31 07:23:57 +03:00
Zack Pollard b3fe177869 feat: futo backups 1.12.1 with restic's parallel scan (#32830)
(cherry picked from commit ca99e909199cfcec87a6e5b613a94bde23847249)
2026-10-31 07:23:57 +03:00
Zack Pollard f5f154f2d8 fix(server): single library exclusion matcher (#32081)
(cherry picked from commit c3dee6f98b7a231adc3d0716a287c4b47bc69d3e)
2026-10-31 07:23:13 +03:00
Zack Pollard abfb6c0a0e fix(server): keep user motion files on embedded motion replace (#32079)
(cherry picked from commit ddb799e0a44d112ecacc4867780f31bb8f71dc4f)
2026-10-31 07:22:45 +03:00
Zack Pollard 91e416b104 fix(server): live photo match library (#32080)
(cherry picked from commit 05658ecba8679f54c4b95deeec3940b54ab0ecf7)
2026-10-31 07:22:45 +03:00
Zack Pollard 92cf23b642 fix(server): compress database backups on 75% of the cores, up to 32 (#32823)
(cherry picked from commit 02c0d4d392217b02b670afb0b4ce9941330120ec)
2026-10-31 07:22:45 +03:00
Yaros e708a80474 fix(mobile): don't block startup when offline (#32652)
* fix(mobile): don't block startup when offline

* chore: implement suggestions

---------

Co-authored-by: Santo Shakil <santoshakil@users.noreply.github.com>
(cherry picked from commit 32b137ab3713545324873f552adb51b9ef2aecde)
2026-10-31 07:22:45 +03:00
arno0392 276d5620fd fix(server): convert to nv12 before hwupload for VAAPI with software decoding (#32474)
* fix(server): convert to nv12 before hwupload for VAAPI with software decoding

* test(server): cover nv12 conversion for VAAPI sw decode

* output nv12 directly

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
(cherry picked from commit 4bdc4480b2771a5a0dfed26e1149d680f6c204d9)
2026-10-31 07:22:45 +03:00
Jason Rasmussen 11900d5a8c chore: add CLAUDE.md (#32801)
* chore: add CLAUDE.md

* chore: pr feedback

* chore: contributing guideline rules

* chore: pr feedback

(cherry picked from commit fc30140e6ec313c94405e7ca4fbab8096dc1c6c7)
2026-10-31 07:22:45 +03:00
immich-push-o-matic[bot] 5dfa7214df chore: version v3.4.0-rc.0
(cherry picked from commit 81778c8c4a9e015511af21eada4bbf829296580d)
2026-10-31 07:22:44 +03:00
Weblate (bot) 6808ed3239 chore(web): update translations (#32256)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/af/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bn/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/cs/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/da/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/de/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/de_CH/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/el/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/en_GB/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/eo/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/et/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fa/
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/ga/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/gl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/he/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hu/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ja/
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/nb/
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/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ro/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ru/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sk/
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/ta/
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/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: Adam Havránek <helak@users.noreply.hosted.weblate.org>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@users.noreply.hosted.weblate.org>
Co-authored-by: Ali Najafi <alinajafi321@users.noreply.hosted.weblate.org>
Co-authored-by: Andi Chandler <andibing@users.noreply.hosted.weblate.org>
Co-authored-by: Artem Grauberger <artart241@users.noreply.hosted.weblate.org>
Co-authored-by: Azharul Haque <drhaque@users.noreply.hosted.weblate.org>
Co-authored-by: Benedict Leuchtenberger <beyney@users.noreply.hosted.weblate.org>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: DevServs <devservs@users.noreply.hosted.weblate.org>
Co-authored-by: Diego Nunnari <danunnari@gmail.com>
Co-authored-by: Donatas I <dilidonas@users.noreply.hosted.weblate.org>
Co-authored-by: Dragos Miron <dragosmr@gmail.com>
Co-authored-by: Eduard Ereza Martínez <ereza@users.noreply.hosted.weblate.org>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Geisler Dias <geisler@users.noreply.hosted.weblate.org>
Co-authored-by: Gideon Wentink <gjwentink@users.noreply.hosted.weblate.org>
Co-authored-by: HackingAll <hacking.all.YT@gmail.com>
Co-authored-by: HackingAll <hackingallyt@users.noreply.hosted.weblate.org>
Co-authored-by: HaikerMaiker <haikermaiker@users.noreply.hosted.weblate.org>
Co-authored-by: Hans Cats <hanscats@gmail.com>
Co-authored-by: Hans Cats <hanscats@users.noreply.hosted.weblate.org>
Co-authored-by: Happy <59247878+happy2452354@users.noreply.github.com>
Co-authored-by: Hosted Weblate user 165391 <neo0732@users.noreply.hosted.weblate.org>
Co-authored-by: Hurricane_32 <hurricane_32@users.noreply.hosted.weblate.org>
Co-authored-by: Hurricane_32 <rodrigorimo@hotmail.com>
Co-authored-by: Hồ Nhất Duy <axicenia@gmail.com>
Co-authored-by: Idan <idan062@gmail.com>
Co-authored-by: Idan <idanlights@users.noreply.hosted.weblate.org>
Co-authored-by: Indrek Haav <indrekhaav@users.noreply.hosted.weblate.org>
Co-authored-by: Ivan Dimitrov <idimitrov08@users.noreply.hosted.weblate.org>
Co-authored-by: Jani Buni <alejandrolage@outlook.com>
Co-authored-by: Javier Villanueva García <jajajavi2203@users.noreply.hosted.weblate.org>
Co-authored-by: Jean Dumoulin <dumoulin.jea@users.noreply.hosted.weblate.org>
Co-authored-by: Jozef Gaal <preklady@mayday.sk>
Co-authored-by: Kisnov <gabrielmarfil0@gmail.com>
Co-authored-by: Kisnov <kisnov@users.noreply.hosted.weblate.org>
Co-authored-by: Kurt Inge Smådal <kurtinge@users.noreply.hosted.weblate.org>
Co-authored-by: Lars Winkelmann <lars.winkelmann@users.noreply.hosted.weblate.org>
Co-authored-by: Lightning_A <lightningaa@users.noreply.hosted.weblate.org>
Co-authored-by: Luis Fernando Illapa <bymoxb@users.noreply.hosted.weblate.org>
Co-authored-by: Marty Etoolie <elioyart@users.noreply.hosted.weblate.org>
Co-authored-by: Matjaž T. <blaze666@users.noreply.hosted.weblate.org>
Co-authored-by: Matjaž T. <matjaz@moj-svet.si>
Co-authored-by: Mees Frensel <meesfrensel@users.noreply.hosted.weblate.org>
Co-authored-by: Mārtiņš Bruņenieks <papuass@users.noreply.hosted.weblate.org>
Co-authored-by: Nagy Krisztián <nkgy17@gmail.com>
Co-authored-by: Nagy Krisztián <nkgy17@users.noreply.hosted.weblate.org>
Co-authored-by: Nicola Bortoletto <translate@nicolabortoletto.com>
Co-authored-by: Nimrod Milo <nimrod@honeybook.com>
Co-authored-by: Nitai <nitai9h@users.noreply.hosted.weblate.org>
Co-authored-by: Noé Brunner <nobrunner@hotmail.com>
Co-authored-by: Olaf Nielsen <solluh@users.noreply.hosted.weblate.org>
Co-authored-by: Oğuz Ersen <ersen@users.noreply.hosted.weblate.org>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: PPNplus <ppnplus@protonmail.com>
Co-authored-by: PPNplus <ppnplus@users.noreply.hosted.weblate.org>
Co-authored-by: PanSketo <pansketo@users.noreply.hosted.weblate.org>
Co-authored-by: Para <ahh-produktivitet@tutanota.com>
Co-authored-by: Pranav Sharma <pranavsharma-io@users.noreply.hosted.weblate.org>
Co-authored-by: Ricky991108 <ricky991108@users.noreply.hosted.weblate.org>
Co-authored-by: Setynus <setynus@users.noreply.hosted.weblate.org>
Co-authored-by: Shawn <xiaxinx@gmail.com>
Co-authored-by: Stefano Sisti <sisti86@users.noreply.hosted.weblate.org>
Co-authored-by: Suu <suu@users.noreply.hosted.weblate.org>
Co-authored-by: Sylvain Pichon <sp_@users.noreply.hosted.weblate.org>
Co-authored-by: Tijs-B <tijs-b@users.noreply.hosted.weblate.org>
Co-authored-by: User 123456789 <user123456789@users.noreply.hosted.weblate.org>
Co-authored-by: VeryUsual <72707293+VeryUsual@users.noreply.github.com>
Co-authored-by: Yoshi <yoshi@users.noreply.hosted.weblate.org>
Co-authored-by: Yuta Tu <yuta_tu@hotmail.com>
Co-authored-by: abergui <abergui@users.noreply.hosted.weblate.org>
Co-authored-by: asdwak <liborsenar1@users.noreply.hosted.weblate.org>
Co-authored-by: bittin1ddc447d824349b2 <bittin1ddc447d824349b2@users.noreply.hosted.weblate.org>
Co-authored-by: elron27 <elron27@users.noreply.hosted.weblate.org>
Co-authored-by: eyrewiut <eyrewiut@users.noreply.hosted.weblate.org>
Co-authored-by: granary17 <granary17@users.noreply.hosted.weblate.org>
Co-authored-by: jstmrby <jstmrby@users.noreply.hosted.weblate.org>
Co-authored-by: jutn-dev <jutn-dev@users.noreply.hosted.weblate.org>
Co-authored-by: madsULF <mads@ulf.dk>
Co-authored-by: nut491 <nut491@users.noreply.hosted.weblate.org>
Co-authored-by: omegadl2 <omegadl@users.noreply.hosted.weblate.org>
Co-authored-by: prabakaran k <prabakaranstk@gmail.com>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
Co-authored-by: waclaw66 <waclaw66@users.noreply.hosted.weblate.org>
(cherry picked from commit 2c3fc559ef01036ce012028a19a3729e6bb8f5d7)
2026-10-31 07:22:08 +03:00
Paul Makles 925aaed54c feat: backups (#32268)
Signed-off-by: izzy <me@insrt.uk>
Co-authored-by: Zack Pollard <zack@futo.org>
(cherry picked from commit 177b57b689be293bad96b8092e473dc03e068e9c)
2026-10-31 07:21:01 +03:00
1030 changed files with 27373 additions and 16595 deletions

No files matched your search

@@ -7,6 +7,7 @@ log "Preparing Immich Nest API Server"
log ""
export CI=1
run_cmd pnpm --filter immich install
SHARP_FORCE_GLOBAL_LIBVIPS=true run_cmd pnpm --config.verify-deps-before-run=false --dir "${IMMICH_WORKSPACE}/server/node_modules/sharp" run build
log "Starting Nest API Server"
log ""
+2
View File
@@ -1,4 +1,6 @@
{
"name": "immich-github",
"private": true,
"scripts": {
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different ."
+1 -1
View File
@@ -230,7 +230,7 @@ jobs:
working_directory: ./mobile
- name: Setup Ruby
uses: ruby/setup-ruby@e1bd6146574da5e0795075b5319b300edb30c5b0 # v1.333.0
uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0
with:
ruby-version: '3.4'
bundler-cache: true # zizmor: ignore[cache-poisoning] release branches cannot read caches written by pull requests
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@9ce3a29eb0e6478dd0140932a678c7fb59d08f22 # v0.1.20
uses: oasdiff/oasdiff-action/breaking@9e56cf8b3aff7edb326d5767d3d23f96127e20c3 # v0.1.21
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+1 -1
View File
@@ -83,7 +83,7 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:ef9eab1055b07aed9111b6dd3f0d70e1b2e08c862d982d4f4560d6353c9e2b9d
image: ghcr.io/immich-app/mdq:main@sha256:7ed8dd09ce9f35b6aca5c098a184ab7be3fc8292ee193ccecd9c9ca4cd267da7
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:
+4 -4
View File
@@ -107,7 +107,7 @@ jobs:
runner-mapping: '{"linux/amd64": "pokedex-large"}'
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -134,7 +134,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -217,14 +217,14 @@ jobs:
SUFFIX: ${{ matrix.suffixes }}
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+3 -3
View File
@@ -154,8 +154,8 @@ jobs:
previous=$(grep -A1 -xF "${TAG}" <<< "${tags}" | tail -1)
echo "generating notes from ${previous}"
mise //:notes --tag "${TAG}" --previous "${previous}" > "${RUNNER_TEMP}/notes.md"
cat "${RUNNER_TEMP}/notes.md"
mise //:notes --tag "${TAG}" --previous "${previous}"
cat misc/release/notes.md
- name: Create draft release
env:
@@ -171,7 +171,7 @@ jobs:
--draft \
--verify-tag \
--prerelease="${PRERELEASE}" \
--notes-file "${RUNNER_TEMP}/notes.md" \
--notes-file misc/release/notes.md \
docker/docker-compose.yml \
docker/docker-compose.rootless.yml \
docker/example.env \
+106 -33
View File
@@ -17,7 +17,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -80,7 +80,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -109,7 +109,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -141,7 +141,7 @@ jobs:
working-directory: ./packages/cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -173,7 +173,7 @@ jobs:
working-directory: ./packages/cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -218,7 +218,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -257,7 +257,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -277,6 +277,47 @@ jobs:
- name: Run ci-unit
run: mise run ci-unit
web-medium-tests:
name: Test Web (Medium)
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@caac7f7e4298458a14238959504f411fc6cc7f06 # use-mise-action-v3.2.3
with:
github_token: ${{ steps.token.outputs.token }}
- name: Run ci-medium
run: mise run ci-medium
- name: Archive medium test results
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: failure()
with:
name: web-medium-test-results
path: |
web/playwright-report/
web/test-results/
i18n-tests:
name: Test i18n
needs: pre-job
@@ -286,7 +327,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -337,7 +378,7 @@ jobs:
working-directory: ./e2e
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -365,16 +406,18 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
defaults:
run:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
permission-packages: read
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -388,6 +431,13 @@ jobs:
with:
github_token: ${{ steps.token.outputs.token }}
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ steps.token.outputs.token }}
- name: Run ci-medium
run: mise run ci-medium
if: ${{ !cancelled() }}
@@ -399,6 +449,7 @@ jobs:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: read
defaults:
run:
working-directory: ./e2e
@@ -407,11 +458,12 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
permission-packages: read
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -442,9 +494,23 @@ jobs:
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ steps.token.outputs.token }}
- name: Start Docker Compose
id: docker
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
shell: bash
run: |
for attempt in 1 2 3 4 5; do
docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 2>&1 | tee "${RUNNER_TEMP}/docker-compose-up.log" && exit 0
grep -qiE 'toomanyrequests|retry-after' "${RUNNER_TEMP}/docker-compose-up.log" || exit 1
sleep $((attempt * 15))
done
exit 1
if: ${{ !cancelled() }}
- name: Run e2e tests (api & cli)
@@ -478,6 +544,7 @@ jobs:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: read
defaults:
run:
working-directory: ./e2e
@@ -486,11 +553,12 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
permission-packages: read
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -521,9 +589,23 @@ jobs:
run: pnpm exec playwright install chromium --only-shell
if: ${{ !cancelled() }}
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ steps.token.outputs.token }}
- name: Docker build
id: docker
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
shell: bash
run: |
for attempt in 1 2 3 4 5; do
docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 2>&1 | tee "${RUNNER_TEMP}/docker-compose-up.log" && exit 0
grep -qiE 'toomanyrequests|retry-after' "${RUNNER_TEMP}/docker-compose-up.log" || exit 1
sleep $((attempt * 15))
done
exit 1
if: ${{ !cancelled() }}
- name: Run e2e tests (web)
@@ -539,19 +621,6 @@ jobs:
name: e2e-web-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- name: Run ui tests (web)
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web:ui
if: ${{ !cancelled() && steps.docker.outcome == 'success' }}
- name: Archive ui test (web) results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success() || failure()
with:
name: e2e-ui-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- name: Run maintenance tests
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
@@ -596,7 +665,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -634,7 +703,7 @@ jobs:
working-directory: ./machine-learning
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -665,7 +734,7 @@ jobs:
working-directory: ./.github
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -696,7 +765,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -718,7 +787,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -763,9 +832,13 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
services:
postgres:
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:dbf18b3ffea4a81434c65b71e20d27203baf903a0275f4341e4c16dfd901fd67
credentials:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
@@ -776,7 +849,7 @@ jobs:
- 5432:5432
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
uses: immich-app/devtools/actions/create-workflow-token@2760f037fd3707bb91b16e4c41dc62b8c512393e # create-workflow-token-action-v3.1.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1
View File
@@ -28,3 +28,4 @@ vite.config.js.timestamp-*
.devcontainer/.env*
*.tsbuildinfo
*.tsbuildInfo
misc/release/notes.md
+1 -1
View File
@@ -1 +1 @@
24.22.1
26.13.0
+2 -2
View File
@@ -13,7 +13,7 @@
"editor.wordBasedSuggestions": "off"
},
"[javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[json]": {
@@ -30,7 +30,7 @@
"tailwindCSS.lint.suggestCanonicalClasses": "ignore"
},
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"cSpell.words": ["immich"],
+21
View File
@@ -0,0 +1,21 @@
# AGENTS.md
Immich monorepo: `server/` (NestJS API + job workers), `web/` (SvelteKit SPA), `mobile/` (Flutter), `native/` (Rust core for mobile), `machine-learning/` (FastAPI + ONNX), `e2e/` (API + Playwright tests), `packages/` (`@immich/sdk`, `@immich/cli`, plugin SDK/core, release scripts), `open-api/`, `i18n/`, `docs/`. Before changing code in `server/`, `web/`, `mobile/`, `native/`, `machine-learning/` or `e2e/`, read that folder's `AGENTS.md`.
## Rules
- No PRs of LLM-generated code; the human contributor must understand and be able to explain every change (see `CONTRIBUTING.md`).
- Do not generate PR titles, descriptions, issue text, review comments or discussion answers. Translating human-written text is fine.
- Never work on issues labelled `good first issue`; those PRs are closed automatically.
- Run every build, lint, test and codegen task through mise: `mise //<dir>:<task>`, root tasks as `mise //:<task>`. `mise tasks ls --all` lists them.
- Never hand-edit generated files: `open-api/immich-openapi-specs.json`, `packages/sdk/src/fetch-client.ts`, `mobile/generated/openapi/`, `server/src/queries/*.sql`, and Dart codegen (`*.g.dart`, `*.drift.dart`, `*.freezed.dart`, `router.gr.dart`, `lib/generated/`, `lib/platform/*.g.dart`).
- Only edit `i18n/en.json`; the other locale files come from Weblate.
- Do not add code comments. Make the code self-explanatory through naming and structure instead.
- PR titles are conventional commits. Keep a PR to one focused change.
- Sharing/asset ownership and external libraries are feature-frozen: bug fixes only.
- Renaming or removing a docs page requires an entry in `docs/static/_redirects`.
## Skills
- **Regenerate the API clients**: after any change to a server controller or DTO, run `mise //:open-api`. It rebuilds the server, rewrites the OpenAPI spec, regenerates the TypeScript SDK and the Dart client. The CI job "OpenAPI Clients" fails if this is stale. If a response field became required, the Dart client also needs a backward-compatibility patch in `open-api/patch`, checked by `flutter test test/openapi_patches_coverage.dart` (job "check-mobile-patches").
- **Add a translation string**: add the key to `i18n/en.json`, then run `mise //:i18n:format-fix` so the file stays key-sorted (CI job "Test i18n" fails otherwise). For the mobile app also run `mise //mobile:translation`. Web keys are typed against `en.json`, so the key must exist before `tsc` passes.
+16 -16
View File
@@ -35,35 +35,35 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742635"
[[tools.terragrunt]]
version = "1.2.0"
version = "1.2.1"
backend = "aqua:gruntwork-io/terragrunt"
[tools.terragrunt."platforms.linux-arm64"]
checksum = "sha256:1e13b7f0646fa7962bc6c5bfba14a30d6a589325b82561e21b66be5981d86395"
checksum = "sha256:8b92d56a20b2848365e22ef94f963fbfe25fedeebcacffacbc286cfcf9966066"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437526"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.1/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/670002309"
[tools.terragrunt."platforms.linux-x64"]
checksum = "sha256:23b5f2f9203d34919f7fd270a4b635b721b4903beca9762a1e4853b159bc9be8"
checksum = "sha256:08c8844cc69e4f03451ebb8f347cb57d9bd6b8db7b16e437ae5ec6f001228ef9"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437263"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.1/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/670001904"
[tools.terragrunt."platforms.macos-arm64"]
checksum = "sha256:79790d841ce71fe94c689fb6f5034afd31ad53d5904937c7a10934cca9d9734b"
checksum = "sha256:0aded323f844ce6567a15feb26393b67eff1400dc1722203fa4dd4e23b3bb182"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436516"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.1/terragrunt_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/670001110"
[tools.terragrunt."platforms.macos-x64"]
checksum = "sha256:0b0e63e80509eb1ffb4f89b892bc6fbf7728f27f0a73974400f1bd82a80b8627"
checksum = "sha256:29806d1708f5c54e54bc6adfcacfb710d4bb1f6f180efd751692e6fad5985640"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436142"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.1/terragrunt_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/670000649"
[tools.terragrunt."platforms.windows-x64"]
checksum = "sha256:c9a146b9aad456a289614023ad5778123d819af3cf9efd1c6130f196cfcb1999"
checksum = "sha256:4393df92d7f6a0bdfd5744f069460a4886c6371b921cd4b1be03c186195dc05c"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_windows_amd64.exe.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662438255"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.1/terragrunt_windows_amd64.exe.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/670003102"
+1 -1
View File
@@ -1,5 +1,5 @@
[tools]
terragrunt = "1.2.0"
terragrunt = "1.2.1"
opentofu = "1.13.2"
[tasks."tg:fmt"]
+28 -18
View File
@@ -2,24 +2,34 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "5.28.1"
constraints = "5.28.1"
version = "4.52.10"
constraints = "4.52.10"
hashes = [
"h1:9YL6g+iEfg3OzX6qSZOYsictVO0fNP09D6jRwIZKwQ0=",
"h1:GjOKfCmvcyE06fUp5WrAzfCLkTQaCg2Ko/kFlq8OfpM=",
"h1:QTIohIPsVCqEmknrR2PdbVXyK1fjS805rMaZpEEpzFQ=",
"h1:SFb952CVUFUoFjfor+GUqAV6DQ6bnUfaFcWL2yJlFBo=",
"h1:aso3s6IuIQju5L/OLrywCfQl7aA5TF+sb5pZzo8JG20=",
"h1:cxGa9LRKCzwNacgnLjXnHUygkvAEd6pb8g8sRwzPSRE=",
"h1:kBW0qcDqNupOnsi7vvx/0TQRxXLtmDKAHwOwH5l03pc=",
"h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
"zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
"zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
"zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
"zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
"zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
"zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
"zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
"zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
"h1:57oeYqz+pYEPAw2GvHARjcER0QYU0teS6DJzS26kCAY=",
"h1:6bnAgXSboppQbj79XmRtQLj/58d+b4EozydI8XrqwRE=",
"h1:6eP/jGOz1ioH17d3pygKhvsRa4AEKvedKqd+wgyiHYk=",
"h1:84gJm57BBSvtfwt7t9ZiU7EKKDQARjsztoDuqB5DfqU=",
"h1:99tME9L/Ug/dXElX3Ac7Kx+KD7nhWEEwOTqaQubW18w=",
"h1:TnMGVaPpIH5NZX324v+y6j1iW3ym0MqqNekxHlzXT3A=",
"h1:Z1L5jI4GiJxAAsfNyZHU4MGTluWEjjKtlB8Xn6cA0Dg=",
"h1:dB/WsY27MLla/cwjYggolJhVeHUeS59rlOObAWK2e24=",
"h1:gpffNocq9fHK+uQehZQK6YmL1aHeCGrVM1p53dcFP/0=",
"h1:jvfw1A2Uck4AjNmmWXwGH6P0sGQrnNY/KUVlqgyqYUM=",
"h1:ojvrl9uI1EPtAwKRHim9eSc4Lc88thU/2BFGdJLEwrc=",
"h1:rJZJK06NYCcSZhY5fwsRUSznSmAmRWVg4sO0auvbURo=",
"h1:y2m6i2SnQ3ZxiJHlMWNLHJrEbNqQT6OpDGyhFP+Nmds=",
"zh:06c09cc50d6c61fb5f1ca79e4b22d5f383c6a848f885ed542dadef44ab4bfd55",
"zh:205bdfdac49d6b25ec2ca7ecffd39bf47126340e1943066bdbbd64d6d72577d2",
"zh:341ed688772d8e2bd9a148c57d56d5dfbe3d625bc50296a505cbae3d29694562",
"zh:3790619bae55558ab4d93f53686f7cf55f35138f800ea07b0b9c20cce75b9c0d",
"zh:44284a5b67e34f1381bbc47b6e765cee581037e25cd3b459caec069493b63898",
"zh:493a173f3d5245db82782e8c1aba63e34c6fcf6b952b9288926b519bfe9d9725",
"zh:4eb7846381fee6eefdb7e0aaf80153ed71d35547752f96987218a9429645844f",
"zh:850b1d1da770f48def40223d754ff65c31fabb1a7f334ed6ebb40eba09c41572",
"zh:9a29a08a9fd40d7c1beefe8597617c2fc475d62ec6d5ca8fcc2cd1a056a67a2e",
"zh:9e57472f0a56863204f5b2eef125d4fb1222eca793c6a5ac9f4df6c1776edc48",
"zh:a9eff98931da596c2d335359841bfd668f4b6cde257e15546a558c7f5d72773b",
"zh:c9293fb3390c79e4f5072e019c0a2c4c7408a55a55e78ae37f739a1da75886ef",
"zh:c9e98abec7f7a1e2669dd9c065896217125d774640e4673c1303a942736b3eb4",
]
}
@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "5.28.1"
version = "4.52.10"
}
}
}
+28 -18
View File
@@ -2,24 +2,34 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "5.28.1"
constraints = "5.28.1"
version = "4.52.10"
constraints = "4.52.10"
hashes = [
"h1:9YL6g+iEfg3OzX6qSZOYsictVO0fNP09D6jRwIZKwQ0=",
"h1:GjOKfCmvcyE06fUp5WrAzfCLkTQaCg2Ko/kFlq8OfpM=",
"h1:QTIohIPsVCqEmknrR2PdbVXyK1fjS805rMaZpEEpzFQ=",
"h1:SFb952CVUFUoFjfor+GUqAV6DQ6bnUfaFcWL2yJlFBo=",
"h1:aso3s6IuIQju5L/OLrywCfQl7aA5TF+sb5pZzo8JG20=",
"h1:cxGa9LRKCzwNacgnLjXnHUygkvAEd6pb8g8sRwzPSRE=",
"h1:kBW0qcDqNupOnsi7vvx/0TQRxXLtmDKAHwOwH5l03pc=",
"h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
"zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
"zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
"zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
"zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
"zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
"zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
"zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
"zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
"h1:57oeYqz+pYEPAw2GvHARjcER0QYU0teS6DJzS26kCAY=",
"h1:6bnAgXSboppQbj79XmRtQLj/58d+b4EozydI8XrqwRE=",
"h1:6eP/jGOz1ioH17d3pygKhvsRa4AEKvedKqd+wgyiHYk=",
"h1:84gJm57BBSvtfwt7t9ZiU7EKKDQARjsztoDuqB5DfqU=",
"h1:99tME9L/Ug/dXElX3Ac7Kx+KD7nhWEEwOTqaQubW18w=",
"h1:TnMGVaPpIH5NZX324v+y6j1iW3ym0MqqNekxHlzXT3A=",
"h1:Z1L5jI4GiJxAAsfNyZHU4MGTluWEjjKtlB8Xn6cA0Dg=",
"h1:dB/WsY27MLla/cwjYggolJhVeHUeS59rlOObAWK2e24=",
"h1:gpffNocq9fHK+uQehZQK6YmL1aHeCGrVM1p53dcFP/0=",
"h1:jvfw1A2Uck4AjNmmWXwGH6P0sGQrnNY/KUVlqgyqYUM=",
"h1:ojvrl9uI1EPtAwKRHim9eSc4Lc88thU/2BFGdJLEwrc=",
"h1:rJZJK06NYCcSZhY5fwsRUSznSmAmRWVg4sO0auvbURo=",
"h1:y2m6i2SnQ3ZxiJHlMWNLHJrEbNqQT6OpDGyhFP+Nmds=",
"zh:06c09cc50d6c61fb5f1ca79e4b22d5f383c6a848f885ed542dadef44ab4bfd55",
"zh:205bdfdac49d6b25ec2ca7ecffd39bf47126340e1943066bdbbd64d6d72577d2",
"zh:341ed688772d8e2bd9a148c57d56d5dfbe3d625bc50296a505cbae3d29694562",
"zh:3790619bae55558ab4d93f53686f7cf55f35138f800ea07b0b9c20cce75b9c0d",
"zh:44284a5b67e34f1381bbc47b6e765cee581037e25cd3b459caec069493b63898",
"zh:493a173f3d5245db82782e8c1aba63e34c6fcf6b952b9288926b519bfe9d9725",
"zh:4eb7846381fee6eefdb7e0aaf80153ed71d35547752f96987218a9429645844f",
"zh:850b1d1da770f48def40223d754ff65c31fabb1a7f334ed6ebb40eba09c41572",
"zh:9a29a08a9fd40d7c1beefe8597617c2fc475d62ec6d5ca8fcc2cd1a056a67a2e",
"zh:9e57472f0a56863204f5b2eef125d4fb1222eca793c6a5ac9f4df6c1776edc48",
"zh:a9eff98931da596c2d335359841bfd668f4b6cde257e15546a558c7f5d72773b",
"zh:c9293fb3390c79e4f5072e019c0a2c4c7408a55a55e78ae37f739a1da75886ef",
"zh:c9e98abec7f7a1e2669dd9c065896217125d774640e4673c1303a942736b3eb4",
]
}
+1 -1
View File
@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "5.28.1"
version = "4.52.10"
}
}
}
+1 -1
View File
@@ -154,7 +154,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:c123e3715db63d06d4ad6964884037aa0d5d4d703939b9929954112889708e1d
image: docker.io/valkey/valkey:9@sha256:3add1a336a920ea54770a001ab6feb3da5fceea96a40373a3a2cd469b815f0b7
healthcheck:
test: redis-cli ping | grep -q PONG || exit 1
+2 -2
View File
@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:c123e3715db63d06d4ad6964884037aa0d5d4d703939b9929954112889708e1d
image: docker.io/valkey/valkey:9@sha256:3add1a336a920ea54770a001ab6feb3da5fceea96a40373a3a2cd469b815f0b7
healthcheck:
test: redis-cli ping | grep -q PONG || exit 1
restart: always
@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:13.2.4-ubuntu@sha256:d838696907b4350d9232eb8da1503dc1d1010a0359d3ce4b3f7d2ff7db28eb75
image: grafana/grafana:13.3.0-ubuntu@sha256:74111b957a8528c1d7f396f57ded8d08a6c7d856ef80eea3f637daeabdde3aa9
volumes:
- grafana-data:/var/lib/grafana
+1 -1
View File
@@ -59,7 +59,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:c123e3715db63d06d4ad6964884037aa0d5d4d703939b9929954112889708e1d
image: docker.io/valkey/valkey:9@sha256:3add1a336a920ea54770a001ab6feb3da5fceea96a40373a3a2cd469b815f0b7
user: '1000:1000'
security_opt:
- no-new-privileges:true
+1 -1
View File
@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:c123e3715db63d06d4ad6964884037aa0d5d4d703939b9929954112889708e1d
image: docker.io/valkey/valkey:9@sha256:3add1a336a920ea54770a001ab6feb3da5fceea96a40373a3a2cd469b815f0b7
healthcheck:
test: redis-cli ping | grep -q PONG || exit 1
restart: always
+1 -1
View File
@@ -1,7 +1,7 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.wrangler]]
version = "4.152.0"
version = "4.155.0"
backend = "npm:wrangler"
[tools.wrangler.options]
+1 -1
View File
@@ -28,4 +28,4 @@ run = "prettier --write ."
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools]
wrangler = "4.152.0"
wrangler = "4.155.0"
+14
View File
@@ -0,0 +1,14 @@
# e2e/AGENTS.md
End-to-end tests against a real Immich stack from `docker-compose.yml` (server on port 2285): API specs with vitest, browser specs with Playwright.
## Rules
- `test-assets/` is a git submodule; initialize it before running exif or upload specs.
- Use the shared helpers in `src/utils.ts` (`utils.adminSetup`, `utils.userSetup`, `utils.resetDatabase`, `utils.createAsset`, `utils.waitForQueueFinish`, `asBearerAuth`, `asKeyAuth`, `immichCli`) instead of re-implementing login or fixtures.
## Skills
- **Run API specs**: `src/specs/server/**/*.e2e-spec.ts`, run with `mise //e2e:test` (it builds the SDK, CLI and Docker images first). If nothing answers at `127.0.0.1:2285`, the vitest `globalSetup` starts the Docker stack itself; set `VITEST_DISABLE_DOCKER_SETUP=true` to reuse a running one. One file: `pnpm exec vitest --run src/specs/server/api/album.e2e-spec.ts`. Maintenance-mode specs use `pnpm exec vitest --run --config vitest.maintenance.config.ts`.
- **Run browser specs**: Playwright projects are `web` (`src/specs/web/`, real backend), `maintenance` (`src/specs/maintenance/web/`), and `ui` (`src/ui/specs/`, mocked network from `src/ui/mock-network/`). `mise //e2e:test-web` runs all projects and installs browsers first. One project or file: `pnpm exec playwright test --project=web src/specs/web/album.e2e-spec.ts`; add `--ui` for the Playwright UI. Set `PLAYWRIGHT_DISABLE_WEBSERVER=1` to reuse a running stack.
- **Write an API spec**: in `beforeAll` call `await utils.resetDatabase()` and `admin = await utils.adminSetup()`, create users with `utils.userSetup(admin.accessToken, dto)`, and call the server through `@immich/sdk` functions with `asBearerAuth(token)` or through `supertest` on `app` for raw status-code and validation checks. After uploads, `await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration')` before asserting on derived data.
+1 -1
View File
@@ -46,7 +46,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:c123e3715db63d06d4ad6964884037aa0d5d4d703939b9929954112889708e1d
image: ghcr.io/valkey-io/valkey:9@sha256:3add1a336a920ea54770a001ab6feb3da5fceea96a40373a3a2cd469b815f0b7
healthcheck:
test: redis-cli ping || exit 1
+5 -8
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "3.3.3",
"version": "3.4.0-rc.0",
"description": "",
"main": "index.js",
"type": "module",
@@ -10,10 +10,8 @@
"test:maintenance": "vitest --run --config vitest.maintenance.config.ts",
"test:web": "pnpm exec playwright test --project=web",
"test:web:maintenance": "pnpm exec playwright test --project=maintenance",
"test:web:ui": "pnpm exec playwright test --project=ui",
"start:web": "pnpm exec playwright test --ui --project=web",
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
@@ -25,33 +23,32 @@
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/js": "^10.0.0",
"@faker-js/faker": "^10.1.0",
"@futo-org/backups-orchestrator-ui": "1.13.1",
"@immich/cli": "workspace:*",
"@immich/e2e-auth-server": "workspace:*",
"@immich/sdk": "workspace:*",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.19.2",
"@types/node": "^26.6.6",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^7.0.0",
"@typescript/native": "npm:typescript@^7.0.2",
"dotenv": "^17.2.3",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^78.0.0",
"exiftool-vendored": "^39.0.0",
"exiftool-vendored": "^39.1.0",
"globals": "^17.0.0",
"luxon": "^3.4.4",
"pg": "^8.11.3",
"pngjs": "^7.0.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.35.3",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "^8.28.0",
"utimes": "^5.2.1",
-9
View File
@@ -11,8 +11,6 @@ export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${pla
export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
const config: PlaywrightTestConfig = {
testDir: './src/specs/server',
testMatch: /.*\.e2e-spec\.ts/,
@@ -38,13 +36,6 @@ const config: PlaywrightTestConfig = {
testDir: './src/specs/web',
workers: 1,
},
{
name: 'ui',
use: { ...devices['Desktop Chrome'] },
testDir: './src/ui/specs',
fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
},
{
name: 'maintenance',
use: { ...devices['Desktop Chrome'] },
@@ -0,0 +1,352 @@
import * as sdk from '@futo-org/backups-orchestrator-ui/sdk';
import { LoginResponseDto, StorageFolder } from '@immich/sdk';
import { io, Socket } from 'socket.io-client';
import { createUserDto } from 'src/fixtures';
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/futo-backups', () => {
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
let requestOpts: any;
let filename: string;
let socket: Socket;
let libraryId: string;
beforeAll(async () => {
utils.initSdk();
sdk.defaults.baseUrl = baseUrl;
await utils.resetDatabase();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
requestOpts = { headers: asBearerAuth(admin.accessToken) };
await utils.resetBackups(admin.accessToken);
await sdk.futoBackupsResetOrchestrator(requestOpts);
socket = io(baseUrl, {
path: '/api/futo-backups/socket.io',
transports: ['websocket'],
extraHeaders: asBearerAuth(admin.accessToken),
});
socket.onAny(console.info);
});
afterAll(async () => {
socket.close();
// trigger update for orchestrator module config
if (libraryId) {
await utils.deleteLibrary(admin.accessToken, libraryId);
}
});
const finishedJobStatuses = new Set(['complete', 'warn', 'failed', 'cancelled', 'incomplete']);
const waitForMessage = (type: string, matches: (payload: any) => boolean = () => true) => {
return new Promise((resolve) => {
const listener = (msg: string) => {
const payload = JSON.parse(msg);
if (payload.type !== type || !matches(payload)) {
return;
}
resolve(payload);
socket.offAny(listener);
};
socket.onAny(listener);
});
};
describe('Orchestration Module', async () => {
it('works', async () => {
await expect(sdk.futoBackupsOnboardingStatus(requestOpts)).resolves.toEqual(
expect.objectContaining({
hasOnboardedKey: false,
hasBackend: false,
hasBackup: false,
hasSchedule: false,
hasSkippedExtraConfig: false,
}),
);
});
it('is inaccessible without admin', async () => {
await expect(sdk.futoBackupsOnboardingStatus({ headers: asBearerAuth(nonAdmin.accessToken) })).rejects.toEqual(
expect.objectContaining({ data: { message: expect.any(String) } }),
);
});
it('is inaccessible without logging in', async () => {
await expect(sdk.futoBackupsOnboardingStatus()).rejects.toEqual(
expect.objectContaining({ data: { message: 'Authentication required' } }),
);
});
});
describe('Local Backup', { concurrent: false }, async () => {
beforeAll(async () => {
await sdk.futoBackupsImportRecoveryKey({ recoveryKey: '0'.repeat(64) }, requestOpts);
});
it('configures a local backend', { concurrent: false }, async () => {
await utils.mkFolder('/local-backend');
await sdk.futoBackupsCreateLocalBackend({ path: '/local-backend' }, requestOpts);
});
it('configures Immich backup', { concurrent: false }, async () => {
const event = waitForMessage('IntegrationUpdate');
await sdk.futoBackupsConfigureImmichIntegration(
{
name: 'Immich',
worm: false,
cron: '0 3 * * *',
backupConfiguration: true,
dataFolders: [StorageFolder.Backups, StorageFolder.Upload],
libraries: ['all'],
},
requestOpts,
);
await event;
await expect(sdk.futoBackupsGetIntegrations(requestOpts)).resolves.toEqual(
expect.objectContaining({
immichIntegration: expect.objectContaining({
configuration: {
backupConfiguration: true,
dataFolders: ['backups', 'upload'],
libraries: ['all'],
},
}),
immichState: {
dataFolders: expect.arrayContaining(Object.values(StorageFolder)),
dataPath: '/data',
// may have stale libraries from other test runs
libraries: expect.any(Array),
},
}),
);
});
it('updates configuration', { concurrent: false }, async () => {
await utils.mkFolder('/test');
({ id: libraryId } = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
name: 'My Library',
importPaths: ['/test'],
}));
await expect(sdk.futoBackupsGetIntegrations(requestOpts)).resolves.toEqual(
expect.objectContaining({
immichIntegration: expect.any(Object),
immichState: expect.objectContaining({
libraries: expect.arrayContaining([
expect.objectContaining({
name: 'My Library',
importPaths: ['/test'],
}),
]),
}),
}),
);
});
it('creates a snapshot', { concurrent: false }, async () => {
const event = waitForMessage('JobUpdate', ({ job }) => finishedJobStatuses.has(job?.status));
const { repositories } = await sdk.futoBackupsGetRepositories(requestOpts);
const { id } = repositories[0];
filename = await utils.createBackup(admin.accessToken);
await sdk.futoBackupsCreateBackup(id, requestOpts);
await event;
const { snapshots } = await sdk.futoBackupsGetSnapshots(id, requestOpts);
const { id: snapshotId } = snapshots[0];
await expect(sdk.futoBackupsGetSnapshotListing(id, snapshotId, {}, requestOpts)).resolves.toMatchInlineSnapshot(`
{
"items": [
{
"isDirectory": true,
"path": "/data",
},
{
"isDirectory": true,
"path": "/test",
},
],
"parent": "/",
"path": "/",
}
`);
await expect(sdk.futoBackupsGetSnapshotListing(id, snapshotId, { path: '/data' }, requestOpts)).resolves
.toMatchInlineSnapshot(`
{
"items": [
{
"isDirectory": true,
"path": "/data/backups",
},
{
"isDirectory": true,
"path": "/data/upload",
},
],
"parent": "/",
"path": "/data",
}
`);
await expect(
sdk.futoBackupsGetSnapshotListing(id, snapshotId, { path: '/data/backups' }, requestOpts),
).resolves.toEqual(
expect.objectContaining({
items: expect.arrayContaining([
{ isDirectory: false, path: '/data/backups/.immich' },
{ isDirectory: false, path: `/data/backups/${filename}` },
{ isDirectory: true, path: '/data/backups/yucca' },
]),
parent: '/data',
path: '/data/backups',
}),
);
});
});
describe('Restore Local Backup', { concurrent: false }, async () => {
let cookie: string;
beforeAll(async () => {
await sdk.futoBackupsResetOrchestrator(requestOpts);
await utils.resetDatabase();
socket.disconnect();
await utils.disconnectDatabase();
});
afterAll(async () => {
await utils.connectDatabase();
});
it('restores backup', { timeout: 60_000, concurrent: false }, async () => {
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';', 1)[0];
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
const maintenanceRequestOpts = {
headers: {
cookie,
},
};
await expect(sdk.futoBackupsGetSchedules(maintenanceRequestOpts)).resolves.toEqual({ schedules: [] });
await sdk.futoBackupsImportRecoveryKey({ recoveryKey: '0'.repeat(64) }, maintenanceRequestOpts);
const {
backend: { id: backendId },
} = await sdk.futoBackupsCreateLocalBackend({ path: '/local-backend' }, maintenanceRequestOpts);
const { repositories } = await sdk.futoBackupsInspectRepositories({}, maintenanceRequestOpts);
const { id: repositoryId, snapshots } = repositories[0];
const { id: snapshotId } = (snapshots ?? [])[0];
socket = io(baseUrl, {
path: '/api/futo-backups/socket.io',
transports: ['websocket'],
extraHeaders: {
cookie,
},
});
const event = waitForMessage('JobUpdate', ({ job }) => finishedJobStatuses.has(job?.status));
await sdk.futoBackupsRestoreFromPoint(
repositoryId,
snapshotId,
backendId,
{
yuccaConfig: '/data/yucca',
include: ['/data'],
},
maintenanceRequestOpts,
);
await event;
socket.disconnect();
const { status: restoreStatus } = await request(app).post('/admin/maintenance').set('Cookie', cookie).send({
action: 'restore_database',
restoreBackupFilename: filename,
});
expect(restoreStatus).toBe(201);
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
const { status: status2, body } = await request(app).get('/admin/maintenance/status');
expect(status2).toBe(200);
expect(body).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
}),
);
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 60_000,
},
)
.toBeFalsy();
await expect(sdk.futoBackupsGetSchedules(requestOpts)).resolves.toEqual({
schedules: expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })]),
});
});
});
});
@@ -95,6 +95,7 @@ test.describe('Database Backups', () => {
await page.waitForURL('/maintenance**');
}
await page.getByRole('button', { name: 'Database Backup' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
@@ -0,0 +1,168 @@
import * as sdk from '@futo-org/backups-orchestrator-ui/sdk';
import { LoginResponseDto, StorageFolder, getServerFeatures } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { io, type Socket } from 'socket.io-client';
import { asBearerAuth, baseUrl, utils } from 'src/utils';
test.describe.configure({ mode: 'serial' });
test.describe('FUTO Backups', () => {
let admin: LoginResponseDto;
let socket: Socket;
const finishedJobStatuses = new Set(['complete', 'warn', 'failed', 'cancelled', 'incomplete']);
const waitForJobEnd = () =>
new Promise<void>((resolve) => {
const listener = (msg: string) => {
try {
const payload = JSON.parse(msg);
if (payload.type === 'JobUpdate' && finishedJobStatuses.has(payload.job?.status)) {
socket.offAny(listener);
resolve();
}
} catch {
// no-op
}
};
socket.onAny(listener);
});
test.beforeAll(async () => {
utils.initSdk();
sdk.defaults.baseUrl = baseUrl;
await utils.resetDatabase();
admin = await utils.adminSetup();
const headers = asBearerAuth(admin.accessToken);
await sdk.futoBackupsResetOrchestrator({ headers });
await sdk.futoBackupsImportRecoveryKey({ recoveryKey: '0'.repeat(64) }, { headers });
await sdk.futoBackupsConfirmRecoveryKey({ headers });
await sdk.futoBackupsEnableTelemetry({ headers });
await utils.mkFolder('/local-backend');
socket = io(baseUrl, {
path: '/api/futo-backups/socket.io',
transports: ['websocket'],
extraHeaders: headers,
forceNew: true,
});
await new Promise<void>((resolve) => socket.on('connect', () => resolve()));
});
test.afterAll(async () => {
socket?.close();
});
test('the backups link opens the upsell before backups are configured', async ({ context, page }) => {
const headers = asBearerAuth(admin.accessToken);
await utils.setAuthCookies(context, admin.accessToken);
await expect(getServerFeatures({ headers })).resolves.toMatchObject({ backups: false });
await page.goto('/link?target=backups');
await page.waitForURL('/admin/backups');
await expect(page.getByRole('heading', { name: 'FUTO Backups', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Get Started' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Set up Backups' })).toBeVisible();
await expect(getServerFeatures({ headers })).resolves.toMatchObject({ backups: true });
});
test('the backups link opens the dashboard once backups are configured', async ({ context, page }) => {
test.setTimeout(60_000);
const headers = asBearerAuth(admin.accessToken);
await utils.setAuthCookies(context, admin.accessToken);
await sdk.futoBackupsCreateLocalBackend({ path: '/local-backend' }, { headers });
await sdk.futoBackupsConfigureImmichIntegration(
{
name: 'Immich',
worm: false,
cron: '0 3 * * *',
backupConfiguration: true,
dataFolders: [StorageFolder.Backups, StorageFolder.Upload],
libraries: ['all'],
},
{ headers },
);
const { repositories } = await sdk.futoBackupsGetRepositories({ headers });
const backupEnd = waitForJobEnd();
await sdk.futoBackupsCreateBackup(repositories[0].id, { headers });
await backupEnd;
await page.goto('/link?target=backups');
await page.waitForURL('/admin/backups');
await expect(page.getByText('Your library backup')).toBeVisible();
await expect(page.getByText('Last backup successful')).toBeVisible();
});
test('manually triggers a backup and waits for completion', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/backups');
await page.getByRole('button', { name: 'Back up now', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: 'Backing up your library' })).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'Backup complete' })).toBeVisible({ timeout: 45_000 });
});
test('resets immich and restores from the local FUTO Backups backup', async ({ context, page }) => {
test.setTimeout(120_000);
await utils.setAuthCookies(context, admin.accessToken);
await utils.resetBackups(admin.accessToken);
await utils.createBackup(admin.accessToken);
await sdk.futoBackupsResetOrchestrator({ headers: asBearerAuth(admin.accessToken) });
await utils.resetDatabase();
await page.goto('/');
await page.getByRole('button', { name: 'Restore from backup' }).click();
try {
await page.waitForURL('/maintenance**');
} catch {
await page.goto('/maintenance');
await page.waitForURL('/maintenance**');
}
await page.getByRole('button', { name: 'FUTO Backups' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.filter({ hasText: 'Telemetry required for closed beta' })).toBeVisible();
await dialog.getByRole('button', { name: 'Continue' }).click();
await expect(dialog.filter({ hasText: 'Import recovery key' })).toBeVisible();
await dialog.getByLabel('Recovery Key').fill('0'.repeat(64));
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog.filter({ hasText: 'Where would you like to restore from?' })).toBeVisible();
await dialog.getByText('Local Storage').click();
await expect(dialog.filter({ hasText: 'Create local backend' })).toBeVisible();
await dialog.getByLabel('Path').fill('/local-backend');
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog.filter({ hasText: 'Select Backup' })).toBeVisible();
await dialog
.getByRole('button', { name: /Last backup/ })
.first()
.click();
await expect(dialog.filter({ hasText: /Restore from/ })).toBeVisible();
await dialog.getByRole('button', { name: 'Restore' }).first().click();
await expect(dialog.filter({ hasText: 'Confirm restore from snapshot' })).toBeVisible();
await dialog.getByRole('button', { name: 'Restore', exact: true }).click();
await expect(dialog.filter({ hasText: 'Restoring' })).toBeVisible();
await expect(dialog.filter({ hasText: 'Restoring' })).toBeHidden({ timeout: 60_000 });
await page.waitForURL('/photos', { timeout: 90_000 });
});
});
@@ -98,6 +98,7 @@ describe('/server', () => {
configFile: false,
duplicateDetection: false,
facialRecognition: false,
backups: false,
map: true,
reverseGeocoding: true,
importFaces: false,
-63
View File
@@ -1,63 +0,0 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { testAssetDir, utils } from 'src/utils.js';
test.describe('Album', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test(`doesn't delete album after canceling add assets`, async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/albums');
await page.getByRole('button', { name: 'Create album' }).click();
await page.getByRole('button', { name: 'Select photos' }).click();
await page.getByRole('button', { name: 'Close' }).click();
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();
});
});
@@ -1,126 +0,0 @@
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 { testAssetDir, utils } from 'src/utils.js';
test.describe('Detail Panel', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let websocket: Socket;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
websocket = await utils.connectWebsocket(admin.accessToken);
});
test.afterAll(() => {
utils.disconnectWebsocket(websocket);
});
test('can be opened for shared links', async ({ page }) => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
await expect(page.getByRole('button', { name: 'Info' })).toBeVisible();
await page.keyboard.press('i');
await expect(page.locator('#detail-panel')).toBeVisible();
await page.keyboard.press('i');
await expect(page.locator('#detail-panel')).toHaveCount(0);
});
test('cannot be opened for shared links with hidden metadata', async ({ page }) => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
showMetadata: false,
});
await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
await expect(page.getByRole('button', { name: 'Info' })).toHaveCount(0);
await page.keyboard.press('i');
await expect(page.locator('#detail-panel')).toHaveCount(0);
await page.keyboard.press('i');
await expect(page.locator('#detail-panel')).toHaveCount(0);
});
test('description is visible for owner on shared links', async ({ context, page }) => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
const textarea = page.getByRole('textbox', { name: 'Add a description' });
await page.getByRole('button', { name: 'Info' }).click();
await expect(textarea).toBeVisible();
await expect(textarea).not.toBeDisabled();
});
test('description changes are visible after reopening', async ({ context, page }) => {
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();
const textarea = page.getByRole('textbox', { name: 'Add a description' });
await textarea.fill('new description');
await expect(textarea).toHaveValue('new description');
await page.getByRole('button', { name: 'Info' }).click();
await expect(textarea).not.toBeVisible();
await page.getByRole('button', { name: 'Info' }).click();
await expect(textarea).toBeVisible();
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);
});
});
});
@@ -1,65 +0,0 @@
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils.js';
test.describe('Asset Viewer Navbar', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test.beforeEach(async () => {
asset = await utils.createAsset(admin.accessToken);
});
test.describe('shared link without metadata', () => {
test('visible guest actions', async ({ page }) => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
showMetadata: false,
});
await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
const expected = ['Zoom Image', 'Copy Image', 'Download'];
const buttons = await page.getByTestId('asset-viewer-navbar-actions').getByRole('button').all();
for (const [i, button] of buttons.entries()) {
await expect(button).toHaveAccessibleName(expected[i]);
}
});
test('visible owner actions', async ({ context, page }) => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
showMetadata: false,
});
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
const expected = ['Share', 'Zoom Image', 'Copy Image', 'Download'];
const buttons = await page.getByTestId('asset-viewer-navbar-actions').getByRole('button').all();
for (const [i, button] of buttons.entries()) {
await expect(button).toHaveAccessibleName(expected[i]);
}
});
});
test.describe('actions', () => {
test('favorite asset with shortcut', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
await page.keyboard.press('f');
await expect(page.getByText('Added to favorites')).toBeVisible();
});
});
});
@@ -1,56 +0,0 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, type Page, test } from '@playwright/test';
import { utils } from 'src/utils.js';
test.describe('Slideshow', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
});
const openSlideshow = async (page: Page) => {
await page.goto(`/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
await page.getByRole('button', { name: 'More' }).click();
await page.getByRole('menuitem', { name: 'Slideshow' }).click();
};
test('open slideshow', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await openSlideshow(page);
await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
});
test('exit slideshow with button', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await openSlideshow(page);
const exitButton = page.getByRole('button', { name: 'Exit Slideshow' });
await exitButton.click();
await expect(exitButton).not.toBeVisible();
});
test('exit slideshow with shortcut', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await openSlideshow(page);
const exitButton = page.getByRole('button', { name: 'Exit Slideshow' });
await expect(exitButton).toBeVisible();
await page.keyboard.press('Escape');
await expect(exitButton).not.toBeVisible();
});
test('favorite shortcut is disabled', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await openSlideshow(page);
await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
await page.keyboard.press('f');
await expect(page.getByText('Added to favorites')).not.toBeVisible();
});
});
-51
View File
@@ -1,51 +0,0 @@
import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import crypto from 'node:crypto';
import { asBearerAuth, utils } from 'src/utils.js';
test.describe('Duplicates Utility', () => {
let admin: LoginResponseDto;
let firstAsset: AssetMediaResponseDto;
let secondAsset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test.beforeEach(async ({ context }) => {
[firstAsset, secondAsset] = await Promise.all([
utils.createAsset(admin.accessToken, {}),
utils.createAsset(admin.accessToken, {}),
]);
await updateAssets(
{
assetBulkUpdateDto: {
ids: [firstAsset.id, secondAsset.id],
duplicateId: crypto.randomUUID(),
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
await utils.setAuthCookies(context, admin.accessToken);
});
test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
await page.goto('/utilities/duplicates');
await page.getByRole('button', { name: 'View' }).first().click();
await page.waitForSelector('#immich-asset-viewer');
const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
const initialAssetId = getViewedAssetId();
expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
await page.keyboard.press('ArrowRight');
await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
await page.keyboard.press('ArrowLeft');
await expect.poll(getViewedAssetId).toBe(initialAssetId);
});
});
-41
View File
@@ -1,41 +0,0 @@
import { LoginResponseDto, ManualJobName, QueueName } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils.js';
test.describe.configure({ mode: 'serial' });
test.describe.skip('Integrity', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test('run integrity jobs to update stats', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
await page.goto('/admin/maintenance');
const count = page.getByText('Untracked Files').locator('..').locator('..').locator('div').nth(1);
const previousCount = Number.parseInt((await count.textContent()) ?? '');
await utils.mkFolder(`/data/upload/${admin.userId}`);
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
const checkButton = page.getByText('Integrity Report').locator('..').getByRole('button', { name: 'Check All' });
await checkButton.click();
await expect(checkButton).toBeEnabled();
await expect(count).toContainText((previousCount + 1).toString());
});
});
@@ -1,80 +0,0 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import type { Socket } from 'socket.io-client';
import { utils } from 'src/utils.js';
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let rawAsset: AssetMediaResponseDto;
let websocket: Socket;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
websocket = await utils.connectWebsocket(admin.accessToken);
});
test.afterAll(() => {
utils.disconnectWebsocket(websocket);
});
test.beforeEach(async ({ context, page }) => {
// before each test, login as user
await utils.setAuthCookies(context, admin.accessToken);
await page.waitForLoadState('networkidle');
});
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
await originalResponse;
const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /original/);
});
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
await fullsizeResponse;
const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /fullsize/);
});
test('right-click targets the img element', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const box = await preview.boundingBox();
const tagAtCenter = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName, {
x: box!.x + box!.width / 2,
y: box!.y + box!.height / 2,
});
expect(tagAtCenter).toBe('IMG');
});
});
-136
View File
@@ -1,136 +0,0 @@
import {
AlbumResponseDto,
AssetMediaResponseDto,
LoginResponseDto,
SharedLinkResponseDto,
SharedLinkType,
createAlbum,
} from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { asBearerAuth, utils } from 'src/utils.js';
test.describe('Shared Links', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let asset2: AssetMediaResponseDto;
let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto;
let sharedLinkPassword: SharedLinkResponseDto;
let individualSharedLink: SharedLinkResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
asset2 = await utils.createAsset(admin.accessToken);
album = await createAlbum(
{
createAlbumDto: {
albumName: 'Test Album',
assetIds: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
});
sharedLinkPassword = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
password: 'test-password',
});
individualSharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id, asset2.id],
});
});
test('download from a shared link', async ({ page }) => {
await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
await page.waitForSelector(`[data-asset-id="${asset.id}"] [role="checkbox"]`);
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
});
test('download all from shared link', async ({ page }) => {
await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
});
test('enter password for a shared link', async ({ page }) => {
await page.goto(`/share/${sharedLinkPassword.key}`);
await page.getByPlaceholder('Password').fill('test-password');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
});
test('show-password button visible', async ({ page }) => {
await page.goto(`/share/${sharedLinkPassword.key}`);
await page.getByPlaceholder('Password').fill('test-password');
await page.getByRole('button', { name: 'Show password' }).waitFor();
});
test('view password for shared link', async ({ page }) => {
await page.goto(`/share/${sharedLinkPassword.key}`);
const input = page.getByPlaceholder('Password');
await input.fill('test-password');
await page.getByRole('button', { name: 'Show password' }).click();
// await page.getByText('test-password').waitFor();
await expect(input).toHaveAttribute('type', 'text');
});
test('hide-password button visible', async ({ page }) => {
await page.goto(`/share/${sharedLinkPassword.key}`);
const input = page.getByPlaceholder('Password');
await input.fill('test-password');
await page.getByRole('button', { name: 'Show password' }).click();
await page.getByRole('button', { name: 'Hide password' }).waitFor();
});
test('hide password for shared link', async ({ page }) => {
await page.goto(`/share/${sharedLinkPassword.key}`);
const input = page.getByPlaceholder('Password');
await input.fill('test-password');
await page.getByRole('button', { name: 'Show password' }).click();
await page.getByRole('button', { name: 'Hide password' }).click();
await expect(input).toHaveAttribute('type', 'password');
});
test('show error for invalid shared link', async ({ page }) => {
await page.goto('/share/invalid');
await page.getByRole('heading', { name: 'Page not found' }).waitFor();
});
test('auth on navigation from shared link to timeline', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.locator('a[href="/"]').click();
await page.waitForURL('/photos');
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
});
test('owner can remove assets from an individual shared link', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/share/${individualSharedLink.key}`);
await page.locator(`[data-asset="${asset.id}"]`).waitFor();
await expect(page.locator(`[data-asset]`)).toHaveCount(2);
await page.locator(`[data-asset="${asset.id}"]`).hover();
await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click();
await page.getByRole('button', { name: 'Remove from shared link' }).click();
await page.getByRole('button', { name: 'Remove', exact: true }).click();
await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0);
await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1);
});
});
-97
View File
@@ -1,97 +0,0 @@
import { getUserAdmin } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { asBearerAuth, utils } from 'src/utils.js';
test.describe('User Administration', () => {
test.beforeAll(() => {
utils.initSdk();
});
test.beforeEach(async () => {
await utils.resetDatabase();
});
test('validate admin/users link', async ({ context, page }) => {
const admin = await utils.adminSetup();
await utils.setAuthCookies(context, admin.accessToken);
// Navigate to user management page and verify title and header
await page.goto(`/admin/users`);
await expect(page).toHaveTitle(/User Management/);
await expect(page.getByText('User Management')).toBeVisible();
});
test('create user', async ({ context, page }) => {
const admin = await utils.adminSetup();
await utils.setAuthCookies(context, admin.accessToken);
// Create a new user
await page.goto('/admin/users');
await page.getByRole('button', { name: 'Create user' }).click();
await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password', { exact: true }).fill('password');
await page.getByLabel('Confirm Password').fill('password');
await page.getByLabel('Name').fill('Immich User');
await page.getByRole('button', { name: 'Create', exact: true }).click();
// Verify the user exists in the user list
await page.getByRole('row', { name: 'user@immich.cloud' });
});
test('promote to admin', async ({ context, page }) => {
const admin = await utils.adminSetup();
await utils.setAuthCookies(context, admin.accessToken);
const user = await utils.userSetup(admin.accessToken, {
name: 'Admin 2',
email: 'admin2@immich.cloud',
password: 'password',
});
expect(user.isAdmin).toBe(false);
await page.goto(`/admin/users/${user.userId}`);
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Save' }).click();
await expect
.poll(async () => {
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
return userAdmin.isAdmin;
})
.toBe(true);
});
test('revoke admin access', async ({ context, page }) => {
const admin = await utils.adminSetup();
await utils.setAuthCookies(context, admin.accessToken);
const user = await utils.userSetup(admin.accessToken, {
name: 'Admin 2',
email: 'admin2@immich.cloud',
password: 'password',
isAdmin: true,
});
expect(user.isAdmin).toBe(true);
await page.goto(`/admin/users/${user.userId}`);
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Save' }).click();
await expect
.poll(async () => {
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
return userAdmin.isAdmin;
})
.toBe(false);
});
});
-2
View File
@@ -1,2 +0,0 @@
export { generateMemoriesFromTimeline, generateMemory } from './memory/model-objects';
export type { MemoryConfig, MemoryYearConfig } from './memory/model-objects';
@@ -1,86 +0,0 @@
import { faker } from '@faker-js/faker';
import { MemoryType, type MemoryResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { toAssetResponseDto } from 'src/ui/generators/timeline/rest-response.js';
import type { MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config.js';
import { SeededRandom, selectRandomMultiple } from 'src/ui/generators/timeline/utils.js';
export type MemoryConfig = {
id?: string;
ownerId: string;
year: number;
memoryAt: string;
isSaved?: boolean;
};
export type MemoryYearConfig = {
year: number;
assetCount: number;
};
export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]): MemoryResponseDto {
const now = new Date().toISOString();
const memoryId = config.id ?? faker.string.uuid();
return {
id: memoryId,
assets: assets.map((asset) => toAssetResponseDto(asset)),
data: { year: config.year },
memoryAt: config.memoryAt,
createdAt: now,
updatedAt: now,
isSaved: config.isSaved ?? false,
ownerId: config.ownerId,
type: MemoryType.OnThisDay,
};
}
export function generateMemoriesFromTimeline(
timelineAssets: MockTimelineAsset[],
ownerId: string,
memoryConfigs: MemoryYearConfig[],
seed: number = 42,
): MemoryResponseDto[] {
const rng = new SeededRandom(seed);
const memories: MemoryResponseDto[] = [];
const usedAssetIds = new Set<string>();
for (const config of memoryConfigs) {
const yearAssets = timelineAssets.filter((asset) => {
const assetYear = DateTime.fromISO(asset.fileCreatedAt).year;
return assetYear === config.year && !usedAssetIds.has(asset.id);
});
if (yearAssets.length === 0) {
continue;
}
const countToSelect = Math.min(config.assetCount, yearAssets.length);
const selectedAssets = selectRandomMultiple(yearAssets, countToSelect, rng);
for (const asset of selectedAssets) {
usedAssetIds.add(asset.id);
}
selectedAssets.sort(
(a, b) => DateTime.fromISO(b.fileCreatedAt).diff(DateTime.fromISO(a.fileCreatedAt)).milliseconds,
);
const memoryAt = DateTime.now().set({ year: config.year }).toISO()!;
memories.push(
generateMemory(
{
ownerId,
year: config.year,
memoryAt,
},
selectedAssets,
),
);
}
memories.sort((a, b) => new Date(b.memoryAt).getMilliseconds() - new Date(a.memoryAt).getMilliseconds());
return memories;
}
-37
View File
@@ -1,37 +0,0 @@
export { generateTimelineData } from './timeline/model-objects';
export { createDefaultTimelineConfig, validateTimelineConfig } from './timeline/timeline-config';
export type {
MockAlbum,
MonthSpec,
SerializedTimelineData,
MockTimelineAsset as TimelineAssetConfig,
TimelineConfig,
MockTimelineData as TimelineData,
} from './timeline/timeline-config';
export {
getAlbum,
getAsset,
getTimeBucket,
getTimeBuckets,
toAssetResponseDto,
toColumnarFormat,
} from './timeline/rest-response';
export type { Changes } from './timeline/rest-response';
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
export {
SeededRandom,
getMockAsset,
parseTimeBucketKey,
selectRandom,
selectRandomDays,
selectRandomMultiple,
} from './timeline/utils';
export { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './timeline/distribution-patterns';
export type { DayPattern, MonthDistribution } from './timeline/distribution-patterns';
@@ -1,179 +0,0 @@
import { generateConsecutiveDays, generateDayAssets } from 'src/ui/generators/timeline/model-objects.js';
import { SeededRandom, selectRandomDays } from 'src/ui/generators/timeline/utils.js';
import type { MockTimelineAsset } from './timeline-config';
import { GENERATION_CONSTANTS } from './timeline-config';
type AssetDistributionStrategy = (rng: SeededRandom) => number;
type DayDistributionStrategy = (
year: number,
month: number,
daysInMonth: number,
totalAssets: number,
ownerId: string,
rng: SeededRandom,
) => MockTimelineAsset[];
/** Strategies for determining total asset count per month */
export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = {
empty: null, // Special case - handled separately
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
medium: (rng) => rng.nextInt(15, 31), // 15-30 assets
dense: (rng) => rng.nextInt(50, 81), // 50-80 assets
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
};
/** Strategies for distributing assets across days within a month */
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// All assets on one day in the middle of the month
const day = Math.floor(daysInMonth / 2);
return generateDayAssets(year, month, day, totalAssets, ownerId, rng);
},
'consecutive-large': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// 3-5 consecutive days with evenly distributed assets
const numDays = Math.min(5, Math.floor(totalAssets / 15));
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
return generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
},
'consecutive-small': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Multiple consecutive days with 1-3 assets each (side-by-side layout)
const assets: MockTimelineAsset[] = [];
const numDays = Math.min(totalAssets, Math.floor(daysInMonth / 2));
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
let assetIndex = 0;
for (let i = 0; i < numDays && assetIndex < totalAssets; i++) {
const dayAssets = Math.min(3, rng.nextInt(1, 4));
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
// Create a new RNG for this day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, startDay + i, actualAssets, ownerId, dayRng));
assetIndex += actualAssets;
}
return assets;
},
alternating: (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Alternate between large (15-25) and small (1-3) days
const assets: MockTimelineAsset[] = [];
let day = 1;
let isLarge = true;
let assetIndex = 0;
while (assetIndex < totalAssets && day <= daysInMonth) {
const dayAssets = isLarge ? Math.min(25, rng.nextInt(15, 26)) : rng.nextInt(1, 4);
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
// Create a new RNG for this day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, day, actualAssets, ownerId, dayRng));
assetIndex += actualAssets;
day += isLarge ? 1 : 1; // Could add gaps here
isLarge = !isLarge;
}
return assets;
},
'sparse-scattered': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Spread assets across random days with gaps
const assets: MockTimelineAsset[] = [];
const numDays = Math.min(totalAssets, Math.floor(daysInMonth * GENERATION_CONSTANTS.SPARSE_DAY_COVERAGE));
const daysWithPhotos = selectRandomDays(daysInMonth, numDays, rng);
let assetIndex = 0;
for (let i = 0; i < daysWithPhotos.length && assetIndex < totalAssets; i++) {
const dayAssets =
Math.floor(totalAssets / numDays) + (i === daysWithPhotos.length - 1 ? totalAssets % numDays : 0);
// Create a new RNG for this day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, daysWithPhotos[i], dayAssets, ownerId, dayRng));
assetIndex += dayAssets;
}
return assets;
},
'start-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Most assets in first week
const assets: MockTimelineAsset[] = [];
const firstWeekAssets = Math.floor(totalAssets * 0.7);
const remainingAssets = totalAssets - firstWeekAssets;
// First 7 days
assets.push(...generateConsecutiveDays(year, month, 1, 7, firstWeekAssets, ownerId, rng));
// Remaining scattered
if (remainingAssets > 0) {
const midDay = Math.floor(daysInMonth / 2);
// Create a new RNG for the remaining assets
const remainingRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, midDay, remainingAssets, ownerId, remainingRng));
}
return assets;
},
'end-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Most assets in last week
const assets: MockTimelineAsset[] = [];
const lastWeekAssets = Math.floor(totalAssets * 0.7);
const remainingAssets = totalAssets - lastWeekAssets;
// Remaining at start
if (remainingAssets > 0) {
// Create a new RNG for the start assets
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, 2, remainingAssets, ownerId, startRng));
}
// Last 7 days
const startDay = daysInMonth - 6;
assets.push(...generateConsecutiveDays(year, month, startDay, 7, lastWeekAssets, ownerId, rng));
return assets;
},
'mid-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Most assets in middle of month
const assets: MockTimelineAsset[] = [];
const midAssets = Math.floor(totalAssets * 0.7);
const sideAssets = Math.floor((totalAssets - midAssets) / 2);
// Start
if (sideAssets > 0) {
// Create a new RNG for the start assets
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, 2, sideAssets, ownerId, startRng));
}
// Middle
const midStart = Math.floor(daysInMonth / 2) - 3;
assets.push(...generateConsecutiveDays(year, month, midStart, 7, midAssets, ownerId, rng));
// End
const endAssets = totalAssets - midAssets - sideAssets;
if (endAssets > 0) {
// Create a new RNG for the end assets
const endRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, daysInMonth - 1, endAssets, ownerId, endRng));
}
return assets;
},
};
export type MonthDistribution =
| 'empty' // 0 assets
| 'sparse' // 3-8 assets
| 'medium' // 15-30 assets
| 'dense' // 50-80 assets
| 'very-dense'; // 80-150 assets
export type DayPattern =
| 'single-day' // All images in one day
| 'consecutive-large' // Multiple days with 15-25 images each
| 'consecutive-small' // Multiple days with 1-3 images each (side-by-side)
| 'alternating' // Alternating large/small days
| 'sparse-scattered' // Few images scattered across month
| 'start-heavy' // Most images at start of month
| 'end-heavy' // Most images at end of month
| 'mid-heavy'; // Most images in middle of month
-111
View File
@@ -1,111 +0,0 @@
import sharp from 'sharp';
import { SeededRandom } from 'src/ui/generators/timeline/utils.js';
export const randomThumbnail = async (seed: string, ratio: number) => {
const height = 235;
const width = Math.round(height * ratio);
return randomImageFromString(seed, { width, height });
};
export const randomPreview = async (seed: string, ratio: number) => {
const height = 500;
const width = Math.round(height * ratio);
return randomImageFromString(seed, { width, height });
};
export const randomImageFromString = async (
seed: string = '',
{ width = 100, height = 100 }: { width: number; height: number },
) => {
// Convert string to number for seeding
let seedNumber = 0;
for (let i = 0; i < seed.length; i++) {
seedNumber = (seedNumber << 5) - seedNumber + (seed.codePointAt(i) ?? 0);
seedNumber &= seedNumber; // Convert to 32bit integer
}
return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
};
export const randomImage = async (rng: SeededRandom, { width, height }: { width: number; height: number }) => {
const r1 = rng.nextInt(0, 256);
const g1 = rng.nextInt(0, 256);
const b1 = rng.nextInt(0, 256);
const r2 = rng.nextInt(0, 256);
const g2 = rng.nextInt(0, 256);
const b2 = rng.nextInt(0, 256);
const patternType = rng.nextInt(0, 5);
let svgPattern = '';
switch (patternType) {
case 0: {
// Solid color
svgPattern = `<svg width="${width}" height="${height}">
<rect x="0" y="0" width="${width}" height="${height}" fill="rgb(${r1},${g1},${b1})"/>
</svg>`;
break;
}
case 1: {
// Horizontal stripes
const stripeHeight = 10;
svgPattern = `<svg width="${width}" height="${height}">
${Array.from(
{ length: height / stripeHeight },
(_, i) =>
`<rect x="0" y="${i * stripeHeight}" width="${width}" height="${stripeHeight}"
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
).join('')}
</svg>`;
break;
}
case 2: {
// Vertical stripes
const stripeWidth = 10;
svgPattern = `<svg width="${width}" height="${height}">
${Array.from(
{ length: width / stripeWidth },
(_, i) =>
`<rect x="${i * stripeWidth}" y="0" width="${stripeWidth}" height="${height}"
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
).join('')}
</svg>`;
break;
}
case 3: {
// Checkerboard
const squareSize = 10;
svgPattern = `<svg width="${width}" height="${height}">
${Array.from({ length: height / squareSize }, (_, row) =>
Array.from({ length: width / squareSize }, (_, col) => {
const isEven = (row + col) % 2 === 0;
return `<rect x="${col * squareSize}" y="${row * squareSize}"
width="${squareSize}" height="${squareSize}"
fill="rgb(${isEven ? r1 : r2},${isEven ? g1 : g2},${isEven ? b1 : b2})"/>`;
}).join(''),
).join('')}
</svg>`;
break;
}
case 4: {
// Diagonal stripes
svgPattern = `<svg width="${width}" height="${height}">
<defs>
<pattern id="diagonal" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="10" height="20" fill="rgb(${r1},${g1},${b1})"/>
<rect x="10" y="0" width="10" height="20" fill="rgb(${r2},${g2},${b2})"/>
</pattern>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#diagonal)" transform="rotate(45 50 50)"/>
</svg>`;
break;
}
}
const svgBuffer = Buffer.from(svgPattern);
const jpegData = await sharp(svgBuffer).jpeg({ quality: 50 }).toBuffer();
return jpegData;
};
@@ -1,259 +0,0 @@
/** Generator functions for timeline model objects */
import { faker } from '@faker-js/faker';
import { AssetVisibility } from '@immich/sdk';
import { DateTime } from 'luxon';
import { writeFileSync } from 'node:fs';
import { SeededRandom } from 'src/ui/generators/timeline/utils.js';
import type { DayPattern, MonthDistribution } from './distribution-patterns';
import { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './distribution-patterns';
import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config';
/** Generate a random aspect ratio based on weighted probabilities */
export function generateAspectRatio(rng: SeededRandom): string {
const random = rng.next();
let cumulative = 0;
for (const [ratio, weight] of Object.entries(ASPECT_RATIO_WEIGHTS)) {
cumulative += weight;
if (random < cumulative) {
return ratio;
}
}
return '16:9'; // Default fallback
}
export function generateThumbhash(rng: SeededRandom): string {
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
}
export function generateDuration(rng: SeededRandom): number {
return (
rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS) *
1000 +
rng.nextInt(0, 1000)
);
}
export function generateUUID(): string {
return faker.string.uuid();
}
export function generateAsset(
year: number,
month: number,
day: number,
ownerId: string,
rng: SeededRandom,
): MockTimelineAsset {
const from = DateTime.fromObject({ year, month, day }).setZone('UTC');
const to = from.endOf('day');
const date = faker.date.between({ from: from.toJSDate(), to: to.toJSDate() });
const isVideo = rng.next() < GENERATION_CONSTANTS.VIDEO_PROBABILITY;
const assetId = generateUUID();
const hasGPS = rng.next() < GENERATION_CONSTANTS.GPS_PERCENTAGE;
const ratio = generateAspectRatio(rng);
const asset: MockTimelineAsset = {
id: assetId,
ownerId,
ratio: Number(ratio.split(':', 1)[0]) / Number(ratio.split(':', 2)[1]),
thumbhash: generateThumbhash(rng),
localDateTime: date.toISOString(),
fileCreatedAt: date.toISOString(),
isFavorite: rng.next() < GENERATION_CONSTANTS.FAVORITE_PROBABILITY,
isTrashed: false,
isVideo,
isImage: !isVideo,
duration: isVideo ? generateDuration(rng) : null,
projectionType: null,
livePhotoVideoId: null,
city: hasGPS ? faker.location.city() : null,
country: hasGPS ? faker.location.country() : null,
people: null,
latitude: hasGPS ? faker.location.latitude() : null,
longitude: hasGPS ? faker.location.longitude() : null,
visibility: AssetVisibility.Timeline,
stack: null,
fileSizeInByte: faker.number.int({ min: 510, max: 5_000_000 }),
checksum: faker.string.alphanumeric({ length: 5 }),
};
return asset;
}
/** Generate assets for a specific day */
export function generateDayAssets(
year: number,
month: number,
day: number,
assetCount: number,
ownerId: string,
rng: SeededRandom,
): MockTimelineAsset[] {
return Array.from({ length: assetCount }, () => generateAsset(year, month, day, ownerId, rng));
}
/**
Distribute assets evenly across consecutive days
@returns Array of generated timeline assets
*/
export function generateConsecutiveDays(
year: number,
month: number,
startDay: number,
numDays: number,
totalAssets: number,
ownerId: string,
rng: SeededRandom,
): MockTimelineAsset[] {
const assets: MockTimelineAsset[] = [];
const assetsPerDay = Math.floor(totalAssets / numDays);
for (let i = 0; i < numDays; i++) {
const dayAssets =
i === numDays - 1
? totalAssets - assetsPerDay * (numDays - 1) // Remainder on last day
: assetsPerDay;
// Create a new RNG with a different seed for each day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000) + i * 100);
assets.push(...generateDayAssets(year, month, startDay + i, dayAssets, ownerId, dayRng));
}
return assets;
}
/** Generate assets for a month with specified distribution pattern */
export function generateMonthAssets(
year: number,
month: number,
ownerId: string,
distribution: MonthDistribution = 'medium',
pattern: DayPattern = 'consecutive-large',
rng: SeededRandom,
): MockTimelineAsset[] {
const daysInMonth = new Date(year, month, 0).getDate();
if (distribution === 'empty') {
return [];
}
const distributionStrategy = ASSET_DISTRIBUTION[distribution];
if (!distributionStrategy) {
console.warn(`Unknown distribution: ${distribution}, defaulting to medium`);
return [];
}
const totalAssets = distributionStrategy(rng);
const dayStrategy = DAY_DISTRIBUTION[pattern];
if (!dayStrategy) {
console.warn(`Unknown pattern: ${pattern}, defaulting to consecutive-large`);
// Fallback to consecutive-large pattern
const numDays = Math.min(5, Math.floor(totalAssets / 15));
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
const assets = generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
return assets;
}
const assets = dayStrategy(year, month, daysInMonth, totalAssets, ownerId, rng);
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
return assets;
}
/** Main generator function for timeline data */
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
validateTimelineConfig(config);
const buckets = new Map<string, MockTimelineAsset[]>();
const monthStats: Record<string, { count: number; distribution: MonthDistribution; pattern: DayPattern }> = {};
const globalRng = new SeededRandom(config.seed || GENERATION_CONSTANTS.DEFAULT_SEED);
faker.seed(globalRng.nextInt(0, 1_000_000));
for (const monthConfig of config.months) {
const { year, month, distribution, pattern } = monthConfig;
const monthSeed = globalRng.nextInt(0, 1_000_000);
const monthRng = new SeededRandom(monthSeed);
const monthAssets = generateMonthAssets(
year,
month,
config.ownerId || generateUUID(),
distribution,
pattern,
monthRng,
);
if (monthAssets.length > 0) {
const monthKey = `${year}-${month.toString().padStart(2, '0')}`;
monthStats[monthKey] = {
count: monthAssets.length,
distribution,
pattern,
};
// Create bucket key (YYYY-MM-01)
const bucketKey = `${year}-${month.toString().padStart(2, '0')}-01`;
buckets.set(bucketKey, monthAssets);
}
}
// Create a mock album from random assets
const allAssets = buckets.values().toArray().flat();
// Select 10-30 random assets for the album (or all assets if less than 10)
const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));
const selectedAssetConfigs: MockTimelineAsset[] = [];
const usedIndices = new Set<number>();
while (selectedAssetConfigs.length < albumSize && usedIndices.size < allAssets.length) {
const randomIndex = globalRng.nextInt(0, allAssets.length);
if (!usedIndices.has(randomIndex)) {
usedIndices.add(randomIndex);
selectedAssetConfigs.push(allAssets[randomIndex]);
}
}
// Sort selected assets by date (newest first)
selectedAssetConfigs.sort(
(a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds,
);
const selectedAssets = selectedAssetConfigs.map((asset) => asset.id);
const now = new Date().toISOString();
const album = {
id: generateUUID(),
albumName: 'Test Album',
description: 'A mock album for testing',
assetIds: selectedAssets,
thumbnailAssetId: selectedAssets.length > 0 ? selectedAssets[0] : null,
createdAt: now,
updatedAt: now,
};
// Write to file if configured
if (config.writeToFile) {
const outputPath = config.outputPath || '/tmp/timeline-data.json';
// Convert Map to object for serialization
const serializedData: SerializedTimelineData = {
buckets: Object.fromEntries(buckets),
album,
};
try {
writeFileSync(outputPath, JSON.stringify(serializedData, null, 2));
console.log(`Timeline data written to ${outputPath}`);
} catch (error) {
console.error(`Failed to write timeline data to ${outputPath}:`, error);
}
}
return { buckets, album };
}
@@ -1,427 +0,0 @@
/** REST API output functions for converting timeline data to API response formats */
import {
AlbumUserRole,
AssetTypeEnum,
AssetVisibility,
UserAvatarColor,
type AlbumResponseDto,
type AssetResponseDto,
type ExifResponseDto,
type TimeBucketAssetResponseDto,
type TimeBucketsResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { DateTime } from 'luxon';
import { signupDto } from 'src/fixtures.js';
import { parseTimeBucketKey } from 'src/ui/generators/timeline/utils.js';
import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
/** Convert timeline/asset models to columnar format (parallel arrays) */
export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
const result: TimeBucketAssetResponseDto = {
id: [],
ownerId: [],
ratio: [],
thumbhash: [],
createdAt: [],
fileCreatedAt: [],
localOffsetHours: [],
isFavorite: [],
isTrashed: [],
isImage: [],
duration: [],
projectionType: [],
livePhotoVideoId: [],
city: [],
country: [],
visibility: [],
};
for (const asset of assets) {
result.id.push(asset.id);
result.ownerId.push(asset.ownerId);
result.ratio.push(asset.ratio);
result.thumbhash.push(asset.thumbhash);
result.fileCreatedAt.push(asset.fileCreatedAt);
result.localOffsetHours.push(0); // Assuming UTC for mocks
result.isFavorite.push(asset.isFavorite);
result.isTrashed.push(asset.isTrashed);
result.isImage.push(asset.isImage);
result.duration.push(asset.duration);
result.projectionType.push(asset.projectionType);
result.livePhotoVideoId.push(asset.livePhotoVideoId);
result.city?.push(asset.city);
result.country?.push(asset.country);
result.visibility.push(asset.visibility);
}
if (assets.some((a) => a.latitude !== null || a.longitude !== null)) {
result.latitude = assets.map((a) => a.latitude);
result.longitude = assets.map((a) => a.longitude);
}
result.stack = assets.map(() => null);
return result;
}
/**
Extract a single bucket from timeline data (mimics getTimeBucket API)
Automatically handles both ISO timestamp and simple month formats
Returns data in columnar format matching the actual API
When albumId is provided, only returns assets from that album
*/
export function getTimeBucket(
timelineData: MockTimelineData,
timeBucket: string,
isTrashed: boolean | undefined,
isArchived: boolean | undefined,
isFavorite: boolean | undefined,
albumId: string | undefined,
changes: Changes,
): TimeBucketAssetResponseDto {
const bucketKey = parseTimeBucketKey(timeBucket);
let assets = timelineData.buckets.get(bucketKey);
if (!assets) {
return toColumnarFormat([]);
}
// Create sets for quick lookups
const deletedAssetIds = new Set(changes.assetDeletions);
const archivedAssetIds = new Set(changes.assetArchivals);
const favoritedAssetIds = new Set(changes.assetFavorites);
// Filter assets based on trashed/archived status
assets = assets.filter((asset) =>
shouldIncludeAsset(asset, isTrashed, isArchived, isFavorite, deletedAssetIds, archivedAssetIds, favoritedAssetIds),
);
// Filter to only include assets from the specified album
if (albumId) {
const album = timelineData.album;
if (!album || album.id !== albumId) {
return toColumnarFormat([]);
}
// Create a Set for faster lookup
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
assets = assets.filter((asset) => albumAssetIds.has(asset.id));
}
// Override properties for assets in changes arrays
const assetsWithOverrides = assets.map((asset) => {
if (deletedAssetIds.has(asset.id) || archivedAssetIds.has(asset.id) || favoritedAssetIds.has(asset.id)) {
return {
...asset,
isFavorite: favoritedAssetIds.has(asset.id) ? true : asset.isFavorite,
isTrashed: deletedAssetIds.has(asset.id) ? true : asset.isTrashed,
visibility: archivedAssetIds.has(asset.id) ? AssetVisibility.Archive : asset.visibility,
};
}
return asset;
});
return toColumnarFormat(assetsWithOverrides);
}
export type Changes = {
// ids of assets that are newly added to the album
albumAdditions: string[];
// ids of assets that are newly deleted
assetDeletions: string[];
// ids of assets that are newly archived
assetArchivals: string[];
// ids of assets that are newly favorited
assetFavorites: string[];
};
/**
Helper function to determine if an asset should be included based on filter criteria
@param asset - The asset to check
@param isTrashed - Filter for trashed status (undefined means no filter)
@param isArchived - Filter for archived status (undefined means no filter)
@param isFavorite - Filter for favorite status (undefined means no filter)
@param deletedAssetIds - Set of IDs for assets that have been deleted
@param archivedAssetIds - Set of IDs for assets that have been archived
@param favoritedAssetIds - Set of IDs for assets that have been favorited
@returns true if the asset matches all filter criteria
*/
function shouldIncludeAsset(
asset: MockTimelineAsset,
isTrashed: boolean | undefined,
isArchived: boolean | undefined,
isFavorite: boolean | undefined,
deletedAssetIds: Set<string>,
archivedAssetIds: Set<string>,
favoritedAssetIds: Set<string>,
): boolean {
// Determine actual status (property or in changes)
const actuallyTrashed = asset.isTrashed || deletedAssetIds.has(asset.id);
const actuallyArchived = asset.visibility === 'archive' || archivedAssetIds.has(asset.id);
const actuallyFavorited = asset.isFavorite || favoritedAssetIds.has(asset.id);
// Apply filters
if (isTrashed !== undefined && actuallyTrashed !== isTrashed) {
return false;
}
if (isArchived !== undefined && actuallyArchived !== isArchived) {
return false;
}
return isFavorite === undefined || actuallyFavorited === isFavorite;
}
/**
Get summary for all buckets (mimics getTimeBuckets API)
When albumId is provided, only includes buckets that contain assets from that album
*/
export function getTimeBuckets(
timelineData: MockTimelineData,
isTrashed: boolean | undefined,
isArchived: boolean | undefined,
isFavorite: boolean | undefined,
albumId: string | undefined,
changes: Changes,
): TimeBucketsResponseDto[] {
const summary: TimeBucketsResponseDto[] = [];
// Create sets for quick lookups
const deletedAssetIds = new Set(changes.assetDeletions);
const archivedAssetIds = new Set(changes.assetArchivals);
const favoritedAssetIds = new Set(changes.assetFavorites);
// If no albumId is specified, return summary for all assets
if (albumId) {
// Filter to only include buckets with assets from the specified album
const album = timelineData.album;
if (!album || album.id !== albumId) {
return [];
}
// Create a Set for faster lookup
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
for (const removed of changes.assetDeletions) {
albumAssetIds.delete(removed);
}
for (const [bucketKey, assets] of timelineData.buckets) {
// Count how many assets in this bucket are in the album and match trashed/archived filters
const albumAssetsInBucket = assets.filter((asset) => {
// Must be in the album
if (!albumAssetIds.has(asset.id)) {
return false;
}
return shouldIncludeAsset(
asset,
isTrashed,
isArchived,
isFavorite,
deletedAssetIds,
archivedAssetIds,
favoritedAssetIds,
);
});
if (albumAssetsInBucket.length > 0) {
summary.push({
timeBucket: bucketKey,
count: albumAssetsInBucket.length,
});
}
}
} else {
for (const [bucketKey, assets] of timelineData.buckets) {
// Filter assets based on trashed/archived status
const filteredAssets = assets.filter((asset) =>
shouldIncludeAsset(
asset,
isTrashed,
isArchived,
isFavorite,
deletedAssetIds,
archivedAssetIds,
favoritedAssetIds,
),
);
if (filteredAssets.length > 0) {
summary.push({
timeBucket: bucketKey,
count: filteredAssets.length,
});
}
}
}
// Sort summary by date (newest first) using luxon
summary.sort((a, b) => {
const dateA = DateTime.fromISO(a.timeBucket);
const dateB = DateTime.fromISO(b.timeBucket);
return dateB.diff(dateA).milliseconds;
});
return summary;
}
const createDefaultOwner = (ownerId: string) => {
const defaultOwner: UserResponseDto = {
id: ownerId,
email: signupDto.admin.email,
name: signupDto.admin.name,
profileImagePath: '',
profileChangedAt: new Date().toISOString(),
avatarColor: UserAvatarColor.Blue,
};
return defaultOwner;
};
/**
Convert a TimelineAssetConfig to a full AssetResponseDto
This matches the response from GET /api/assets/:id
*/
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
const now = new Date().toISOString();
// Default owner if not provided
const defaultOwner = createDefaultOwner(asset.ownerId);
const exifInfo: ExifResponseDto = {
make: null,
model: null,
exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
exifImageHeight: Math.round(asset.ratio > 1 ? 4000 : 3000 / asset.ratio),
fileSizeInByte: asset.fileSizeInByte,
orientation: '1',
dateTimeOriginal: asset.fileCreatedAt,
modifyDate: asset.fileCreatedAt,
timeZone: asset.latitude === null ? null : 'UTC',
lensModel: null,
fNumber: null,
focalLength: null,
iso: null,
exposureTime: null,
latitude: asset.latitude,
longitude: asset.longitude,
city: asset.city,
country: asset.country,
state: null,
description: null,
};
return {
id: asset.id,
ownerId: asset.ownerId,
owner: owner || defaultOwner,
libraryId: `library-${asset.ownerId}`,
type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
originalMimeType: asset.isVideo ? 'video/mp4' : 'image/jpeg',
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
updatedAt: now,
createdAt: asset.fileCreatedAt,
isFavorite: asset.isFavorite,
isArchived: false,
isTrashed: asset.isTrashed,
visibility: asset.visibility,
duration: asset.duration,
exifInfo,
livePhotoVideoId: asset.livePhotoVideoId,
tags: [],
people: [],
stack: asset.stack,
isOffline: false,
hasMetadata: true,
duplicateId: null,
resized: true,
checksum: asset.checksum,
width: exifInfo.exifImageWidth ?? 1,
height: exifInfo.exifImageHeight ?? 1,
isEdited: false,
};
}
/**
Get a single asset by ID from timeline data
This matches the response from GET /api/assets/:id
*/
export function getAsset(
timelineData: MockTimelineData,
assetId: string,
owner?: UserResponseDto,
): AssetResponseDto | undefined {
// Search through all buckets for the asset
const buckets = timelineData.buckets.values().toArray();
for (const assets of buckets) {
const asset = assets.find((a) => a.id === assetId);
if (asset) {
return toAssetResponseDto(asset, owner);
}
}
return undefined;
}
/**
Get a mock album from timeline data
This matches the response from GET /api/albums/:id
*/
export function getAlbum(
timelineData: MockTimelineData,
ownerId: string,
albumId: string | undefined,
changes: Changes,
): AlbumResponseDto | undefined {
if (!timelineData.album) {
return undefined;
}
// If albumId is provided and doesn't match, return undefined
if (albumId && albumId !== timelineData.album.id) {
return undefined;
}
const album = timelineData.album;
const albumOwner = createDefaultOwner(ownerId);
// Get the actual asset objects from the timeline data
const albumAssets: AssetResponseDto[] = [];
const allAssets = timelineData.buckets.values().toArray().flat();
for (const assetId of album.assetIds) {
const assetConfig = allAssets.find((a) => a.id === assetId);
if (assetConfig) {
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
}
}
for (const assetId of changes.albumAdditions ?? []) {
const assetConfig = allAssets.find((a) => a.id === assetId);
if (assetConfig) {
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
}
}
albumAssets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
// For a basic mock album, we don't include any albumUsers (shared users)
// The owner is represented by the owner field, not in albumUsers
const response: AlbumResponseDto = {
id: album.id,
albumName: album.albumName,
description: album.description,
albumThumbnailAssetId: album.thumbnailAssetId,
createdAt: album.createdAt,
updatedAt: album.updatedAt,
albumUsers: [{ user: albumOwner, role: AlbumUserRole.Owner }],
shared: false,
hasSharedLink: false,
isActivityEnabled: true,
assetCount: albumAssets.length,
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,
};
return response;
}
@@ -1,187 +0,0 @@
import type { AssetVisibility } from '@immich/sdk';
import { DayPattern, MonthDistribution } from 'src/ui/generators/timeline/distribution-patterns.js';
// Constants for generation parameters
export const GENERATION_CONSTANTS = {
VIDEO_PROBABILITY: 0.15, // 15% of assets are videos
GPS_PERCENTAGE: 0.7, // 70% of assets have GPS data
FAVORITE_PROBABILITY: 0.1, // 10% of assets are favorited
MIN_VIDEO_DURATION_SECONDS: 5,
MAX_VIDEO_DURATION_SECONDS: 300,
DEFAULT_SEED: 12_345,
DEFAULT_OWNER_ID: 'user-1',
MAX_SELECT_ATTEMPTS: 10,
SPARSE_DAY_COVERAGE: 0.4, // 40% of days have photos in sparse pattern
} as const;
// Aspect ratio distribution weights (must sum to 1)
export const ASPECT_RATIO_WEIGHTS = {
'4:3': 0.35, // 35% 4:3 landscape
'3:2': 0.25, // 25% 3:2 landscape
'16:9': 0.2, // 20% 16:9 landscape
'2:3': 0.1, // 10% 2:3 portrait
'1:1': 0.09, // 9% 1:1 square
'3:1': 0.01, // 1% 3:1 panorama
} as const;
// Mock configuration for asset generation - will be transformed to API response formats
export type MockTimelineAsset = {
id: string;
ownerId: string;
ratio: number;
thumbhash: string | null;
localDateTime: string;
fileCreatedAt: string;
isFavorite: boolean;
isTrashed: boolean;
isVideo: boolean;
isImage: boolean;
duration: number | null;
projectionType: string | null;
livePhotoVideoId: string | null;
city: string | null;
country: string | null;
people: string[] | null;
latitude: number | null;
longitude: number | null;
visibility: AssetVisibility;
stack: null;
checksum: string;
fileSizeInByte: number;
};
export type MonthSpec = {
year: number;
month: number; // 1-12
distribution: MonthDistribution;
pattern: DayPattern;
};
/** Configuration for timeline data generation */
export type TimelineConfig = {
ownerId?: string;
months: MonthSpec[];
seed?: number;
writeToFile?: boolean;
outputPath?: string;
};
export type MockAlbum = {
id: string;
albumName: string;
description: string;
assetIds: string[]; // IDs of assets in the album
thumbnailAssetId: string | null;
createdAt: string;
updatedAt: string;
};
export type MockTimelineData = {
buckets: Map<string, MockTimelineAsset[]>;
album: MockAlbum; // Mock album created from random assets
};
export type SerializedTimelineData = {
buckets: Record<string, MockTimelineAsset[]>;
album: MockAlbum;
};
/** Validates a TimelineConfig object to ensure all values are within expected ranges */
export function validateTimelineConfig(config: TimelineConfig): void {
if (!config.months || config.months.length === 0) {
throw new Error('TimelineConfig must contain at least one month');
}
const seenMonths = new Set<string>();
for (const month of config.months) {
if (month.month < 1 || month.month > 12) {
throw new Error(`Invalid month: ${month.month}. Must be between 1 and 12`);
}
if (month.year < 1900 || month.year > 2100) {
throw new Error(`Invalid year: ${month.year}. Must be between 1900 and 2100`);
}
const monthKey = `${month.year}-${month.month}`;
if (seenMonths.has(monthKey)) {
throw new Error(`Duplicate month found: ${monthKey}`);
}
seenMonths.add(monthKey);
// Validate distribution if provided
if (month.distribution && !['empty', 'sparse', 'medium', 'dense', 'very-dense'].includes(month.distribution)) {
throw new Error(
`Invalid distribution: ${month.distribution}. Must be one of: empty, sparse, medium, dense, very-dense`,
);
}
const validPatterns = [
'single-day',
'consecutive-large',
'consecutive-small',
'alternating',
'sparse-scattered',
'start-heavy',
'end-heavy',
'mid-heavy',
];
if (month.pattern && !validPatterns.includes(month.pattern)) {
throw new Error(`Invalid pattern: ${month.pattern}. Must be one of: ${validPatterns.join(', ')}`);
}
}
// Validate seed if provided
if (config.seed !== undefined && (config.seed < 0 || !Number.isSafeInteger(config.seed))) {
throw new Error('Seed must be a non-negative integer');
}
// Validate ownerId if provided
if (config.ownerId !== undefined && config.ownerId.trim() === '') {
throw new Error('Owner ID cannot be an empty string');
}
}
/** Create a default timeline configuration */
export function createDefaultTimelineConfig(): TimelineConfig {
const months: MonthSpec[] = [
// 2024 - Mix of patterns
{ year: 2024, month: 12, distribution: 'very-dense', pattern: 'alternating' },
{ year: 2024, month: 11, distribution: 'dense', pattern: 'consecutive-large' },
{ year: 2024, month: 10, distribution: 'medium', pattern: 'mid-heavy' },
{ year: 2024, month: 9, distribution: 'sparse', pattern: 'consecutive-small' },
{ year: 2024, month: 8, distribution: 'empty', pattern: 'single-day' },
{ year: 2024, month: 7, distribution: 'dense', pattern: 'start-heavy' },
{ year: 2024, month: 6, distribution: 'medium', pattern: 'sparse-scattered' },
{ year: 2024, month: 5, distribution: 'sparse', pattern: 'single-day' },
{ year: 2024, month: 4, distribution: 'very-dense', pattern: 'consecutive-large' },
{ year: 2024, month: 3, distribution: 'empty', pattern: 'single-day' },
{ year: 2024, month: 2, distribution: 'medium', pattern: 'end-heavy' },
{ year: 2024, month: 1, distribution: 'dense', pattern: 'alternating' },
// 2023 - Testing year boundaries and more patterns
{ year: 2023, month: 12, distribution: 'very-dense', pattern: 'end-heavy' },
{ year: 2023, month: 11, distribution: 'sparse', pattern: 'consecutive-small' },
{ year: 2023, month: 10, distribution: 'empty', pattern: 'single-day' },
{ year: 2023, month: 9, distribution: 'medium', pattern: 'alternating' },
{ year: 2023, month: 8, distribution: 'dense', pattern: 'mid-heavy' },
{ year: 2023, month: 7, distribution: 'sparse', pattern: 'sparse-scattered' },
{ year: 2023, month: 6, distribution: 'medium', pattern: 'consecutive-large' },
{ year: 2023, month: 5, distribution: 'empty', pattern: 'single-day' },
{ year: 2023, month: 4, distribution: 'sparse', pattern: 'single-day' },
{ year: 2023, month: 3, distribution: 'dense', pattern: 'start-heavy' },
{ year: 2023, month: 2, distribution: 'medium', pattern: 'alternating' },
{ year: 2023, month: 1, distribution: 'very-dense', pattern: 'consecutive-large' },
];
for (let year = 2022; year >= 2000; year--) {
for (let month = 12; month >= 1; month--) {
months.push({ year, month, distribution: 'medium', pattern: 'sparse-scattered' });
}
}
return {
months,
seed: 42,
};
}
-172
View File
@@ -1,172 +0,0 @@
import { DateTime } from 'luxon';
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config.js';
/** Linear Congruential Generator for deterministic pseudo-random numbers */
export class SeededRandom {
private seed: number;
constructor(seed: number) {
this.seed = seed;
}
/** Generate next random number in range [0, 1) */
next(): number {
// LCG parameters from Numerical Recipes
this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
return this.seed / 2_147_483_647;
}
/** Generate random integer in range [min, max) */
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min)) + min;
}
/** Generate random boolean with given probability */
nextBoolean(probability = 0.5): boolean {
return this.next() < probability;
}
}
/**
Select random days using seed variation to avoid collisions.
@param daysInMonth - Total number of days in the month
@param numDays - Number of days to select
@param rng - Random number generator instance
@returns Array of selected day numbers, sorted in descending order
*/
export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
const selectedDays = new Set<number>();
const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit
let attempts = 0;
while (selectedDays.size < numDays && attempts < maxAttempts) {
const day = rng.nextInt(1, daysInMonth + 1);
selectedDays.add(day);
attempts++;
}
// Fallback: if we couldn't select enough random days, fill with sequential days
if (selectedDays.size < numDays) {
for (let day = 1; day <= daysInMonth && selectedDays.size < numDays; day++) {
selectedDays.add(day);
}
}
return [...selectedDays].toSorted((a, b) => b - a);
}
/** Select item from array using seeded random */
export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
if (arr.length === 0) {
throw new Error('Cannot select from empty array');
}
const index = rng.nextInt(0, arr.length);
return arr[index];
}
/** Select multiple random items from array using seeded random without duplicates */
export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] {
if (arr.length === 0) {
throw new Error('Cannot select from empty array');
}
if (count < 0) {
throw new Error('Count must be non-negative');
}
if (count > arr.length) {
throw new Error('Count cannot exceed array length');
}
const result: T[] = [];
const selectedIndices = new Set<number>();
while (result.length < count) {
const index = rng.nextInt(0, arr.length);
if (!selectedIndices.has(index)) {
selectedIndices.add(index);
result.push(arr[index]);
}
}
return result;
}
/**
Parse timeBucket parameter to extract year-month key
Handles both formats:
- ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
- Simple format: "2024-12-01" -> "2024-12-01"
*/
export function parseTimeBucketKey(timeBucket: string): string {
if (!timeBucket) {
throw new Error('timeBucket parameter cannot be empty');
}
const dt = DateTime.fromISO(timeBucket, { zone: 'utc' });
if (!dt.isValid) {
// Fallback to regex if not a valid ISO string
const match = timeBucket.match(/^(\d{4}-\d{2}-\d{2})/);
return match ? match[1] : timeBucket;
}
// Format as YYYY-MM-01 (first day of month)
return `${dt.year}-${String(dt.month).padStart(2, '0')}-01`;
}
export function getMockAsset(
asset: MockTimelineAsset,
sortedDescendingAssets: MockTimelineAsset[],
direction: 'next' | 'previous',
unit: 'day' | 'month' | 'year' = 'day',
): MockTimelineAsset | null {
const currentDateTime = DateTime.fromISO(asset.localDateTime, { zone: 'utc' });
const currentIndex = sortedDescendingAssets.findIndex((a) => a.id === asset.id);
if (currentIndex === -1) {
return null;
}
const step = direction === 'next' ? 1 : -1;
const startIndex = currentIndex + step;
if (direction === 'next' && currentIndex >= sortedDescendingAssets.length - 1) {
return null;
}
if (direction === 'previous' && currentIndex <= 0) {
return null;
}
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
if (unit === 'day') {
return !date1.startOf('day').equals(date2.startOf('day'));
}
return unit === 'month' ? date1.year !== date2.year || date1.month !== date2.month : date1.year !== date2.year;
};
if (direction === 'next') {
// Search forward in array (backwards in time)
for (let i = startIndex; i < sortedDescendingAssets.length; i++) {
const nextAsset = sortedDescendingAssets[i];
const nextDate = DateTime.fromISO(nextAsset.localDateTime, { zone: 'utc' });
if (isInDifferentPeriod(nextDate, currentDateTime)) {
return nextAsset;
}
}
} else {
// Search backward in array (forwards in time)
for (let i = startIndex; i >= 0; i--) {
const prevAsset = sortedDescendingAssets[i];
const prevDate = DateTime.fromISO(prevAsset.localDateTime, { zone: 'utc' });
if (isInDifferentPeriod(prevDate, currentDateTime)) {
return prevAsset;
}
}
}
return null;
}
-313
View File
@@ -1,313 +0,0 @@
import { BrowserContext } from '@playwright/test';
import { playwrightHost } from 'src/../playwright.config.js';
import { adminConfig, publicConfig, userConfig } from 'src/ui/mock-network/config-fixtures.js';
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
await context.addCookies([
{
name: 'immich_is_authenticated',
value: 'true',
domain: playwrightHost,
path: '/',
},
]);
await context.route('**/api/users/me', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
id: adminUserId,
email: 'admin@immich.cloud',
name: 'Immich Admin',
profileImagePath: '',
avatarColor: 'orange',
profileChangedAt: '2025-01-22T21:31:23.996Z',
storageLabel: 'admin',
shouldChangePassword: true,
isAdmin: true,
createdAt: '2025-01-22T21:31:23.996Z',
deletedAt: null,
updatedAt: '2025-11-14T00:00:00.369Z',
oauthId: '',
quotaSizeInBytes: null,
quotaUsageInBytes: 20_849_000_159,
status: 'active',
license: null,
},
});
});
await context.route('**/users/me/preferences', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: {
defaultAssetOrder: 'desc',
},
folders: {
enabled: false,
sidebarWeb: false,
},
memories: {
enabled: true,
duration: 5,
},
people: {
enabled: true,
sidebarWeb: false,
},
sharedLinks: {
enabled: true,
sidebarWeb: false,
},
ratings: {
enabled: false,
},
tags: {
enabled: false,
sidebarWeb: false,
},
emailNotifications: {
enabled: true,
albumInvite: true,
albumUpdate: true,
},
download: {
archiveSize: 4_294_967_296,
includeEmbeddedVideos: false,
},
purchase: {
showSupportBadge: true,
hideBuyButtonUntil: '2100-02-12T00:00:00.000Z',
},
cast: {
gCastEnabled: false,
},
recentlyAdded: {
sidebarWeb: false,
},
},
});
});
await context.route('**/server/about', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
version: 'v2.2.3',
versionUrl: 'https://github.com/immich-app/immich/releases/tag/v2.2.3',
licensed: false,
build: '1234567890',
buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890',
buildImage: 'e2e',
buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server',
repository: 'immich-app/immich',
repositoryUrl: 'https://github.com/immich-app/immich',
sourceRef: 'e2e',
sourceCommit: 'e2eeeeeeeeeeeeeeeeee',
sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee',
nodejs: 'v22.18.0',
exiftool: '13.41',
ffmpeg: '7.1.1-6',
libvips: '8.17.2',
imagemagick: '7.1.2-2',
},
});
});
await context.route('**/api/public/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: publicConfig,
});
});
await context.route('**/api/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: userConfig,
});
});
await context.route('**/api/admin/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: adminConfig,
});
});
await context.route('**/api/server/features', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
smartSearch: false,
facialRecognition: false,
duplicateDetection: false,
map: true,
reverseGeocoding: true,
importFaces: false,
sidecar: true,
search: true,
trash: true,
oauth: false,
oauthAutoLaunch: false,
ocr: false,
passwordLogin: true,
configFile: false,
email: false,
},
});
});
await context.route('**/api/server/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
loginPageMessage: '',
trashDays: 30,
userDeleteDelay: 7,
oauthButtonText: 'Login with OAuth',
isInitialized: true,
isOnboarded: true,
externalDomain: '',
publicUsers: true,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
maintenanceMode: false,
},
});
});
await context.route('**/api/server/media-types', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
video: [
'.3gp',
'.3gpp',
'.avi',
'.flv',
'.insv',
'.m2t',
'.m2ts',
'.m4v',
'.mkv',
'.mov',
'.mp4',
'.mpe',
'.mpeg',
'.mpg',
'.mts',
'.ts',
'.vob',
'.webm',
'.wmv',
],
image: [
'.3fr',
'.ari',
'.arw',
'.cap',
'.cin',
'.cr2',
'.cr3',
'.crw',
'.dcr',
'.dng',
'.erf',
'.fff',
'.iiq',
'.k25',
'.kdc',
'.mrw',
'.nef',
'.nrw',
'.orf',
'.ori',
'.pef',
'.psd',
'.raf',
'.raw',
'.rw2',
'.rwl',
'.sr2',
'.srf',
'.srw',
'.x3f',
'.avif',
'.gif',
'.jpeg',
'.jpg',
'.png',
'.webp',
'.bmp',
'.heic',
'.heif',
'.hif',
'.insp',
'.jp2',
'.jpe',
'.jxl',
'.mpo',
'.svg',
'.tif',
'.tiff',
],
sidecar: ['.xmp'],
},
});
});
await context.route('**/api/notifications*', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
});
await context.route('**/api/albums*', async (route, request) => {
const url = request.url();
if (url.endsWith('albums?isShared=true') || url.endsWith('albums?isOwned=true') || url.endsWith('albums')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
}
await route.fallback();
});
await context.route('**/api/memories*', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
});
await context.route('**/api/server/storage', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
diskSize: '100.0 GiB',
diskUse: '74.4 GiB',
diskAvailable: '25.6 GiB',
diskSizeRaw: 107_374_182_400,
diskUseRaw: 79_891_660_800,
diskAvailableRaw: 27_482_521_600,
diskUsagePercentage: 74.4,
},
});
});
await context.route('**/api/server/version-history', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [
{
id: 'd1fbeadc-cb4f-4db3-8d19-8c6a921d5d8e',
createdAt: '2025-11-15T20:14:01.935Z',
version: '2.2.3',
},
],
});
});
};
@@ -1,164 +0,0 @@
import { faker } from '@faker-js/faker';
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline.js';
export type MockStack = {
id: string;
primaryAssetId: string;
assets: AssetResponseDto[];
brokenAssetIds: Set<string>;
assetMap: Map<string, AssetResponseDto>;
};
export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
const assetId = faker.string.uuid();
const now = new Date().toISOString();
return {
id: assetId,
ownerId,
owner: {
id: ownerId,
email: 'admin@immich.cloud',
name: 'Admin',
profileImagePath: '',
profileChangedAt: now,
avatarColor: 'blue' as never,
},
libraryId: `library-${ownerId}`,
type: AssetTypeEnum.Image,
originalPath: `/original/${assetId}.jpg`,
originalFileName: `${assetId}.jpg`,
originalMimeType: 'image/jpeg',
thumbhash: null,
fileCreatedAt: now,
fileModifiedAt: now,
localDateTime: now,
updatedAt: now,
createdAt: now,
isFavorite: false,
isArchived: false,
isTrashed: false,
visibility: AssetVisibility.Timeline,
duration: null,
exifInfo: {
make: null,
model: null,
exifImageWidth: 3000,
exifImageHeight: 4000,
fileSizeInByte: null,
orientation: null,
dateTimeOriginal: now,
modifyDate: null,
timeZone: null,
lensModel: null,
fNumber: null,
focalLength: null,
iso: null,
exposureTime: null,
latitude: null,
longitude: null,
city: null,
country: null,
state: null,
description: null,
},
livePhotoVideoId: null,
tags: [],
people: [],
stack: undefined,
isOffline: false,
hasMetadata: true,
duplicateId: null,
resized: true,
checksum: faker.string.alphanumeric({ length: 28 }),
width: 3000,
height: 4000,
isEdited: false,
};
};
export const createMockStack = (
primaryAssetDto: AssetResponseDto,
additionalAssets: AssetResponseDto[],
brokenAssetIds?: Set<string>,
): MockStack => {
const stackId = faker.string.uuid();
const allAssets = [primaryAssetDto, ...additionalAssets];
const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id));
const assetMap = new Map(allAssets.map((a) => [a.id, a]));
primaryAssetDto.stack = {
id: stackId,
assetCount: allAssets.length,
primaryAssetId: primaryAssetDto.id,
};
return {
id: stackId,
primaryAssetId: primaryAssetDto.id,
assets: allAssets,
brokenAssetIds: resolvedBrokenIds,
assetMap,
};
};
export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => {
await context.route('**/api/stacks/*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const stackResponse: StackResponseDto = {
id: mockStack.id,
primaryAssetId: mockStack.primaryAssetId,
assets: mockStack.assets,
};
return route.fulfill({
status: 200,
contentType: 'application/json',
json: stackResponse,
});
});
await context.route('**/api/assets/*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const segments = url.pathname.split('/');
const assetId = segments.at(-1);
if (assetId && mockStack.assetMap.has(assetId)) {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: mockStack.assetMap.get(assetId),
});
}
return route.fallback();
});
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
if (!route.request().serviceWorker()) {
return route.continue();
}
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern);
if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) {
return route.fallback();
}
if (mockStack.brokenAssetIds.has(match.groups.assetId)) {
return route.fulfill({ status: 404 });
}
const asset = mockStack.assetMap.get(match.groups.assetId)!;
const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000);
const body =
match.groups.size === 'preview'
? await randomPreview(match.groups.assetId, ratio)
: await randomThumbnail(match.groups.assetId, ratio);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body,
});
});
};
-171
View File
@@ -1,171 +0,0 @@
export const publicConfig = {
ffmpeg: { realtime: { enabled: false } },
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
oauth: { autoLaunch: false, buttonText: 'Login with OAuth', enabled: false },
passkey: { enabled: true },
passwordLogin: { enabled: true },
server: { loginPageMessage: '' },
};
export const userConfig = {
ffmpeg: { realtime: { enabled: false, videoCodecs: ['h264', 'hevc'], resolutions: [480, 720, 1080] } },
machineLearning: {
enabled: false,
clip: { enabled: true },
duplicateDetection: { enabled: true },
facialRecognition: { enabled: true, minFaces: 3 },
ocr: { enabled: true },
},
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
reverseGeocoding: { enabled: true },
oauth: { autoLaunch: false, buttonText: 'Login with OAuth', enabled: false, accountManagementUrl: '' },
passkey: { enabled: true },
passwordLogin: { enabled: true },
image: { thumbnail: { size: 250 }, preview: { size: 1440 }, fullsize: { enabled: false } },
trash: { enabled: true, days: 30 },
notifications: { smtp: { enabled: false } },
server: { externalDomain: '', loginPageMessage: '', publicUsers: true },
user: { deleteDelay: 7 },
};
export const adminConfig = {
backup: { database: { enabled: true, cronExpression: '0 02 * * *', keepLastAmount: 14 } },
ffmpeg: {
crf: 23,
threads: 0,
preset: 'ultrafast',
targetVideoCodec: 'h264',
acceptedVideoCodecs: ['h264'],
targetAudioCodec: 'aac',
acceptedAudioCodecs: ['aac', 'mp3', 'opus'],
acceptedContainers: ['mov', 'ogg', 'webm'],
targetResolution: '720',
maxBitrate: '0',
bframes: -1,
refs: 0,
gopSize: 0,
temporalAQ: false,
cqMode: 'auto',
twoPass: false,
preferredHwDevice: 'auto',
transcode: 'required',
tonemap: 'hable',
accel: 'disabled',
accelDecode: true,
realtime: { enabled: false, videoCodecs: ['h264', 'hevc'], resolutions: [480, 720, 1080] },
},
integrityChecks: {
missingFiles: { enabled: true, cronExpression: '0 03 * * *' },
untrackedFiles: { enabled: true, cronExpression: '0 03 * * *' },
checksumFiles: { enabled: true, cronExpression: '0 03 * * *', timeLimit: 3_600_000, percentageLimit: 1 },
},
job: {
thumbnailGeneration: { concurrency: 3 },
metadataExtraction: { concurrency: 5 },
videoConversion: { concurrency: 1 },
faceDetection: { concurrency: 2 },
smartSearch: { concurrency: 2 },
backgroundTask: { concurrency: 5 },
migration: { concurrency: 5 },
search: { concurrency: 5 },
sidecar: { concurrency: 5 },
library: { concurrency: 5 },
notifications: { concurrency: 5 },
ocr: { concurrency: 1 },
workflow: { concurrency: 5 },
editor: { concurrency: 2 },
integrityCheck: { concurrency: 1 },
},
logging: { enabled: true, level: 'log' },
machineLearning: {
enabled: false,
urls: ['http://immich-machine-learning:3003'],
availabilityChecks: { enabled: true, timeout: 2000, interval: 30_000 },
clip: { enabled: true, modelName: 'ViT-B-32__openai' },
duplicateDetection: { enabled: true, maxDistance: 0.01 },
facialRecognition: { enabled: true, modelName: 'buffalo_l', minScore: 0.7, maxDistance: 0.5, minFaces: 3 },
ocr: {
enabled: true,
modelName: 'PP-OCRv5_mobile',
minDetectionScore: 0.5,
minRecognitionScore: 0.8,
maxResolution: 736,
},
},
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
reverseGeocoding: { enabled: true },
metadata: { faces: { import: false } },
oauth: {
autoLaunch: false,
autoRegister: true,
buttonText: 'Login with OAuth',
clientId: '',
clientSecret: '',
defaultStorageQuota: null,
enabled: false,
issuerUrl: '',
accountManagementUrl: '',
endSessionEndpoint: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
prompt: '',
scope: 'openid email profile',
signingAlgorithm: 'RS256',
profileSigningAlgorithm: 'none',
storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
roleClaim: 'immich_role',
tokenEndpointAuthMethod: 'client_secret_post',
timeout: 30_000,
allowInsecureRequests: false,
},
passkey: { enabled: true, domain: null, additionalDomains: [] },
passwordLogin: { enabled: true },
storageTemplate: {
enabled: false,
hashVerificationEnabled: true,
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
},
image: {
thumbnail: { format: 'webp', size: 250, quality: 80, progressive: false },
preview: { format: 'jpeg', size: 1440, quality: 80, progressive: false },
colorspace: 'p3',
extractEmbedded: false,
fullsize: { enabled: false, format: 'jpeg', quality: 80, progressive: false },
},
newVersionCheck: { enabled: true, channel: 'stable' },
nightlyTasks: {
startTime: '00:00',
databaseCleanup: true,
generateMemories: true,
syncQuotaUsage: true,
missingThumbnails: true,
clusterNewFaces: true,
},
trash: { enabled: true, days: 30 },
theme: { customCss: '' },
library: { scan: { enabled: true, cronExpression: '0 0 * * *' }, watch: { enabled: false } },
server: { externalDomain: '', loginPageMessage: '', publicUsers: true },
notifications: {
smtp: {
enabled: false,
from: '',
replyTo: '',
transport: { ignoreCert: false, host: '', port: 587, secure: false, username: '', password: '' },
},
},
templates: { email: { welcomeTemplate: '', albumInviteTemplate: '', albumUpdateTemplate: '' } },
user: { deleteDelay: 7 },
};
@@ -1,127 +0,0 @@
import { BrowserContext } from '@playwright/test';
import { randomThumbnail } from 'src/ui/generators/timeline.js';
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
const MINIMAL_MP4_BASE64 =
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' +
'3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' +
'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' +
'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' +
'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' +
'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' +
'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' +
'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' +
'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' +
'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' +
'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' +
'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' +
'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' +
'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' +
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' +
'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' +
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' +
'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' +
'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' +
'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' +
'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' +
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' +
'2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' +
'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' +
'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' +
'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0';
export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64');
export type MockPerson = {
id: string;
name: string;
birthDate: string | null;
isHidden: boolean;
thumbnailPath: string;
updatedAt: string;
};
export const createMockPeople = (count: number): MockPerson[] => {
const names = [
'Alice Johnson',
'Bob Smith',
'Charlie Brown',
'Diana Prince',
'Eve Adams',
'Frank Castle',
'Grace Lee',
'Hank Pym',
'Iris West',
'Jack Ryan',
];
return Array.from({ length: count }, (_, index) => ({
id: `person-${index}`,
name: names[index % names.length],
birthDate: null,
isHidden: false,
thumbnailPath: `/upload/thumbs/person-${index}.jpeg`,
updatedAt: '2025-01-01T00:00:00.000Z',
}));
};
export type FaceCreateCapture = {
requests: Array<{
assetId: string;
personId: string;
x: number;
y: number;
width: number;
height: number;
imageWidth: number;
imageHeight: number;
}>;
};
export const setupFaceEditorMockApiRoutes = async (
context: BrowserContext,
mockPeople: MockPerson[],
faceCreateCapture: FaceCreateCapture,
) => {
await context.route('**/api/people?*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
hasNextPage: false,
hidden: 0,
people: mockPeople,
total: mockPeople.length,
},
});
});
await context.route('**/api/faces', async (route, request) => {
if (request.method() !== 'POST') {
return route.fallback();
}
const body = request.postDataJSON();
faceCreateCapture.requests.push(body);
return route.fulfill({
status: 201,
contentType: 'text/plain',
body: 'OK',
});
});
await context.route('**/api/people/*/thumbnail', async (route) => {
if (!route.request().serviceWorker()) {
return route.continue();
}
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail('person-thumb', 1),
});
});
};
-75
View File
@@ -1,75 +0,0 @@
import type { MemoryResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
export type MemoryChanges = {
memoryDeletions: string[];
assetRemovals: Map<string, string[]>;
};
export const setupMemoryMockApiRoutes = async (
context: BrowserContext,
memories: MemoryResponseDto[],
changes: MemoryChanges,
) => {
await context.route('**/api/memories*', async (route, request) => {
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/memories' && request.method() === 'GET') {
const activeMemories = memories
.filter((memory) => !changes.memoryDeletions.includes(memory.id))
.map((memory) => {
const removedAssets = changes.assetRemovals.get(memory.id) ?? [];
return {
...memory,
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
};
})
.filter((memory) => memory.assets.length > 0);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: activeMemories,
});
}
const memoryMatch = pathname.match(/\/api\/memories\/([^/]+)$/);
if (memoryMatch && request.method() === 'GET') {
const memoryId = memoryMatch[1];
const memory = memories.find((m) => m.id === memoryId);
if (!memory || changes.memoryDeletions.includes(memoryId)) {
return route.fulfill({ status: 404 });
}
const removedAssets = changes.assetRemovals.get(memoryId) ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
...memory,
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
},
});
}
if (/\/api\/memories\/([^/]+)$/.test(pathname) && request.method() === 'DELETE') {
const memoryId = pathname.split('/').pop()!;
changes.memoryDeletions.push(memoryId);
return route.fulfill({ status: 204 });
}
await route.fallback();
});
await context.route('**/api/memories/statistics*', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
total: memories.length,
},
});
});
};
-55
View File
@@ -1,55 +0,0 @@
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,
});
});
};
-189
View File
@@ -1,189 +0,0 @@
import { AssetResponseDto } from '@immich/sdk';
import { BrowserContext, Page, Request, Route } from '@playwright/test';
import { basename } from 'node:path';
import {
Changes,
getAlbum,
getAsset,
getTimeBucket,
getTimeBuckets,
randomPreview,
randomThumbnail,
TimelineData,
} from 'src/ui/generators/timeline';
import { sleep } from 'src/ui/specs/timeline/utils.js';
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
export class TimelineTestContext {
slowBucket = false;
adminId = '';
}
export const setupTimelineMockApiRoutes = async (
context: BrowserContext,
timelineRestData: TimelineData,
changes: Changes,
testContext: TimelineTestContext,
) => {
await context.route('**/api/timeline**', async (route, request) => {
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/timeline/buckets') {
const albumId = url.searchParams.get('albumId') || undefined;
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
const isArchived = url.searchParams.get('visibility')
? url.searchParams.get('visibility') === 'archive'
: undefined;
return route.fulfill({
status: 200,
contentType: 'application/json',
json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes),
});
}
if (pathname === '/api/timeline/bucket') {
const timeBucket = url.searchParams.get('timeBucket');
if (!timeBucket) {
return route.continue();
}
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
const isArchived = url.searchParams.get('visibility')
? url.searchParams.get('visibility') === 'archive'
: undefined;
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
const albumId = url.searchParams.get('albumId') || undefined;
const assets = getTimeBucket(timelineRestData, timeBucket, isTrashed, isArchived, isFavorite, albumId, changes);
if (testContext.slowBucket) {
await sleep(5000);
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: assets,
});
}
return route.continue();
});
await context.route('**/api/assets/*', async (route, request) => {
if (request.method() === 'GET') {
const url = new URL(request.url());
const pathname = url.pathname;
const assetId = basename(pathname);
let asset = getAsset(timelineRestData, assetId);
if (changes.assetDeletions.includes(asset!.id)) {
asset = {
...asset,
isTrashed: true,
} as AssetResponseDto;
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: asset,
});
}
await route.fallback();
});
await context.route('**/api/assets', async (route, request) => {
if (request.method() === 'DELETE') {
return route.fulfill({
status: 204,
});
}
await route.fallback();
});
await context.route('**/api/assets/*/ocr', async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
});
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern);
if (!match?.groups) {
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
}
if (match.groups.size === 'preview') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
body: await randomPreview(
match.groups.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
if (match.groups.size === 'thumbnail') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail(
match.groups.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
return route.continue();
});
await context.route('**/api/assets/*/video/playback*', async (route) => {
return route.fulfill({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: MINIMAL_MP4_BUFFER,
});
});
await context.route('**/api/albums/**', async (route, request) => {
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
if (albumsMatch) {
const album = getAlbum(timelineRestData, testContext.adminId, albumsMatch.groups?.albumId, changes);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: album,
});
}
return route.fallback();
});
await context.route('**/api/albums**', async (route, request) => {
const allAlbums = request.url().match(/\/api\/albums\?assetId=(?<assetId>[^&]+)/);
if (allAlbums) {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
}
return route.fallback();
});
};
export const pageRoutePromise = async (
page: Page,
route: string,
callback: (route: Route, request: Request) => Promise<void>,
) => {
let resolveRequest: ((value: unknown | PromiseLike<unknown>) => void) | undefined;
const deleteRequest = new Promise((resolve) => {
resolveRequest = resolve;
});
await page.route(route, async (route, request) => {
await callback(route, request);
const requestJson = request.postDataJSON();
resolveRequest?.(requestJson);
});
return deleteRequest;
};
@@ -1,269 +0,0 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
SeededRandom,
selectRandom,
TimelineAssetConfig,
TimelineData,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network.js';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network.js';
import { utils } from 'src/utils.js';
import { assetViewerUtils } from '../timeline/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer', () => {
const rng = new SeededRandom(529);
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const yearMonths: string[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
test.beforeAll(async () => {
utils.initSdk();
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-', 2);
yearMonths.push(`${year}-${Number(month)}`);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/photos/:id', () => {
test('Navigate to next asset via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
});
test('Navigate to previous asset via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
});
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.keyboard.press('ArrowRight');
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
});
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.keyboard.press('ArrowLeft');
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
});
test('Navigate forward 5 times via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
for (let i = 1; i <= 5; i++) {
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
}
});
test('Navigate backward 5 times via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
for (let i = 1; i <= 5; i++) {
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
}
});
test('Navigate forward then backward via keyboard', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
// Navigate forward 3 times
for (let i = 1; i <= 3; i++) {
await page.keyboard.press('ArrowRight');
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
}
// Navigate backward 3 times to return to original
for (let i = 2; i >= 0; i--) {
await page.keyboard.press('ArrowLeft');
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
}
// Verify we're back at the original asset
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
});
test('Verify no next button on last asset', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await page.goto(`/photos/${lastAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
// Verify next button doesn't exist
await expect(page.getByLabel('View next asset')).toHaveCount(0);
});
test('Verify no previous button on first asset', async ({ page }) => {
const firstAsset = assets[0];
await page.goto(`/photos/${firstAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
// Verify previous button doesn't exist
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
});
test('Delete photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
});
test('Delete photo advances to next (2x)', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
});
test('Delete last photo advances to prev', async ({ page }) => {
const asset = assets.at(-1)!;
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
});
test('Delete last photo advances to prev (2x)', async ({ page }) => {
const asset = assets.at(-1)!;
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
});
});
test.describe('/trash/photos/:id', () => {
test('Delete trashed photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
});
test('Delete trashed photo advances to next 2x', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
});
test('Delete trashed photo advances to prev', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${assets[index + 9].id}`);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
});
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${assets[index + 9].id}`);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
});
});
});
@@ -1,86 +0,0 @@
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline.js';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('broken-asset responsiveness', () => {
const fixture = setupAssetViewerFixture(889);
let mockStack: MockStack;
test.beforeAll(async () => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const brokenAssets = [
createMockStackAsset(fixture.adminUserId),
createMockStackAsset(fixture.adminUserId),
createMockStackAsset(fixture.adminUserId),
];
mockStack = createMockStack(primaryAssetDto, brokenAssets);
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
});
test('broken asset in stack strip hides icon at small size', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
await expect(brokenAssets.first()).toBeVisible();
await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size);
for (const brokenAsset of await brokenAssets.all()) {
await expect(brokenAsset.locator('svg')).not.toBeVisible();
}
});
test('broken asset in stack strip uses text-xs class', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
await expect(brokenAssets.first()).toBeVisible();
for (const brokenAsset of await brokenAssets.all()) {
const messageSpan = brokenAsset.locator('span');
await expect(messageSpan).toHaveClass(/text-xs/);
}
});
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
await context.route(
(url) =>
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
async (route) => {
return route.fulfill({ status: 404 });
},
);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
const messageSpan = viewerBrokenAsset.locator('span');
await expect(messageSpan).toHaveClass(/text-base/);
});
});
@@ -1,285 +0,0 @@
import { expect, Page, test } from '@playwright/test';
import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline.js';
import {
createMockPeople,
FaceCreateCapture,
MockPerson,
setupFaceEditorMockApiRoutes,
} from 'src/ui/mock-network/face-editor-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
const waitForSelectorTransition = async (page: Page) => {
await page.waitForFunction(
() => {
const selector = document.querySelector('#face-selector') as HTMLElement | null;
if (!selector) {
return false;
}
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
},
undefined,
{ timeout: 1000, polling: 50 },
);
};
const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => {
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.keyboard.press('i');
await page.locator('#detail-panel').waitFor({ state: 'visible' });
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await waitForSelectorTransition(page);
};
test.describe.configure({ mode: 'parallel' });
test.describe('face-editor', () => {
const fixture = setupAssetViewerFixture(777);
const rng = new SeededRandom(777);
let mockPeople: MockPerson[];
let faceCreateCapture: FaceCreateCapture;
test.beforeAll(async () => {
mockPeople = createMockPeople(8);
});
test.beforeEach(async ({ context }) => {
faceCreateCapture = { requests: [] };
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
});
type ScreenRect = { top: number; left: number; width: number; height: number };
const getFaceBoxRect = async (page: Page): Promise<ScreenRect> => {
const dataEl = page.locator('#face-editor-data');
await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/);
await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/);
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/);
const canvasBox = await page.locator('#face-editor').boundingBox();
if (!canvasBox) {
throw new Error('Canvas element not found');
}
const left = Number(await dataEl.getAttribute('data-face-left'));
const top = Number(await dataEl.getAttribute('data-face-top'));
const width = Number(await dataEl.getAttribute('data-face-width'));
const height = Number(await dataEl.getAttribute('data-face-height'));
return {
top: canvasBox.y + top,
left: canvasBox.x + left,
width,
height,
};
};
const getSelectorRect = async (page: Page): Promise<ScreenRect> => {
const box = await page.locator('#face-selector').boundingBox();
if (!box) {
throw new Error('Face selector element not found');
}
return { top: box.y, left: box.x, width: box.width, height: box.height };
};
const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => {
const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left));
const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
return overlapX * overlapY;
};
const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => {
const faceBox = await getFaceBoxRect(page);
const centerX = faceBox.left + faceBox.width / 2;
const centerY = faceBox.top + faceBox.height / 2;
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(300);
};
test('Face editor opens with person list', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await expect(page.locator('#face-selector')).toBeVisible();
await expect(page.locator('#face-editor')).toBeVisible();
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
}
});
test('Search filters people by name', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const searchInput = page.locator('#face-selector input');
await searchInput.fill('Alice');
await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible();
await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden();
await searchInput.clear();
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
}
});
test('Search with no results shows empty message', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const searchInput = page.locator('#face-selector input');
await searchInput.fill('Nonexistent Person XYZ');
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden();
}
});
test('Selecting a person shows confirmation dialog', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const personToTag = mockPeople[0];
await page.locator('#face-selector').getByText(personToTag.name).click();
await expect(page.getByRole('dialog')).toBeVisible();
});
test('Confirming tag calls createFace API and closes editor', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const personToTag = mockPeople[0];
await page.locator('#face-selector').getByText(personToTag.name).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: /confirm/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
await expect(page.locator('#face-editor')).toBeHidden();
expect(faceCreateCapture.requests).toHaveLength(1);
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
});
test('Cancel button closes face editor', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await expect(page.locator('#face-selector')).toBeVisible();
await expect(page.locator('#face-editor')).toBeVisible();
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
await expect(page.locator('#face-editor')).toBeHidden();
});
test('Selector does not overlap face box on initial open', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box down', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 0, 150);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box right', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 200, 0);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, -300, -300);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 300, 300);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector stays within viewport bounds', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const viewportSize = page.viewportSize()!;
const selectorBox = await getSelectorRect(page);
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
});
test('Selector stays within viewport after dragging to edge', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, -400, -400);
const viewportSize = page.viewportSize()!;
const selectorBox = await getSelectorRect(page);
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
});
test('Face box is draggable on the canvas', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const beforeDrag = await getFaceBoxRect(page);
await dragFaceBox(page, 100, 50);
const afterDrag = await getFaceBoxRect(page);
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
});
});
@@ -1,301 +0,0 @@
import type { AssetOcrResponseDto, AssetResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline.js';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { createMockOcrData, setupOcrMockApiRoutes } from 'src/ui/mock-network/ocr-network.js';
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 page.locator(`#stack-slideshow [data-asset="${secondAssetDto.id}"]`).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;
}
// eslint-disable-next-line unicorn/no-unnecessary-parameters
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);
});
});
@@ -1,84 +0,0 @@
import { faker } from '@faker-js/faker';
import type { AssetResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline.js';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { assetViewerUtils } from '../timeline/utils';
import { enableTagsPreference, ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer stack', () => {
const fixture = setupAssetViewerFixture(888);
let mockStack: MockStack;
let primaryAssetDto: AssetResponseDto;
let secondAssetDto: AssetResponseDto;
test.beforeAll(async () => {
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
primaryAssetDto.tags = [
{
id: faker.string.uuid(),
name: '1',
value: 'test/1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
secondAssetDto = createMockStackAsset(fixture.adminUserId);
secondAssetDto.tags = [
{
id: faker.string.uuid(),
name: '2',
value: 'test/2',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
});
test('stack slideshow is visible', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const stackAssets = stackSlideshow.locator('[data-asset]');
await expect(stackAssets).toHaveCount(mockStack.assets.length);
});
test('tags of primary asset are visible', async ({ context, page }) => {
await enableTagsPreference(context);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await ensureDetailPanelVisible(page);
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/1');
});
test('tags of second asset are visible', async ({ context, page }) => {
await enableTagsPreference(context);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await ensureDetailPanelVisible(page);
const secondAsset = page.locator(`#stack-slideshow [data-asset="${secondAssetDto.id}"]`);
await secondAsset.click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2');
});
});
-117
View File
@@ -1,117 +0,0 @@
import { faker } from '@faker-js/faker';
import type { AssetResponseDto } from '@immich/sdk';
import { BrowserContext, Page, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
SeededRandom,
selectRandom,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network.js';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network.js';
import { utils } from 'src/utils.js';
export type AssetViewerTestFixture = {
adminUserId: string;
timelineRestData: TimelineData;
assets: TimelineAssetConfig[];
testContext: TimelineTestContext;
changes: Changes;
primaryAsset: TimelineAssetConfig;
primaryAssetDto: AssetResponseDto;
};
export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture {
const rng = new SeededRandom(seed);
const testContext = new TimelineTestContext();
const fixture: AssetViewerTestFixture = {
adminUserId: undefined!,
timelineRestData: undefined!,
assets: [],
testContext,
changes: {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
},
primaryAsset: undefined!,
primaryAssetDto: undefined!,
};
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',
);
utils.initSdk();
fixture.adminUserId = faker.string.uuid();
testContext.adminId = fixture.adminUserId;
fixture.timelineRestData = generateTimelineData({
...createDefaultTimelineConfig(),
ownerId: fixture.adminUserId,
});
for (const timeBucket of fixture.timelineRestData.buckets.values()) {
fixture.assets.push(...timeBucket);
}
fixture.primaryAsset = selectRandom(
fixture.assets.filter((a) => a.isImage),
rng,
);
fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, fixture.adminUserId);
await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext);
});
test.afterEach(() => {
fixture.testContext.slowBucket = false;
fixture.changes.albumAdditions = [];
fixture.changes.assetDeletions = [];
fixture.changes.assetArchivals = [];
fixture.changes.assetFavorites = [];
});
return fixture;
}
export async function ensureDetailPanelVisible(page: Page) {
await page.waitForSelector('#immich-asset-viewer');
const isVisible = await page.locator('#detail-panel').isVisible();
if (!isVisible) {
await page.keyboard.press('i');
await page.waitForSelector('#detail-panel');
}
}
export async function enableTagsPreference(context: BrowserContext) {
await context.route('**/users/me/preferences', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: { defaultAssetOrder: 'desc' },
folders: { enabled: false, sidebarWeb: false },
memories: { enabled: true, duration: 5 },
people: { enabled: true, sidebarWeb: false },
sharedLinks: { enabled: true, sidebarWeb: false },
ratings: { enabled: false },
tags: { enabled: true, sidebarWeb: false },
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
cast: { gCastEnabled: false },
recentlyAdded: { sidebarWeb: false },
},
});
});
}
@@ -1,289 +0,0 @@
import { faker } from '@faker-js/faker';
import type { MemoryResponseDto } from '@immich/sdk';
import { test } from '@playwright/test';
import { generateMemoriesFromTimeline } from 'src/ui/generators/memory.js';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
TimelineAssetConfig,
TimelineData,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network.js';
import { MemoryChanges, setupMemoryMockApiRoutes } from 'src/ui/mock-network/memory-network.js';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network.js';
import { memoryAssetViewerUtils, memoryGalleryUtils, memoryViewerUtils } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
let memories: MemoryResponseDto[];
const assets: TimelineAssetConfig[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
const memoryChanges: MemoryChanges = {
memoryDeletions: [],
assetRemovals: new Map(),
};
test.beforeAll(async () => {
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({
...createDefaultTimelineConfig(),
ownerId: adminUserId,
});
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
memories = generateMemoriesFromTimeline(
assets,
adminUserId,
[
{ year: 2024, assetCount: 3 },
{ year: 2023, assetCount: 2 },
{ year: 2022, assetCount: 4 },
],
42,
);
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
memoryChanges.memoryDeletions = [];
memoryChanges.assetRemovals.clear();
});
test.describe('Asset viewer navigation from gallery', () => {
test('shows both prev/next buttons for middle asset within a memory', async ({ page }) => {
const firstMemory = memories[0];
const middleAsset = firstMemory.assets[1];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, middleAsset.id);
await memoryGalleryUtils.clickThumbnail(page, middleAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, middleAsset);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
});
test('shows next button when at last asset of first memory (next memory exists)', async ({ page }) => {
const firstMemory = memories[0];
const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!;
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirstMemory.id);
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirstMemory);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
});
test('shows prev button when at first asset of last memory (prev memory exists)', async ({ page }) => {
const lastMemory = memories.at(-1)!;
const firstAssetOfLastMemory = lastMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, lastMemory.id, firstAssetOfLastMemory.id);
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfLastMemory);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
});
test('can navigate from last asset of memory to first asset of next memory', async ({ page }) => {
const firstMemory = memories[0];
const secondMemory = memories[1];
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirst.id);
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
await memoryAssetViewerUtils.clickNextButton(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
await memoryAssetViewerUtils.expectCurrentAssetId(page, firstAssetOfSecond.id);
});
test('can navigate from first asset of memory to last asset of previous memory', async ({ page }) => {
const firstMemory = memories[0];
const secondMemory = memories[1];
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, secondMemory.id, firstAssetOfSecond.id);
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
await memoryAssetViewerUtils.clickPreviousButton(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
});
test('hides prev button at very first asset (first memory, first asset, no prev memory)', async ({ page }) => {
const firstMemory = memories[0];
const veryFirstAsset = firstMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, veryFirstAsset.id);
await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, veryFirstAsset);
await memoryAssetViewerUtils.expectPreviousButtonNotVisible(page);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
});
test('hides next button at very last asset (last memory, last asset, no next memory)', async ({ page }) => {
const lastMemory = memories.at(-1)!;
const veryLastAsset = lastMemory.assets.at(-1)!;
await memoryViewerUtils.openMemoryPageWithAsset(page, lastMemory.id, veryLastAsset.id);
await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, veryLastAsset);
await memoryAssetViewerUtils.expectNextButtonNotVisible(page);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
});
});
test.describe('Keyboard navigation', () => {
test('ArrowLeft navigates to previous asset across memory boundary', async ({ page }) => {
const firstMemory = memories[0];
const secondMemory = memories[1];
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, secondMemory.id, firstAssetOfSecond.id);
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
await page.keyboard.press('ArrowLeft');
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
});
test('ArrowRight navigates to next asset across memory boundary', async ({ page }) => {
const firstMemory = memories[0];
const secondMemory = memories[1];
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirst.id);
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
await page.keyboard.press('ArrowRight');
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
});
});
});
test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
let memories: MemoryResponseDto[];
const assets: TimelineAssetConfig[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
const memoryChanges: MemoryChanges = {
memoryDeletions: [],
assetRemovals: new Map(),
};
test.beforeAll(async () => {
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({
...createDefaultTimelineConfig(),
ownerId: adminUserId,
});
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
memories = generateMemoriesFromTimeline(
assets,
adminUserId,
[
{ year: 2024, assetCount: 2 },
{ year: 2023, assetCount: 1 },
{ year: 2022, assetCount: 2 },
],
123,
);
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
memoryChanges.memoryDeletions = [];
memoryChanges.assetRemovals.clear();
});
test('single asset memory shows both prev/next when surrounded by other memories', async ({ page }) => {
const singleAssetMemory = memories[1];
const singleAsset = singleAssetMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, singleAssetMemory.id, singleAsset.id);
await memoryGalleryUtils.clickThumbnail(page, singleAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, singleAsset);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
});
});
-119
View File
@@ -1,119 +0,0 @@
/* eslint-disable unicorn/no-this-outside-of-class */
import type { AssetResponseDto } from '@immich/sdk';
import { expect, Page } from '@playwright/test';
function getAssetIdFromUrl(url: URL): string | null {
const pathMatch = url.pathname.match(/\/memories\/[^/]+\/photos\/([^/]+)/);
if (pathMatch) {
return pathMatch[1];
}
return url.searchParams.get('assetId');
}
export const memoryViewerUtils = {
locator(page: Page) {
return page.locator('#memory-viewer');
},
async waitForMemoryLoad(page: Page) {
await expect(this.locator(page)).toBeVisible();
await expect(page.locator('#memory-viewer img').first()).toBeVisible();
},
async openMemoryPageWithAsset(page: Page, memoryId: string, assetId: string) {
await page.goto(`/memories/${memoryId}?assetId=${assetId}`);
await this.waitForMemoryLoad(page);
},
};
export const memoryGalleryUtils = {
locator(page: Page) {
return page.locator('#gallery-memory');
},
thumbnailWithAssetId(page: Page, assetId: string) {
return page.locator(`#gallery-memory [data-thumbnail-focus-container][data-asset="${assetId}"]`);
},
async scrollToGallery(page: Page) {
const showGalleryButton = page.getByLabel('Show gallery');
if (await showGalleryButton.isVisible()) {
await showGalleryButton.click();
}
await expect(this.locator(page)).toBeInViewport();
},
async clickThumbnail(page: Page, assetId: string) {
await this.scrollToGallery(page);
await this.thumbnailWithAssetId(page, assetId).click();
},
async getAllThumbnails(page: Page) {
await this.scrollToGallery(page);
return page.locator('#gallery-memory [data-thumbnail-focus-container]');
},
};
export const memoryAssetViewerUtils = {
locator(page: Page) {
return page.locator('#immich-asset-viewer');
},
async waitForViewerOpen(page: Page) {
await expect(this.locator(page)).toBeVisible();
},
async waitForAssetLoad(page: Page, asset: AssetResponseDto) {
const viewer = this.locator(page);
const imgLocator = viewer.locator(`img[draggable="false"][src*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
const videoLocator = viewer.locator(`video[poster*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
await imgLocator.or(videoLocator).waitFor({ timeout: 10_000 });
},
nextButton(page: Page) {
return page.getByLabel('View next asset');
},
previousButton(page: Page) {
return page.getByLabel('View previous asset');
},
async expectNextButtonVisible(page: Page) {
await expect(this.nextButton(page)).toBeVisible();
},
async expectNextButtonNotVisible(page: Page) {
await expect(this.nextButton(page)).toHaveCount(0);
},
async expectPreviousButtonVisible(page: Page) {
await expect(this.previousButton(page)).toBeVisible();
},
async expectPreviousButtonNotVisible(page: Page) {
await expect(this.previousButton(page)).toHaveCount(0);
},
async clickNextButton(page: Page) {
await this.nextButton(page).click();
},
async clickPreviousButton(page: Page) {
await this.previousButton(page).click();
},
async closeViewer(page: Page) {
await page.keyboard.press('Escape');
await expect(this.locator(page)).not.toBeVisible();
},
getCurrentAssetId(page: Page): string | null {
const url = new URL(page.url());
return getAssetIdFromUrl(url);
},
async expectCurrentAssetId(page: Page, expectedAssetId: string) {
await expect.poll(() => this.getCurrentAssetId(page)).toBe(expectedAssetId);
},
};
@@ -1,124 +0,0 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network.js';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network.js';
import { assetViewerUtils } from '../timeline/utils';
const buildSearchUrl = (assetId: string) => {
const searchQuery = encodeURIComponent(JSON.stringify({ originalFileName: 'test' }));
return `/search/photos/${assetId}?query=${searchQuery}`;
};
test.describe.configure({ mode: 'parallel' });
test.describe('search gallery-viewer', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
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 });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
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))
.map((asset) => toAssetResponseDto(asset));
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: { total: 0, count: 0, items: [], facets: [] },
assets: {
total: searchAssets.length,
count: searchAssets.length,
items: searchAssets,
facets: [],
nextPage: null,
},
},
});
}
await route.fallback();
});
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/search/photos/:id', () => {
test('Deleting a photo advances to the next photo', async ({ page }) => {
const asset = assets[0];
await page.goto(buildSearchUrl(asset.id));
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[1]);
});
test('Deleting two photos in a row advances to the next photo each time', async ({ page }) => {
const asset = assets[0];
await page.goto(buildSearchUrl(asset.id));
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[2]);
});
test('Navigating backward then deleting advances to the next photo', async ({ page }) => {
const asset = assets[1];
await page.goto(buildSearchUrl(asset.id));
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[2]);
});
test('Deleting the last photo advances to the previous photo', async ({ page }) => {
const lastAsset = assets[4];
await page.goto(buildSearchUrl(lastAsset.id));
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
await expect(page.getByLabel('View next asset')).toHaveCount(0);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[3]);
await expect(page.getByLabel('View previous asset')).toBeVisible();
});
});
});
@@ -1,859 +0,0 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
getAsset,
getMockAsset,
SeededRandom,
selectRandom,
selectRandomMultiple,
TimelineAssetConfig,
TimelineData,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network.js';
import {
pageRoutePromise,
setupTimelineMockApiRoutes,
TimelineTestContext,
} from 'src/ui/mock-network/timeline-network';
import { utils } from 'src/utils.js';
import { assetViewerUtils, padYearMonth, pageUtils, poll, thumbnailUtils, timelineUtils } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('Timeline', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const yearMonths: string[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
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',
);
utils.initSdk();
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-', 2);
yearMonths.push(`${year}-${Number(month)}`);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/photos', () => {
test('Open /photos', async ({ page }) => {
await page.goto(`/photos`);
await page.waitForSelector('#asset-grid');
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
});
test('Deep link to last photo', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
await thumbnailUtils.expectInViewport(page, lastAsset.id);
});
const rng = new SeededRandom(529);
for (let i = 0; i < 10; i++) {
test('Deep link to random asset ' + i, async ({ page }) => {
const asset = selectRandom(assets, rng);
await pageUtils.deepLinkPhotosPage(page, asset.id);
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
await thumbnailUtils.expectInViewport(page, asset.id);
});
}
test('Open /photos, open asset-viewer, browser back', async ({ page }) => {
const rng = new SeededRandom(22);
const asset = selectRandom(assets, rng);
await pageUtils.deepLinkPhotosPage(page, asset.id);
const scrollTopBefore = await timelineUtils.getScrollTop(page);
await thumbnailUtils.clickAssetId(page, asset.id);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.goBack();
await timelineUtils.locator(page).waitFor();
const scrollTopAfter = await timelineUtils.getScrollTop(page);
expect(scrollTopAfter).toBe(scrollTopBefore);
});
test('Open /photos, open asset-viewer, next photo, browser back, back', async ({ page }) => {
const rng = new SeededRandom(49);
const asset = selectRandom(assets, rng);
const assetIndex = assets.indexOf(asset);
const nextAsset = assets[assetIndex + 1];
await pageUtils.deepLinkPhotosPage(page, asset.id);
const scrollTopBefore = await timelineUtils.getScrollTop(page);
await thumbnailUtils.clickAssetId(page, asset.id);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, nextAsset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${nextAsset.id}`);
await page.goBack();
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.goBack();
await page.waitForURL('**/photos?at=*');
const scrollTopAfter = await timelineUtils.getScrollTop(page);
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThan(5);
});
test('Open /photos, open asset-viewer, next photo 15x, backwardsArrow', async ({ page }) => {
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
await thumbnailUtils.clickAssetId(page, assets[0].id);
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
for (let i = 1; i <= 15; i++) {
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[i]);
}
await page.getByLabel('Go back').click();
await page.waitForURL('**/photos?at=*');
await thumbnailUtils.expectInViewport(page, assets[15].id);
await thumbnailUtils.expectBottomIsTimelineBottom(page, assets[15]!.id);
});
test('Open /photos, open asset-viewer, previous photo 15x, backwardsArrow', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await thumbnailUtils.clickAssetId(page, lastAsset.id);
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
for (let i = 1; i <= 15; i++) {
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets.at(-1 - i)!);
}
await page.getByLabel('Go back').click();
await page.waitForURL('**/photos?at=*');
await thumbnailUtils.expectInViewport(page, assets.at(-1 - 15)!.id);
await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(-1 - 15)!.id);
});
});
test.describe('keyboard', () => {
/**
This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
as necessary downwards), then the asset should always be at the lowest row of the grid.
*/
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
const rightKey = 'ArrowRight';
const leftKey = 'ArrowLeft';
for (let i = 1; i < 15; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 15; i <= 20; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
expect(await thumbnailUtils.expectBottomIsTimelineBottom(page, assets.at(i)!.id));
}
// now test previous asset
for (let i = 19; i >= 15; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 14; i > 0; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
expect(await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(i)!.id));
}
});
test('Next/previous asset - Tab/Shift+Tab', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
const rightKey = 'Tab';
const leftKey = 'Shift+Tab';
for (let i = 1; i < 15; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 15; i <= 20; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
// now test previous asset
for (let i = 19; i >= 15; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 14; i > 0; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
});
test('Next/previous day - d, Shift+D', async ({ page }) => {
await pageUtils.openPhotosPage(page);
let asset = assets[0];
await timelineUtils.locator(page).hover();
await page.keyboard.press('d');
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('d');
const next = getMockAsset(asset, assets, 'next', 'day')!;
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
asset = next;
}
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Shift+D');
const previous = getMockAsset(asset, assets, 'previous', 'day')!;
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
asset = previous;
}
});
test('Next/previous month - m, Shift+M', async ({ page }) => {
await pageUtils.openPhotosPage(page);
let asset = assets[0];
await timelineUtils.locator(page).hover();
await page.keyboard.press('m');
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('m');
const next = getMockAsset(asset, assets, 'next', 'month')!;
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
asset = next;
}
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Shift+M');
const previous = getMockAsset(asset, assets, 'previous', 'month')!;
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
asset = previous;
}
});
test('Next/previous year - y, Shift+Y', async ({ page }) => {
await pageUtils.openPhotosPage(page);
let asset = assets[0];
await timelineUtils.locator(page).hover();
await page.keyboard.press('y');
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('y');
const next = getMockAsset(asset, assets, 'next', 'year')!;
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
asset = next;
}
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Shift+Y');
const previous = getMockAsset(asset, assets, 'previous', 'year')!;
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
asset = previous;
}
});
test('Navigate to time - g', async ({ page }) => {
const rng = new SeededRandom(4782);
await pageUtils.openPhotosPage(page);
for (let i = 0; i < 10; i++) {
const asset = selectRandom(assets, rng);
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
}
});
});
test.describe('selection', () => {
test('Select day, unselect day', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
});
test('Select asset, click asset to select', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
await thumbnailUtils.selectButton(page, assets[1].id).click();
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
// no need to hover, once selection is active
await thumbnailUtils.clickAssetId(page, assets[2].id);
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(2);
});
test('Select asset, click unselect asset', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
await thumbnailUtils.selectButton(page, assets[1].id).click();
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
await thumbnailUtils.clickAssetId(page, assets[1].id);
// the hover uses a checked button too, so just move mouse away
await page.mouse.move(0, 0);
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
});
test('Select asset, shift-hover candidates, shift-click end', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const asset = assets[0];
await thumbnailUtils.withAssetId(page, asset.id).hover();
await thumbnailUtils.selectButton(page, asset.id).click();
await page.keyboard.down('Shift');
await thumbnailUtils.withAssetId(page, assets[2].id).hover();
await expect(
thumbnailUtils.locator(page).locator('.absolute.top-0.size-full.bg-immich-primary.opacity-40'),
).toHaveCount(3);
await thumbnailUtils.selectButton(page, assets[2].id).click();
await page.keyboard.up('Shift');
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(3);
});
test('Add multiple to selection - Select day, shift-click end', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).hover();
await thumbnailUtils.selectButton(page, assets[0].id).click();
await thumbnailUtils.clickAssetId(page, assets[2].id);
await page.keyboard.down('Shift');
await thumbnailUtils.clickAssetId(page, assets[4].id);
await page.mouse.move(0, 0);
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
});
});
test.describe('scroll', () => {
test('Open /photos, random click scrubber 20x', async ({ page }) => {
test.slow();
await pageUtils.openPhotosPage(page);
const rng = new SeededRandom(6637);
const selectedMonths = selectRandomMultiple(yearMonths, 20, rng);
for (const month of selectedMonths) {
await page.locator(`[data-segment-year-month="${month}"]`).click({ force: true });
const visibleMockAssetsYearMonths = await poll(page, async () => {
const assetIds = await thumbnailUtils.getAllInViewport(
page,
(assetId: string) => getYearMonth(assets, assetId) === month,
);
const visibleMockAssetsYearMonths: string[] = [];
for (const assetId of assetIds!) {
const yearMonth = getYearMonth(assets, assetId);
visibleMockAssetsYearMonths.push(yearMonth);
if (yearMonth === month) {
return [yearMonth];
}
}
});
if (page.isClosed()) {
return;
}
expect(visibleMockAssetsYearMonths).toContain(month);
}
});
test.skip('Deep link to last photo, scroll up', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await timelineUtils.locator(page).hover();
for (let i = 0; i < 100; i++) {
await page.mouse.wheel(0, -100);
await page.waitForTimeout(25);
}
await thumbnailUtils.expectInViewport(page, '14e5901f-fd7f-40c0-b186-4d7e7fc67968');
});
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();
for (let i = 0; i < 100; i++) {
await page.mouse.wheel(0, 100);
await page.waitForTimeout(25);
}
await thumbnailUtils.expectInViewport(page, 'b7983a13-4b4e-4950-a731-f2962d9a1555');
});
test('Deep link to last photo, drag scrubber to scroll up', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
const lastMonth = yearMonths.at(-1);
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
const lastScrubSegment = page.locator(`[data-segment-year-month="${lastMonth}"]`);
const sourcebox = (await lastScrubSegment.boundingBox())!;
const targetBox = (await firstScrubSegment.boundingBox())!;
await firstScrubSegment.hover();
const currentY = sourcebox.y;
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
await page.mouse.down();
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, targetBox.y, { steps: 100 });
await page.mouse.up();
await thumbnailUtils.expectInViewport(page, assets[0].id);
});
test('Deep link to first bucket, drag scrubber to scroll down', async ({ page }) => {
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
const sourcebox = (await firstScrubSegment.boundingBox())!;
await firstScrubSegment.hover();
const currentY = sourcebox.y;
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
await page.mouse.down();
const height = page.viewportSize()?.height;
expect(height).toBeDefined();
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, height! - 10, {
steps: 100,
});
await page.mouse.up();
await thumbnailUtils.expectInViewport(page, assets.at(-1)!.id);
});
test('Buckets cancel on scroll', async ({ page }) => {
await pageUtils.openPhotosPage(page);
testContext.slowBucket = true;
const failedUris: string[] = [];
page.on('requestfailed', (request) => {
failedUris.push(request.url());
});
const offscreenSegment = page.locator(`[data-segment-year-month="${yearMonths[12]}"]`);
await offscreenSegment.click({ force: true });
const lastSegment = page.locator(`[data-segment-year-month="${yearMonths.at(-1)!}"]`);
await lastSegment.click({ force: true });
const uris = await poll(page, async () => (failedUris.length > 0 ? failedUris : null));
expect(uris).toEqual(expect.arrayContaining([expect.stringContaining(padYearMonth(yearMonths[12]!))]));
});
});
test.describe('/albums', () => {
test('Open album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, album.assetIds[0]);
});
test('Deep link to last photo', async ({ page }) => {
const album = timelineRestData.album;
const lastAsset = album.assetIds.at(-1);
await pageUtils.deepLinkAlbumPage(page, album.id, lastAsset!);
await thumbnailUtils.expectInViewport(page, album.assetIds.at(-1)!);
await thumbnailUtils.expectBottomIsTimelineBottom(page, album.assetIds.at(-1)!);
});
test('Add photos to album pre-selects existing', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
await page.getByLabel('Add photos').click();
const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
});
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();
const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
const requestJson = request.postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
json: requestJson.ids.map((id: string) => ({ id, success: true })),
});
changes.albumAdditions.push(...requestJson.ids);
});
await page.getByText('Add assets').click();
await expect(put).resolves.toEqual({
ids: [
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
'040fd762-dbbc-486d-a51a-2d84115e6229',
'86af0b5f-79d3-4f75-bab3-3b61f6c72b23',
],
});
const addedAsset = getAsset(timelineRestData, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658')!;
await pageUtils.goToAsset(page, addedAsset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658');
await thumbnailUtils.expectInViewport(page, '040fd762-dbbc-486d-a51a-2d84115e6229');
await thumbnailUtils.expectInViewport(page, '86af0b5f-79d3-4f75-bab3-3b61f6c72b23');
});
});
test.describe('/trash', () => {
test('open /photos, trash photo, open /trash, restore', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const assetToTrash = assets[0];
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
await page.getByLabel('Menu').click();
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions.push(...requestJson.ids);
await route.fulfill({
status: 200,
contentType: 'application/json',
json: requestJson.ids.map((id: string) => ({ id, success: true })),
});
});
await page.getByRole('menuitem').getByText('Delete').click();
await expect(deleteRequest).resolves.toEqual({
force: false,
ids: [assetToTrash.id],
});
await page.getByRole('link', { name: 'Trash' }).click();
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 200,
contentType: 'application/json',
json: { count: requestJson.ids.length },
});
});
await page.getByText('Restore').click();
await expect(restoreRequest).resolves.toEqual({
ids: [assetToTrash.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
await page.getByRole('link', { name: 'Photos' }).click();
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
});
test('open album, trash photo, open /trash, restore', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
const assetToTrash = getAsset(timelineRestData, album.assetIds[0])!;
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
await page.getByLabel('Menu').click();
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions.push(...requestJson.ids);
await route.fulfill({
status: 200,
contentType: 'application/json',
json: requestJson.ids.map((id: string) => ({ id, success: true })),
});
});
await page.getByRole('menuitem').getByText('Delete').click();
await expect(deleteRequest).resolves.toEqual({
force: false,
ids: [assetToTrash.id],
});
await page.locator('#control-bar').getByLabel('Close').click();
await page.getByRole('link', { name: 'Trash' }).click();
await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 200,
contentType: 'application/json',
json: { count: requestJson.ids.length },
});
});
await page.getByText('Restore').click();
await expect(restoreRequest).resolves.toEqual({
ids: [assetToTrash.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
});
});
test.describe('/archive', () => {
test('open /photos, archive photo, open /archive, unarchive', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const assetToArchive = assets[0];
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
await page.getByLabel('Menu').click();
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'archive') {
return await route.continue();
}
await route.fulfill({
status: 204,
});
changes.assetArchivals.push(...requestJson.ids);
});
await page.getByRole('menuitem').getByText('Archive').click();
await expect(archive).resolves.toEqual({
visibility: 'archive',
ids: [assetToArchive.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await page.getByRole('link').getByText('Archive').click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'timeline') {
return await route.continue();
}
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Unarchive').click();
await expect(unarchiveRequest).resolves.toEqual({
visibility: 'timeline',
ids: [assetToArchive.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await page.getByRole('link', { name: 'Photos' }).click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
});
test('open /archive, favorite photo, unfavorite', async ({ page }) => {
const assetToFavorite = assets[0];
changes.assetArchivals.push(assetToFavorite.id);
await pageUtils.openArchivePage(page);
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
const isFavorite = requestJson.isFavorite;
if (isFavorite) {
changes.assetFavorites.push(...requestJson.ids);
}
await route.fulfill({
status: 204,
});
});
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
await page.getByLabel('Favorite').click();
await expect(favorite).resolves.toEqual({
isFavorite: true,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Remove from favorites').click();
await expect(unFavoriteRequest).resolves.toEqual({
isFavorite: false,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
await thumbnailUtils.expectThumbnailIsNotFavorite(page, assetToFavorite.id);
});
test('open album, archive photo, open album, unarchive', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
const assetToArchive = getAsset(timelineRestData, album.assetIds[0])!;
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
await page.getByLabel('Menu').click();
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'archive') {
return await route.continue();
}
changes.assetArchivals.push(...requestJson.ids);
await route.fulfill({
status: 204,
});
});
await page.getByRole('menuitem').getByText('Archive').click();
await expect(archive).resolves.toEqual({
visibility: 'archive',
ids: [assetToArchive.id],
});
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
await page.locator('#control-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Archive').click();
await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'timeline') {
return await route.continue();
}
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Unarchive').click();
await expect(unarchiveRequest).resolves.toEqual({
visibility: 'timeline',
ids: [assetToArchive.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
});
});
test.describe('/favorite', () => {
test('open /photos, favorite photo, open /favorites, remove favorite, open /photos', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const assetToFavorite = assets[0];
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
const isFavorite = requestJson.isFavorite;
if (isFavorite) {
changes.assetFavorites.push(...requestJson.ids);
}
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Favorite').click();
await expect(favorite).resolves.toEqual({
isFavorite: true,
ids: [assetToFavorite.id],
});
// ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await page.getByRole('link').getByText('Favorites').click();
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Remove from favorites').click();
await expect(unFavoriteRequest).resolves.toEqual({
isFavorite: false,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
await page.getByRole('link', { name: 'Photos' }).click();
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
});
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();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
await page.getByLabel('Menu').click();
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'archive') {
return await route.continue();
}
await route.fulfill({
status: 204,
});
changes.assetArchivals.push(...requestJson.ids);
});
await page.getByRole('menuitem').getByText('Archive').click();
await expect(archive).resolves.toEqual({
visibility: 'archive',
ids: [assetToArchive.id],
});
await page.getByRole('link').getByText('Archive').click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'timeline') {
return await route.continue();
}
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Unarchive').click();
await expect(unarchiveRequest).resolves.toEqual({
visibility: 'timeline',
ids: [assetToArchive.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
});
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
const assetToFavorite = getAsset(timelineRestData, album.assetIds[0])!;
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
const isFavorite = requestJson.isFavorite;
if (isFavorite) {
changes.assetFavorites.push(...requestJson.ids);
}
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Favorite').click();
await expect(favorite).resolves.toEqual({
isFavorite: true,
ids: [assetToFavorite.id],
});
// ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await page.locator('#control-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Favorites').click();
await timelineUtils.waitForTimelineLoad(page);
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Remove from favorites').click();
await expect(unFavoriteRequest).resolves.toEqual({
isFavorite: false,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
});
});
});
const getYearMonth = (assets: TimelineAssetConfig[], assetId: string) => {
const mockAsset = assets.find((mockAsset) => mockAsset.id === assetId)!;
const dateTime = DateTime.fromISO(mockAsset.fileCreatedAt!);
return dateTime.year + '-' + dateTime.month;
};
-218
View File
@@ -1,218 +0,0 @@
import { expect, Page } from '@playwright/test';
import { DateTime } from 'luxon';
import { TimelineAssetConfig } from 'src/ui/generators/timeline.js';
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const padYearMonth = (yearMonth: string) => {
const [year, month] = yearMonth.split('-', 2);
return `${year}-${month.padStart(2, '0')}`;
};
export const poll = async <T>(
page: Page,
query: () => Promise<T>,
callback?: (result: Awaited<T> | undefined) => boolean,
) => {
let result;
const timeout = Date.now() + 10_000;
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
while (!terminate(result) && Date.now() < timeout) {
try {
result = await query();
} catch {
// ignore
}
if (page.isClosed()) {
return;
}
try {
await page.waitForTimeout(50);
} catch {
return;
}
}
if (!result) {
// rerun to trigger error if any
result = await query();
}
return result;
};
export const thumbnailUtils = {
locator(page: Page) {
return page.locator('[data-thumbnail-focus-container]');
},
withAssetId(page: Page, assetId: string) {
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[role="checkbox"]`);
},
selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container][data-selected]');
},
async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click();
},
async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
const assetIds: string[] = [];
// eslint-disable-next-line unicorn/no-this-outside-of-class
for (const thumb of await this.locator(page).all()) {
const box = await thumb.boundingBox();
if (box) {
const assetId = await thumb.evaluate((e) => e.dataset.asset);
if (collector?.(assetId!)) {
return [assetId!];
}
assetIds.push(assetId!);
}
}
return assetIds;
},
async getFirstInViewport(page: Page) {
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, () => true));
},
async getAllInViewport(page: Page, collector: (assetId: string) => boolean) {
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
},
async expectThumbnailIsFavorite(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1);
},
async expectThumbnailIsNotFavorite(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0);
},
async expectThumbnailIsArchive(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1);
},
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
},
async expectSelectedDisabled(page: Page, assetId: string) {
await expect(
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected][data-disabled]`),
).toBeVisible();
},
async expectTimelineHasOnScreenAssets(page: Page) {
const first = await thumbnailUtils.getFirstInViewport(page);
if (page.isClosed()) {
return;
}
expect(first).toBeTruthy();
},
async expectInViewport(page: Page, assetId: string) {
const box = await poll(page, () => thumbnailUtils.withAssetId(page, assetId).boundingBox());
if (page.isClosed()) {
return;
}
expect(box).toBeTruthy();
},
async expectBottomIsTimelineBottom(page: Page, assetId: string) {
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
const gridBox = await timelineUtils.locator(page).boundingBox();
if (page.isClosed()) {
return;
}
expect(box!.y + box!.height).toBeCloseTo(gridBox!.y + gridBox!.height, 0);
},
async expectTopIsTimelineTop(page: Page, assetId: string) {
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
const gridBox = await timelineUtils.locator(page).boundingBox();
if (page.isClosed()) {
return;
}
expect(box!.y).toBeCloseTo(gridBox!.y, 0);
},
};
export const timelineUtils = {
locator(page: Page) {
return page.locator('#asset-grid');
},
async waitForTimelineLoad(page: Page) {
await expect(timelineUtils.locator(page)).toHaveCount(1);
await expect(timelineUtils.locator(page)).toBeInViewport();
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
},
async getScrollTop(page: Page) {
const queryTop = () =>
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return document.querySelector('#asset-grid').scrollTop;
});
await expect.poll(queryTop).toBeGreaterThan(0);
return await queryTop();
},
};
export const assetViewerUtils = {
locator(page: Page) {
return page.locator('#immich-asset-viewer');
},
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
await page
.locator(
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
)
.or(
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
)
.waitFor();
},
async expectActiveAssetToBe(page: Page, assetId: string) {
const activeElement = () =>
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line unicorn/no-optional-chaining-on-undeclared-variable
return document.activeElement?.dataset?.asset;
});
await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId);
},
};
export const pageUtils = {
async deepLinkPhotosPage(page: Page, assetId: string) {
await page.goto(`/photos?at=${assetId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async openPhotosPage(page: Page) {
await page.goto(`/photos`);
await timelineUtils.waitForTimelineLoad(page);
},
async openFavorites(page: Page) {
await page.goto(`/favorites`);
await timelineUtils.waitForTimelineLoad(page);
},
async openAlbumPage(page: Page, albumId: string) {
await page.goto(`/albums/${albumId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async openArchivePage(page: Page) {
await page.goto(`/archive`);
await timelineUtils.waitForTimelineLoad(page);
},
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
await page.goto(`/albums/${albumId}?at=${assetId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async goToAsset(page: Page, assetDate: string) {
await timelineUtils.locator(page).hover();
const stringDate = DateTime.fromISO(assetDate).toFormat('MMddyyyy,hh:mm:ss.SSSa');
await page.keyboard.press('g');
await page.locator('#datetime').pressSequentially(stringDate);
await page.getByText('Confirm').click();
},
async selectDay(page: Page, day: string) {
const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]');
await section.hover();
await section.locator('.w-8').click();
},
async pauseTestDebug() {
console.log('NOTE: pausing test indefinitely for debug');
await new Promise(() => void 0);
},
};
+3
View File
@@ -31,6 +31,7 @@ import {
createUserAdmin,
deleteAssets,
deleteDatabaseBackup,
deleteLibrary,
getAssetInfo,
getConfig,
getConfigDefaults,
@@ -466,6 +467,8 @@ export const utils = {
updateLibrary: (accessToken: string, id: string, dto: UpdateLibraryDto) =>
updateLibrary({ id, updateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
deleteLibrary: (accessToken: string, id: string) => deleteLibrary({ id }, { headers: asBearerAuth(accessToken) }),
createPartner: (accessToken: string, id: string) =>
createPartner({ partnerCreateDto: { sharedWithId: id } }, { headers: asBearerAuth(accessToken) }),
+3
View File
@@ -1,5 +1,6 @@
{
"about": "Oor",
"accept": "Aanvaar",
"account": "Rekening",
"account_settings": "Rekeninginstellings",
"acknowledge": "Neem kennis",
@@ -29,11 +30,13 @@
"add_to_album_bottom_sheet_added": "Tot {album} toegevoeg",
"add_to_album_bottom_sheet_already_exists": "Reeds in {album}",
"add_to_album_bottom_sheet_some_local_assets": "Sommige plaaslike bates kon nie toe gevoeg word tot die album nie",
"add_to_album_item_count": "Voeg {count, plural, one {# item} other {# items}} tot album toe",
"add_to_albums": "Voeg toe tot albums",
"add_to_albums_count": "Voeg toe tot albums ({count})",
"add_to_bottom_bar": "Voeg toe",
"add_upload_to_stack": "Voeg oplaai by stapel",
"add_url": "Voeg bronadres toe",
"add_user": "Voeg gebruiker toe",
"added_to_archive": "Tot argief toegevoeg",
"added_to_favorites": "Tot gunstelinge toegevoeg",
"added_to_favorites_count": "{count, number} tot gunstelinge toegevoeg",
-4
View File
@@ -1210,7 +1210,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}، {country} مع {person1}، {person2}، و{person3} في {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {city}, {country} with {person1}, {person2}, مع {additionalCount, number} آخرين في {date}",
"immich_logo": "شعار immich",
"immich_web_interface": "واجهة ويب immich",
"import_from_json": "استيراد من JSON",
"import_path": "مسار الاستيراد",
"in_albums": "في {count, plural, one {# ألبوم } other {# ألبومات}}",
@@ -1915,8 +1914,6 @@
"setting_image_viewer_help": "يقوم عارض التفاصيل بتحميل الصورة المصغرة الصغيرة أولاً ، ثم يقوم بتحميل المعاينة متوسطة الحجم (إذا تم تمكينها) ، ويقوم أخيرًا بتحميل الأصل (إذا تم تمكينه).",
"setting_image_viewer_original_subtitle": "تمكين تحميل الصورة الكاملة الدقة الأصلية (كبيرة!).تعطيل لتقليل استخدام البيانات (كل من الشبكة وعلى ذاكرة التخزين المؤقت للجهاز).",
"setting_image_viewer_original_title": "تحميل الصورة الأصلية",
"setting_image_viewer_preview_subtitle": "تمكين تحميل صورة متوسطة الدقة.تعطيل إما لتحميل مباشرة أو استخدام الصورة المصغرة مباشرة.",
"setting_image_viewer_preview_title": "تحميل صورة معاينة",
"setting_languages_apply": "تغيير الإعدادات",
"setting_languages_subtitle": "تغيير لغة التطبيق",
"setting_notifications_notify_minutes": "{count} دقائق",
@@ -1982,7 +1979,6 @@
"shared_link_password_description": "طلب كلمة مرور للوصول إلى هذا الرابط المشترك",
"shared_links": "روابط مشتركة",
"shared_links_description": "وصف الروابط المشتركة",
"shared_photos_and_videos_count": "{assetCount, plural, other {# الصور ومقاطع الفيديو المُشارَكة.}}",
"shared_with_me": "تمت مشاركتها معي",
"shared_with_partner": "تمت المشاركة مع {partner}",
"sharing": "مشاركة",
-4
View File
@@ -1212,7 +1212,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Відэа} other {Фота}} зробленае ў {city}, {country} з {person1}, {person2} і {person3} {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Відэа} other {Фота}} зробленае ў {city}, {country} з {person1}, {person2} і яшчэ {additionalCount, number} інш. {date}",
"immich_logo": "Лагатып Immich",
"immich_web_interface": "Вэб-інтэрфейс Immich",
"import_from_json": "Імпарт з JSON",
"import_path": "Шлях імпарту",
"in_albums": "У {count, plural, one {# альбоме} few {# альбомах} many {# альбомах} other {# альбома}}",
@@ -1931,8 +1930,6 @@
"setting_image_viewer_help": "Дэталёвы прагляд спачатку запампоўвае маленькую мініяцюру, потым папярэдні прагляд сярэдняга памеру (калі ўключана), і нарэшце арыгінал (калі ўключана).",
"setting_image_viewer_original_subtitle": "Уключыце для загрузкі зыходнага відарыса ў поўнай раздзяляльнасці (вялікі!). Адключыце, каб зменшыць выкарыстанне трафіка (як сеткі, так і кэша прылады).",
"setting_image_viewer_original_title": "Загружаць арыгінальны відарыс",
"setting_image_viewer_preview_subtitle": "Уключыце для запампавання відарыса сярэдняй раздзяляльнасці. Адключыце, каб загружаць толькі арыгінал ці мініяцюру.",
"setting_image_viewer_preview_title": "Загружаць відарыс перадпрагляду",
"setting_languages_apply": "Ужыць",
"setting_languages_subtitle": "Змяненне мовы праграмы",
"setting_notifications_notify_minutes": "{count} хв",
@@ -1998,7 +1995,6 @@
"shared_link_password_description": "Патрабаваць пароль для доступу да гэтай абагуленай спасылкі",
"shared_links": "Абагуленыя спасылкі",
"shared_links_description": "Абагульвайце фота і відэа праз спасылку",
"shared_photos_and_videos_count": "{assetCount, plural, one {# абагуленае фота і відэа.} few {# абагуленыя фота і відэа.} many {# абагуленых фота і відэа.} other {# абагуленага фота і відэа.}}",
"shared_with_me": "Абагуленыя са мной",
"shared_with_partner": "Абагулена з {partner}",
"sharing": "Абагульванне",
+25 -6
View File
@@ -323,6 +323,13 @@
"oauth_timeout": "Време на изчакване при заявка",
"oauth_timeout_description": "Време за изчакване на отговор на заявка, в милисекунди",
"ocr_job_description": "Използване на машинно обучение за разпознаване на текст в изображенията",
"passkey_additional_domains": "Допълнителни домейни",
"passkey_additional_domains_description": "Допълнителни, доверени домейни (свързани заявки за произход). Те ще работят само ако домейнът с ключ за достъп също е достъпен.",
"passkey_domain": "Домейн с ключ за достъп",
"passkey_domain_description": "Домейн, използван за пароли (доверяваща се страна). Оставете празно, за да се прави извод от заглавията на оригинала. Промяната на тази стойност ще направи съществуващите пароли да не работят.",
"passkey_enable_description": "Влизане с парола",
"passkey_settings": "Вход с парола",
"passkey_settings_description": "Управление на настройки за влизане с парола",
"password_enable_description": "Влизане с имейл и парола",
"password_settings": "Вписване с парола",
"password_settings_description": "Управление на настройките за влизане с парола",
@@ -671,6 +678,7 @@
"birthdate_set_description": "Датата на раждане се използва за изчисляване на възрастта на този човек към момента на снимката.",
"birthday_memory_age": "{age, selectordinal, one {#ви} two {#ри} few {#ти} other {#ти}} рожден ден",
"birthday_memory_title": "Ражден ден на {name}",
"birthday_memory_title_with_age": "Рожден ден на {name} · {age, plural, one {# година} other {# години}}",
"blurred_background": "Замъглен заден фон",
"browse_templates": "Разглеждане на шаблони",
"bugs_and_feature_requests": "Бъгове и заявки за функции",
@@ -872,6 +880,8 @@
"delete_local_dialog_ok_backed_up_only": "Изтрий локално само архивираните",
"delete_local_dialog_ok_force": "Въпреки това изтрий",
"delete_others": "Изтрий останалите",
"delete_passkey": "Премахване на парола",
"delete_passkey_prompt": "Наистина ли искате да премахнете паролата?",
"delete_permanently": "Изтрий за постоянно",
"delete_permanently_action_prompt": "{count} изтрити за постоянно",
"delete_shared_link": "Изтриване на споделен линк",
@@ -941,6 +951,7 @@
"edit_location_action_prompt": "{count} локации са редактирани",
"edit_location_dialog_title": "Местоположение",
"edit_name": "Редактиране на име",
"edit_passkey": "Смяна на парола",
"edit_people": "Редактиране на хора",
"edit_person": "Редактиране на данни за човек",
"edit_tag": "Редактиране на етикет",
@@ -1238,7 +1249,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Видео} other {Снимка}} заснето с {city}, {country} и {person1}, {person2}, и {person3} на {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Видео} other {Снимка}} заснето в {city}, {country} с {person1}, {person2}, и {additionalCount, number} други на{date}",
"immich_logo": "Immich лого",
"immich_web_interface": "Уеб интерфейс на Immich",
"import_from_json": "Импортиране от JSON",
"import_path": "Път за импортиране",
"in_albums": "В {count, plural, one {# албум} other {# албума}}",
@@ -1286,6 +1296,7 @@
"last": "Последен",
"last_months": "{count, plural, one {Последния месец} other {Последните # месеца}}",
"last_seen": "Последно видяно",
"last_used": "Последно използвана",
"latest_version": "Последна версия",
"latitude": "Ширина",
"leave": "Излез",
@@ -1361,6 +1372,7 @@
"login_has_been_disabled": "Влизането е деактивирано",
"login_password_changed_error": "Грешка при обновяване на паролата",
"login_password_changed_success": "Успешно обновена парола",
"login_with_passkey": "Влизане с паролa",
"logout_all_device_confirmation": "Сигурни ли сте, че искате да излезете от всички устройства?",
"logout_this_device_confirmation": "Сигурни ли сте, че искате да излезете от това устройство?",
"logs": "Дневник",
@@ -1400,6 +1412,7 @@
"manage_media_access_title": "Управление на медийни файлове",
"manage_oauth_account": "Управление на OAuth акаунт",
"manage_people": "Управление на хора",
"manage_people_access_description_v2": "Даване на достъп на други потребители до {count, plural, one {този човек} other {тези хора}}, като им разрешавате да виждат и управляват от ваше име. Това действие не споделя достъп до никакви ресурси.",
"manage_person_access": "Управление на достъпа",
"manage_sharing_with_other_users": "Управление на споделяне с други потребители",
"manage_sharing_with_partners": "Управление на споделянето с партньори",
@@ -1408,6 +1421,7 @@
"manage_your_api_keys": "Управление на вашите API ключове",
"manage_your_devices": "Управление на влезлите в системата устройства",
"manage_your_oauth_connection": "Управление на OAuth връзката",
"manage_your_passkeys": "Управлявайте вашите пароли",
"map": "Карта",
"map_assets_in_bounds": "{count, plural, =0 {Няма снимки в тази зона} one {# снимка} other {# снимки}}",
"map_cannot_get_user_location": "Не можах да получа местоположението",
@@ -1527,6 +1541,7 @@
"new_album": "Нов Албум",
"new_api_key": "Нов API ключ",
"new_feature": "Нова функция",
"new_passkey": "Нова паролa",
"new_password": "Нова парола",
"new_person": "Нов човек",
"new_pin_code": "Нов PIN код",
@@ -1639,6 +1654,8 @@
"partner_sharing": "Споделяне с партньори",
"partners": "Партньори",
"partners_description": "Споделете своите снимки и видео с други потребители",
"passkeys": "{count, plural, one {Парола} other {Пароли}}",
"passkeys_not_supported": "Този браузър не поддържа пароли",
"password": "Парола",
"password_does_not_match": "Паролата не съвпада",
"password_required": "Изисква се парола",
@@ -1823,6 +1840,7 @@
"removed_from_favorites_count": "{count, plural, other {Премахнати #}} от Любими",
"removed_from_stack": "Активът е премахнат от групата",
"removed_memory": "Премахнат спомен",
"removed_passkey": "Премахната парола: {name}",
"removed_tagged_assets": "Премахнат е етикетът от {count, plural, one {# елемент} other {# елемента}}",
"rename": "Преименувай",
"repository": "Хранилище",
@@ -1984,11 +2002,9 @@
"setting_image_navigation_enable_subtitle": "Ако е избрано, можете да навигирате към предишна/следваща снимка като натиснете върху лявата/дясната страна на екрана.",
"setting_image_navigation_enable_title": "Натисни за навигиране",
"setting_image_navigation_title": "Навигиране на снимка",
"setting_image_viewer_help": "При показване на обект първо се зарежда миниатюра, после изображение със средно качество (ако е разрешено) и накрая оригинала (ако е разрешено).",
"setting_image_viewer_help": "При показване на обект първо се зарежда миниатюра, после изображение със средно качество и накрая оригинала (ако е разрешено).",
"setting_image_viewer_original_subtitle": "Разреши за да се зарежда оригиналното изображение в пълен размер (голям!). Забрани за да се намали обема на данните (по мрежата и в кеша на устройството).",
"setting_image_viewer_original_title": "Зареждане на оригинално изображение",
"setting_image_viewer_preview_subtitle": "Разреши за да се зарежда изображение със средно качество (и размер). Забрани за да се зарежда само оригинал и миниатюра.",
"setting_image_viewer_preview_title": "Зареждане на миниатюра",
"setting_languages_apply": "Приложи",
"setting_languages_subtitle": "Смени езика на приложението",
"setting_notifications_notify_minutes": "{count} минути",
@@ -2008,8 +2024,9 @@
"share_everyone": "Споделете с всеки",
"share_everyone_description": "Дайте на {user} достъп до всички ваши хора.",
"share_link": "Връзка за споделяне",
"share_logs": "Споделяне на дневници",
"share_original": "Използвай оригинала (голям размер)",
"share_preview": "Използвай миниатюра (намален размер)",
"share_preview": "Използвай миниатюра (умален размер)",
"share_quality_body": "Натиснете и задръжте бутона за споделяне, за да изберете качеството на изображението, преди да го споделите.",
"share_quality_title": "Изберете качеството при споделяне",
"share_with": "Споделете достъп с",
@@ -2059,7 +2076,6 @@
"shared_link_password_description": "Изискване на парола за достъп до споделения линк",
"shared_links": "Споделени връзки",
"shared_links_description": "Сподели снимки и видеа с линк",
"shared_photos_and_videos_count": "{assetCount, plural, other {# споделени снимки и видеа.}}",
"shared_with": "Споделено с",
"shared_with_count": "Споделени ({count})",
"shared_with_me": "Споделено с мен",
@@ -2187,6 +2203,8 @@
"theme": "Тема",
"theme_selection": "Избор на тема",
"theme_selection_description": "Автоматично задаване на светла или тъмна тема спрямо системните предпочитания на браузъра ви",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Превъртай непрекъснато при влачене по скролбара, вместо да скачаш по месеци",
"theme_setting_asset_list_smooth_scrubbing_title": "Плавно превъртане на времевата линия",
"theme_setting_asset_list_storage_indicator_title": "Показвай индикатор за хранилището в заглавията на обектите",
"theme_setting_asset_list_tiles_per_row_title": "Брой обекти на ред ({count})",
"theme_setting_colorful_interface_subtitle": "Нанеси основен цвят върху фоновите повърхности.",
@@ -2338,6 +2356,7 @@
"view_group": "Вижте група",
"view_in_timeline": "Покажи във времева линия",
"view_link": "Преглед на връзката",
"view_more": "Вижте още",
"view_name": "Прегледай",
"view_next_asset": "Преглед на следващия файл",
"view_previous_asset": "Преглед на предишния файл",
+6 -5
View File
@@ -34,7 +34,7 @@
"add_to_albums": "অ্যালবামে যোগ করুন",
"add_to_albums_count": "অ্যালবামে যোগ করুন ({count})",
"add_to_bottom_bar": "এ যোগ করুন",
"add_upload_to_stack": "আপলোড স্ট্যাকে যোগ করুন",
"add_upload_to_stack": "আপলোড করুন এবং {isStack, select, true {স্ট্যাকে যোগ করুন} other {স্ট্যাক তৈরি করুন}}",
"add_url": "লিঙ্ক যোগ করুন",
"add_user": "ব্যবহারকারী যোগ করুন",
"added_to_archive": "আর্কাইভ এ যোগ করা হয়েছে",
@@ -323,6 +323,11 @@
"oauth_timeout": "রিকোয়েস্ট টাইম-আউট (Request Timeout)",
"oauth_timeout_description": "মিলিসেকেন্ডে রিকোয়েস্টের টাইম-আউট (Timeout for requests in milliseconds)",
"ocr_job_description": "ছবি থেকে টেক্সট শনাক্ত করতে মেশিন লার্নিং ব্যবহার করুন",
"passkey_additional_domains": "অতিরিক্ত ডোমেইনসমূহ",
"passkey_additional_domains_description": "অতিরিক্ত বিশ্বস্ত ডোমেইনসমূহ (সংশ্লিষ্ট উৎস থেকে আসা অনুরোধ)। পাসকি ডোমেইনটিও অ্যাক্সেসযোগ্য থাকলেই কেবল এগুলো কাজ করবে।",
"passkey_domain": "পাসকি ডোমেইন",
"passkey_domain_description": "পাসকির জন্য ব্যবহৃত ডোমেইন (রিলাইং পার্টি)। অরিজিন হেডার থেকে স্বয়ংক্রিয়ভাবে নির্ধারণ করতে চাইলে ফাঁকা রাখুন। এই মান পরিবর্তন করলে বিদ্যমান পাসকিগুলো আর কাজ করবে না।",
"passkey_enable_description": "পাসকি দিয়ে লগইন করুন",
"password_enable_description": "ইমেল এবং পাসওয়ার্ড দিয়ে লগইন করুন",
"password_settings": "পাসওয়ার্ড লগইন (Password Login)",
"password_settings_description": "পাসওয়ার্ড লগইন সেটিংস ম্যানেজ করুন",
@@ -1220,7 +1225,6 @@
"image_alt_text_date_place_3_people": "{city}, {country}-এ {person1}, {person2}, এবং {person3}-এর সাথে {date} তারিখে তোলা {isVideo, select, true {ভিডিও} other {ছবি}}",
"image_alt_text_date_place_4_or_more_people": "{city}, {country}-এ {person1}, {person2}, এবং আরও {additionalCount, number} জনের সাথে {date} তারিখে তোলা {isVideo, select, true {ভিডিও} other {ছবি}}",
"immich_logo": "ইমিচ লোগো",
"immich_web_interface": "ইমিচ ওয়েব ইন্টারফেস",
"import_from_json": "জেএসওএন থেকে আমদানি করুন",
"import_path": "আমদানি পথ",
"in_albums": "{count, plural, one {#টি অ্যালবামে} other {#টি অ্যালবামে}}",
@@ -1949,8 +1953,6 @@
"setting_image_viewer_help": "ডিটেইল ভিউয়ার প্রথমে ছোট থাম্বনেইল লোড করে, তারপর মাঝারি আকারের প্রিভিউ লোড করে (চালু থাকলে), অবশেষে মূল ছবি লোড করে (চালু থাকলে)।",
"setting_image_viewer_original_subtitle": "মূল পূর্ণ-রেজোলিউশন ছবি লোড করতে চালু করুন (বড়!)। ডেটা ব্যবহার কমাতে (নেটওয়ার্ক ও ডিভাইস ক্যাশ উভয়ই) নিষ্ক্রিয় করুন।",
"setting_image_viewer_original_title": "মূল ছবি লোড করুন",
"setting_image_viewer_preview_subtitle": "মাঝারি-রেজোলিউশন ছবি লোড করতে চালু করুন। সরাসরি মূল ছবি লোড করতে বা শুধুমাত্র থাম্বনেইল ব্যবহার করতে নিষ্ক্রিয় করুন।",
"setting_image_viewer_preview_title": "প্রিভিউ ছবি লোড করুন",
"setting_languages_apply": "প্রয়োগ করুন",
"setting_languages_subtitle": "অ্যাপের ভাষা পরিবর্তন করুন",
"setting_notifications_notify_minutes": "{count} মিনিট",
@@ -2016,7 +2018,6 @@
"shared_link_password_description": "এই শেয়ার লিংক অ্যাক্সেস করতে পাসওয়ার্ড আবশ্যক করুন",
"shared_links": "শেয়ার করা লিংক",
"shared_links_description": "একটি লিংক দিয়ে ছবি ও ভিডিও শেয়ার করুন",
"shared_photos_and_videos_count": "{assetCount, plural, other {#টি শেয়ার করা ছবি ও ভিডিও।}}",
"shared_with_me": "আমার সাথে শেয়ার করা হয়েছে",
"shared_with_partner": "{partner}-এর সাথে শেয়ার করা হয়েছে",
"sharing": "শেয়ারিং",
+36 -17
View File
@@ -86,7 +86,7 @@
"failed_job_command": "L’ordre {command} ha fallat a la tasca: {job}",
"force_delete_user_warning": "ADVERTÈNCIA: Aquesta acció suprimirà immediatament l’usuari i tots els recursos. Aquesta acció no es pot desfer i els fitxers no es podran recuperar.",
"image_format": "Format",
"image_format_description": "WebP genera fitxers més petits que JPEG, però es codifica més lentament.",
"image_format_description": "WebP genera fitxers més petits que JPEG, però es codifica més lentament. JPEG XL és la millor opció per a una compressió d’alta qualitat, però no tots els navegadors i aplicacions el poden mostrar.",
"image_fullsize_description": "Imatge de mida completa amb les metadades eliminades. S’utilitza en ampliar",
"image_fullsize_enabled": "Activa la generació d’imatges de mida completa",
"image_fullsize_enabled_description": "Genera imatges de mida completa per a formats no aptes per al web. Quan s’activa «Prefereix la previsualització incrustada», s’utilitzen directament previsualitzacions incrustades sense conversió. No afecta els formats aptes per al web com JPEG.",
@@ -100,7 +100,7 @@
"image_preview_quality_description": "Qualitat de la previsualització de l’1 al 100. Un valor més alt és millor, però produeix fitxers més grans i pot reduir la capacitat de resposta de l’aplicació. Establir-hi un valor baix pot afectar la qualitat de l’aprenentatge automàtic.",
"image_preview_title": "Configuració de les previsualitzacions",
"image_progressive": "Progressiu",
"image_progressive_description": "Codifica les imatges JPEG en mode progressiu perquè es visualitzin amb una càrrega gradual. No té cap efecte sobre les imatges WebP.",
"image_progressive_description": "Codifica les imatges JPEG en mode progressiu perquè es visualitzin amb una càrrega gradual. No té cap efecte sobre altres formats.",
"image_quality": "Qualitat",
"image_resolution": "Resolució",
"image_resolution_description": "Les resolucions més altes poden conservar més detalls, però tarden més temps a codificar-se, tenen mides de fitxer més grans i poden reduir la capacitat de resposta de l’aplicació.",
@@ -163,7 +163,7 @@
"machine_learning_clip_model_description": "El nom d’un model CLIP que aparegui <link>aquí</link>. Tingueu en compte que si canvieu de model, cal que torneu a executar la tasca «Cerca intel·ligent» per a totes les imatges.",
"machine_learning_duplicate_detection": "Detecció de duplicats",
"machine_learning_duplicate_detection_enabled": "Activa la detecció de duplicats",
"machine_learning_duplicate_detection_enabled_description": "Si està desactivada, els elements idèntics encara es desduplicaran.",
"machine_learning_duplicate_detection_enabled_description": "Si està desactivada, els recursos idèntics encara es desduplicaran.",
"machine_learning_duplicate_detection_setting_description": "Utilitza incrustacions CLIP per a trobar possibles duplicats",
"machine_learning_enabled": "Activa l’aprenentatge automàtic",
"machine_learning_enabled_description": "Si està desactivat, totes les funcions de ML es desactivaran, independentment de la configuració següent.",
@@ -323,6 +323,13 @@
"oauth_timeout": "Temps d’espera de les sol·licituds",
"oauth_timeout_description": "Temps d’espera de les sol·licituds en mil·lisegons",
"ocr_job_description": "Utilitza aprenentatge automàtic per a reconèixer text a les imatges",
"passkey_additional_domains": "Dominis addicionals",
"passkey_additional_domains_description": "Dominis addicionals i de confiança (peticions d’origen relacionades). Només funcionen si el domini de la clau d’accés també és accessible.",
"passkey_domain": "Domini de la clau d’accés",
"passkey_domain_description": "Domini utilitzat per a les claus d’accés («relying party»). Deixeu-lo en blanc per a deduir-lo de la capçalera d’origen. Canviar aquest valor farà que les claus d’accés existents deixin de funcionar.",
"passkey_enable_description": "Inicia la sessió amb una clau d’accés",
"passkey_settings": "Inici de sessió amb clau d’accés",
"passkey_settings_description": "Gestiona la configuració de l’inici de sessió amb claus d’accés",
"password_enable_description": "Inicia la sessió amb adreça electrònica i contrasenya",
"password_settings": "Inici de sessió amb contrasenya",
"password_settings_description": "Gestioneu els paràmetres de l’inici de sessió amb contrasenya",
@@ -395,7 +402,7 @@
"theme_settings": "Configuració del tema",
"theme_settings_description": "Gestioneu la personalització de la interfície web de l’Immich",
"thumbnail_generation_job": "Genera les miniatures",
"thumbnail_generation_job_description": "Genera les miniatures grans, petites i borroses per a cada element, així com miniatures per a cada persona",
"thumbnail_generation_job_description": "Genera les miniatures grans, petites i borroses per a cada recurs, així com miniatures per a cada persona",
"transcoding_acceleration_api": "API d’acceleració",
"transcoding_acceleration_api_description": "L’API que interactuarà amb el vostre dispositiu per a accelerar la transcodificació. Aquesta configuració és de l’estil «millor esforç»: en cas d’error, tornarà a la transcodificació per programari. El VP9 pot funcionar o no depenent del vostre maquinari.",
"transcoding_acceleration_nvenc": "NVENC (requereix GPU d’NVIDIA)",
@@ -506,7 +513,7 @@
"advanced_settings_clear_image_cache_error": "No s’ha pogut esborrar la memòria cau de les imatges",
"advanced_settings_clear_image_cache_success": "S’han esborrat correctament {size}",
"advanced_settings_log_level_title": "Nivell del registre: {level}",
"advanced_settings_prefer_remote_subtitle": "Alguns dispositius són molt lents en carregar miniatures dels elements locals. Activeu aquest paràmetre per a carregar imatges remotes en el seu lloc.",
"advanced_settings_prefer_remote_subtitle": "Alguns dispositius són molt lents en carregar miniatures dels recursos locals. Activeu aquest paràmetre per a carregar imatges remotes en el seu lloc.",
"advanced_settings_prefer_remote_title": "Prefereix les imatges remotes",
"advanced_settings_proxy_headers_subtitle": "Definiu les capçaleres de servidor intermediari que l’Immich ha d’enviar amb cada sol·licitud de xarxa",
"advanced_settings_proxy_headers_title": "Capçaleres de servidor intermediari personalitzades [EXPERIMENTAL]",
@@ -615,11 +622,11 @@
"assets_cannot_be_added_to_albums": "{count, plural, one {El recurs} other {Els recursos}} no es poden afegir a cap dels àlbums",
"assets_count": "{count, plural, one {# recurs} other {# recursos}}",
"assets_moved_to_trash_count": "{count, plural, one {S’ha mogut # recurs} other {S’han mogut # recursos}} a la paperera",
"assets_permanently_deleted_count": "{count, plural, one {S’ha suprimit # recurs} other {S’ha suprimit # recursos}} permanentment",
"assets_permanently_deleted_count": "{count, plural, one {S’ha suprimit # recurs} other {S’han suprimit # recursos}} permanentment",
"assets_removed_count": "{count, plural, one {S’ha eliminat # recurs} other {S’han eliminat # recursos}}",
"assets_restore_confirmation": "Segur que voleu restaurar tots els recursos de la paperera? Aquesta acció no es pot desfer! Tingueu en compte que els recursos fora de línia no es poden restaurar d’aquesta manera.",
"assets_restored_count": "{count, plural, one {S’ha restaurat # recurs} other {S’han restaurat # recursos}}",
"assets_trashed": "{count, plural, one {S’ha enviat # element} other {S’ha enviat # elements}} a la paperera",
"assets_trashed": "{count, plural, one {S’ha enviat # recurs} other {S’han enviat # recursos}} a la paperera",
"assets_trashed_count": "{count, plural, one {S’ha enviat # recurs} other {S’han enviat # recursos}} a la paperera",
"assets_were_part_of_album_count": "{count, plural, one {El recurs ja era} other {Els recursos ja eren}} part de l’àlbum",
"assets_were_part_of_albums_count": "{count, plural, one {El recurs ja era} other {Els recursos ja eren}} part dels àlbums",
@@ -726,7 +733,7 @@
"cleanup_found_assets": "S’han trobat {count} recursos amb còpia al servidor",
"cleanup_found_assets_with_size": "S’han trobat {count} recursos amb còpia al servidor ({size})",
"cleanup_icloud_shared_albums_excluded": "Els àlbums compartits de l’iCloud s’exclouen de l’escaneig",
"cleanup_no_assets_found": "No s’han trobat recursos que coincideixin amb els criteris anteriors. L’alliberament d’espai només pot esborrar elements dels quals s’hagi fet còpia al servidor",
"cleanup_no_assets_found": "No s’han trobat recursos que coincideixin amb els criteris anteriors. L’alliberament d’espai només pot esborrar recursos dels quals s’hagi fet còpia al servidor",
"cleanup_preview_title": "Recursos a eliminar ({count})",
"cleanup_step3_description": "Escaneja cercant recursos que ja tinguin una còpia al servidor que encaixin amb la data de tall i la configuració de manteniment.",
"cleanup_step4_summary": "S’esborraran {count} recursos (creats abans del {date}) del dispositiu local. Les fotos romandran accessibles a l’aplicació Immich.",
@@ -873,6 +880,8 @@
"delete_local_dialog_ok_backed_up_only": "Suprimeix només els que tinguin còpia al servidor",
"delete_local_dialog_ok_force": "Suprimeix igualment",
"delete_others": "Suprimeix els altres",
"delete_passkey": "Suprimeix la clau s’accés",
"delete_passkey_prompt": "Segur que voleu suprimir aquesta clau d’accés?",
"delete_permanently": "Suprimeix permanentment",
"delete_permanently_action_prompt": "{count} suprimits permanentment",
"delete_shared_link": "Suprimeix l’enllaç compartit",
@@ -942,6 +951,7 @@
"edit_location_action_prompt": "{count} ubicacions editades",
"edit_location_dialog_title": "Ubicació",
"edit_name": "Edita el nom",
"edit_passkey": "Edita la clau d’accés",
"edit_people": "Edita les persones",
"edit_person": "Edita la persona",
"edit_tag": "Edita l’etiqueta",
@@ -1032,8 +1042,8 @@
"unable_to_add_comment": "No s’ha pogut afegir el comentari",
"unable_to_add_exclusion_pattern": "No s’ha pogut afegir el patró d’exclusió",
"unable_to_add_partners": "No s’han pogut afegir companys",
"unable_to_add_remove_archive": "No s’ha pogut {archived, select, true {eliminar l’element de} other {afegir l’element a}} l’arxiu",
"unable_to_add_remove_favorites": "No s’ha pogut {favorite, select, true {afegir l’element als} other {eliminar l’element dels}} preferits",
"unable_to_add_remove_archive": "No s’ha pogut {archived, select, true {eliminar el recurs de} other {afegir el recurs a}} l’arxiu",
"unable_to_add_remove_favorites": "No s’ha pogut {favorite, select, true {afegir el recurs als} other {eliminar el recurs dels}} preferits",
"unable_to_archive_unarchive": "No s’ha pogut {archived, select, true {arxivar} other {desarxivar}}",
"unable_to_change_album_user_role": "No s’ha pogut canviar el rol de l’usuari de l’àlbum",
"unable_to_change_date": "No s’ha pogut canviar la data",
@@ -1239,7 +1249,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo enregistrat} other {Imatge capturada}} a {city}, {country} amb {person1}, {person2} i {person3} el {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo enregistrat} other {Imatge capturada}} a {city}, {country} amb {person1}, {person2} i {additionalCount, number} més el {date}",
"immich_logo": "Logo de l’Immich",
"immich_web_interface": "Interfície web de l’Immich",
"import_from_json": "Importa d’un JSON",
"import_path": "Camí d’importació",
"in_albums": "En {count, plural, one {# àlbum} other {# àlbums}}",
@@ -1287,6 +1296,7 @@
"last": "Darrer",
"last_months": "{count, plural, one {Darrer mes} other {Darrers # mesos}}",
"last_seen": "Vist per darrera vegada",
"last_used": "Utilitzada per darrer cop",
"latest_version": "Darrera versió",
"latitude": "Latitud",
"leave": "Surt",
@@ -1362,6 +1372,7 @@
"login_has_been_disabled": "S’ha desactivat l’inici de sessió",
"login_password_changed_error": "S’ha produït un error en actualitzar la contrasenya",
"login_password_changed_success": "La contrasenya s’ha canviat correctament",
"login_with_passkey": "Inicia la sessió amb una clau d’accés",
"logout_all_device_confirmation": "Segur que voleu tancar la sessió en tots els dispositius?",
"logout_this_device_confirmation": "Segur que voleu tancar la sessió d’aquest dispositiu?",
"logs": "Registres",
@@ -1410,6 +1421,7 @@
"manage_your_api_keys": "Gestioneu les vostres claus de l’API",
"manage_your_devices": "Gestioneu els vostres dispositius connectats",
"manage_your_oauth_connection": "Gestioneu la vostra connexió amb l’OAuth",
"manage_your_passkeys": "Gestiona les meves claus d’accés",
"map": "Mapa",
"map_assets_in_bounds": "{count, plural, =0 {No hi ha cap foto en aquesta zona} one {# foto} other {# fotos}}",
"map_cannot_get_user_location": "No s’ha pogut obtenir la ubicació de l’usuari",
@@ -1529,6 +1541,7 @@
"new_album": "Àlbum nou",
"new_api_key": "Clau de l’API nova",
"new_feature": "Funcionalitat nova",
"new_passkey": "Clau d’accés nova",
"new_password": "Contrasenya nova",
"new_person": "Persona nova",
"new_pin_code": "Codi PIN nou",
@@ -1641,6 +1654,8 @@
"partner_sharing": "Compartició amb companys",
"partners": "Companys",
"partners_description": "Compartiu les vostres fotos i vídeos amb altres usuaris",
"passkeys": "{count, plural, one {Clau d’accés} other {Claus d’accés}}",
"passkeys_not_supported": "Aquest navegador no admet les claus d’accés",
"password": "Contrasenya",
"password_does_not_match": "La contrasenya no coincideix",
"password_required": "La contrasenya és obligatòria",
@@ -1825,6 +1840,7 @@
"removed_from_favorites_count": "{count, plural, other {# eliminats}} dels preferits",
"removed_from_stack": "S’ha eliminat el recurs de la pila",
"removed_memory": "S’ha eliminat el record",
"removed_passkey": "S’ha suprimit la clau d’accés: {name}",
"removed_tagged_assets": "S’ha eliminat l’etiqueta {count, plural, one {d’# recurs} other {de # recursos}}",
"rename": "Canvia de nom",
"repository": "Repositori",
@@ -1986,11 +2002,9 @@
"setting_image_navigation_enable_subtitle": "Si està activat, podeu navegar a la imatge anterior/següent tocant la quarta part més a l’esquerra/dreta de la pantalla.",
"setting_image_navigation_enable_title": "Toqueu per a navegar",
"setting_image_navigation_title": "Navegació per les imatges",
"setting_image_viewer_help": "El visualitzador de detalls carrega primer la miniatura petita, després carrega la previsualització de mida mitjana (si està activada), i finalment carrega l’original (si està activat).",
"setting_image_viewer_help": "El visualitzador de detalls carrega primer la miniatura petita, després carrega la previsualització de mida mitjana, i finalment carrega l’original (si està activat).",
"setting_image_viewer_original_subtitle": "Activeu-ho per a carregar la imatge en resolució original (molt gran!). Desactiveu-ho per a reduir el consum de dades (tant de xarxa com de memòria cau del dispositiu).",
"setting_image_viewer_original_title": "Carrega la imatge original",
"setting_image_viewer_preview_subtitle": "Activeu-ho per a carregar una imatge de resolució mitjana. Desactiveu-ho per carregar directament la imatge original, o bé utilitzar només la miniatura.",
"setting_image_viewer_preview_title": "Carrega la imatge de previsualització",
"setting_languages_apply": "Aplica",
"setting_languages_subtitle": "Canvia la llengua de l’aplicació",
"setting_notifications_notify_minutes": "{count} minuts",
@@ -2010,8 +2024,9 @@
"share_everyone": "Comparteix tothom",
"share_everyone_description": "Dóna accés a totes les vostres persones a {user}.",
"share_link": "Comparteix l’enllaç",
"share_logs": "Comparteix els registres",
"share_original": "Utilitza l’original (gran)",
"share_preview": "Utilitza la miniatura (petita)",
"share_preview": "Utilitza la previsualització (petita)",
"share_quality_body": "Mantingueu premut el botó de compartició per a triar la qualitat de la imatge abans de compartir-la.",
"share_quality_title": "Trieu la qualitat de la compartició",
"share_with": "Comparteix l’accés amb",
@@ -2061,7 +2076,6 @@
"shared_link_password_description": "Requereix una contrasenya per a accedir a aquest enllaç compartit",
"shared_links": "Enllaços compartits",
"shared_links_description": "Compartiu fotos i vídeos amb un enllaç",
"shared_photos_and_videos_count": "{assetCount, plural, other {# fotos i vídeos compartits.}}",
"shared_with": "Compartida amb",
"shared_with_count": "Compartit ({count})",
"shared_with_me": "Compartit amb mi",
@@ -2189,6 +2203,8 @@
"theme": "Tema",
"theme_selection": "Selecció del tema",
"theme_selection_description": "Activa automàticament el tema fosc o clar en funció de les preferències del sistema del navegador",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Es desplaça de manera contínua en arrossegar la barra de desplaçament, en lloc de saltar d’un mes a l’altre",
"theme_setting_asset_list_smooth_scrubbing_title": "Desplaçament suau per la cronologia",
"theme_setting_asset_list_storage_indicator_title": "Mostra l’indicador d’emmagatzematge a les tessel·les dels recursos",
"theme_setting_asset_list_tiles_per_row_title": "Nombre de recursos per fila ({count})",
"theme_setting_colorful_interface_subtitle": "Aplica el color primari a les superfícies de fons.",
@@ -2229,6 +2245,8 @@
"trigger": "Disparador",
"trigger_asset_metadata_extraction": "Extracció de metadades dels recursos",
"trigger_asset_metadata_extraction_description": "Es dispara quan s’extreuen les metadades EXIF d’un recurs",
"trigger_asset_ocr_extraction": "Extracció de text OCR dels recursos",
"trigger_asset_ocr_extraction_description": "Es dispara quan s’extreu el text OCR d’un recurs",
"trigger_asset_tagged": "Recurs etiquetat",
"trigger_asset_tagged_description": "Es dispara quan s’afegeix una etiqueta a un recurs",
"trigger_asset_uploaded": "Pujada de recursos",
@@ -2304,7 +2322,7 @@
"user": "Usuari",
"user_has_been_deleted": "Aquest usuari s’ha eliminat.",
"user_id": "Identificador d’usuari",
"user_liked": "A {user} li ha agradat {type, select, photo {aquesta foto} video {aquest vídeo} asset {aquest recurs} other {això}}",
"user_liked": "A {user} li ha agradat {type, select, image {aquesta foto} video {aquest vídeo} other {això}}",
"user_pin_code_settings": "Codi PIN",
"user_pin_code_settings_description": "Gestioneu el vostre codi PIN",
"user_privacy": "Privacitat de l’usuari",
@@ -2340,6 +2358,7 @@
"view_group": "Mostra el grup",
"view_in_timeline": "Mostra a la cronologia",
"view_link": "Mostra l’enllaç",
"view_more": "Mostra’n més",
"view_name": "Visualització",
"view_next_asset": "Mostra el recurs següent",
"view_previous_asset": "Mostra el recurs anterior",
+28 -9
View File
@@ -86,7 +86,7 @@
"failed_job_command": "Příkaz {command} se nezdařil pro úlohu: {job}",
"force_delete_user_warning": "UPOZORNĚNÍ: Tímto okamžitě odstraníte uživatele a všechny jeho položky. Tento krok nelze vrátit zpět a soubory nelze obnovit.",
"image_format": "Formát",
"image_format_description": "WebP vytváří menší soubory než JPEG, ale je pomalejší při kódování.",
"image_format_description": "WebP vytváří menší soubory než JPEG, ale je pomalejší při kódování. JPEG XL je nejlepší volbou pro kompresi ve vysoké kvalitě, ale ne všechny prohlížeče a aplikace jej dokážou zobrazit.",
"image_fullsize_description": "Obrázek v plné velikosti s odstraněnými metadaty, použito při přiblížení",
"image_fullsize_enabled": "Povolit generování obrázků v plné velikosti",
"image_fullsize_enabled_description": "Generovat obrázky v plné velikosti pro formáty, které nejsou vhodné pro web. Pokud je povolena možnost „Preferovat vložený náhled“, budou přímo použity vložené náhledy bez převodu. Neovlivňuje formáty vhodné pro web, jako je JPEG.",
@@ -100,7 +100,7 @@
"image_preview_quality_description": "Kvalita náhledu od 1 do 100. Vyšší je lepší, ale vytváří větší soubory a může snížit responzivitu aplikace. Nastavení nízké hodnoty může ovlivnit kvalitu strojového učení.",
"image_preview_title": "Náhledy",
"image_progressive": "Progresivní",
"image_progressive_description": "Kódujte JPEG obrázky progresivně pro postupné načítání zobrazení. Na WebP obrázky to nemá žádný vliv.",
"image_progressive_description": "Kódujte JPEG obrázky progresivně pro postupné načítání zobrazení. Na ostatní formáty to nemá žádný vliv.",
"image_quality": "Kvalita",
"image_resolution": "Rozlišení",
"image_resolution_description": "Vyšší rozlišení mohou zachovat více detailů, ale jejich kódování trvá déle, mají větší velikost souboru a mohou snížit odezvu aplikace.",
@@ -323,6 +323,13 @@
"oauth_timeout": "Časový limit požadavku",
"oauth_timeout_description": "Časový limit pro požadavky v milisekundách",
"ocr_job_description": "Použijte strojové učení k rozpoznávání textu v obrázcích",
"passkey_additional_domains": "Další domény",
"passkey_additional_domains_description": "Další důvěryhodné domény (související požadavky na origin). Tyto budou fungovat pouze v případě, že je přístupná i doména s přístupovým klíčem.",
"passkey_domain": "Doména přístupového klíče",
"passkey_domain_description": "Doména používaná pro přístupové klíče (ověřující strana). Nechte pole prázdné, aby se hodnota odvodila z origin hlavičky. Změna této hodnoty způsobí, že stávající přístupové klíče přestanou fungovat.",
"passkey_enable_description": "Přihlášení pomocí přístupového klíče",
"passkey_settings": "Přihlášení pomocí přístupového klíče",
"passkey_settings_description": "Správa nastavení přihlášení pomocí přístupového klíče",
"password_enable_description": "Přihlášení pomocí e-mailu a hesla",
"password_settings": "Přihlášení heslem",
"password_settings_description": "Správa nastavení přihlašování pomocí hesla",
@@ -873,6 +880,8 @@
"delete_local_dialog_ok_backed_up_only": "Smazat pouze zálohované",
"delete_local_dialog_ok_force": "Přesto smazat",
"delete_others": "Smazat ostatní",
"delete_passkey": "Odstranit přístupový klíč",
"delete_passkey_prompt": "Opravdu chcete tento přístupový klíč smazat?",
"delete_permanently": "Trvale smazat",
"delete_permanently_action_prompt": "{count} trvale smazáno",
"delete_shared_link": "Smazat sdílený odkaz",
@@ -942,6 +951,7 @@
"edit_location_action_prompt": "{count} upravených poloh",
"edit_location_dialog_title": "Poloha",
"edit_name": "Upravit jméno",
"edit_passkey": "Upravit přístupový klíč",
"edit_people": "Upravit lidi",
"edit_person": "Upravit osobu",
"edit_tag": "Upravit značku",
@@ -1239,7 +1249,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video pořízeno} other {Obrázek požízen}} {date} v místě {city}, {country} uživateli {person1}, {person2} a {person3}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video pořízeno} other {Obrázek požízen}} {date} v místě {city}, {country} uživateli {person1}, {person2} a {additionalCount, plural, one {dalším # uživatelem} other {dalšími # uživateli}}",
"immich_logo": "Immich Logo",
"immich_web_interface": "Webové rozhraní Immich",
"import_from_json": "Import z JSONu",
"import_path": "Cesta importu",
"in_albums": "{count, plural, one {V # albu} few {Ve # albech} other {V # albech}}",
@@ -1287,6 +1296,7 @@
"last": "Poslední",
"last_months": "{count, plural, one {Poslední měsíc} few {Poslední # měsíce} other {Posledních # měsíců}}",
"last_seen": "Naposledy viděno",
"last_used": "Naposledy použito",
"latest_version": "Nejnovější verze",
"latitude": "Zeměpisná šířka",
"leave": "Opustit",
@@ -1362,6 +1372,7 @@
"login_has_been_disabled": "Přihlášení bylo zakázáno",
"login_password_changed_error": "Při aktualizaci vašeho hesla došlo k chybě",
"login_password_changed_success": "Heslo bylo úspěšně aktualizováno",
"login_with_passkey": "Přihlásit se pomocí přístupového klíče",
"logout_all_device_confirmation": "Opravdu chcete odhlásit všechna zařízení?",
"logout_this_device_confirmation": "Opravdu chcete odhlásit toto zařízení?",
"logs": "Protokoly",
@@ -1410,6 +1421,7 @@
"manage_your_api_keys": "Správa API klíčů",
"manage_your_devices": "Správa přihlášených zařízení",
"manage_your_oauth_connection": "Správa OAuth propojení",
"manage_your_passkeys": "Správa přístupových klíčů",
"map": "Mapa",
"map_assets_in_bounds": "{count, plural, =0 {Žádné fotografie v této oblasti} one {# fotografie} few {# fotografie} other {# fotografií}}",
"map_cannot_get_user_location": "Nelze zjistit polohu uživatele",
@@ -1529,6 +1541,7 @@
"new_album": "Nové album",
"new_api_key": "Nový API klíč",
"new_feature": "Nová funkce",
"new_passkey": "Nový přístupový klíč",
"new_password": "Nové heslo",
"new_person": "Nová osoba",
"new_pin_code": "Nový PIN kód",
@@ -1641,6 +1654,8 @@
"partner_sharing": "Sdílení mezi partnery",
"partners": "Partneři",
"partners_description": "Sdílejte své fotografie a videa s ostatními uživateli",
"passkeys": "{count, plural, one {přístupový klíč} few {přístupové klíče} other {přístupových klíčů}}",
"passkeys_not_supported": "Tento prohlížeč nepodporuje přístupové klíče",
"password": "Heslo",
"password_does_not_match": "Heslo se neshoduje",
"password_required": "Vyžadováno heslo",
@@ -1825,6 +1840,7 @@
"removed_from_favorites_count": "{count, plural, one {Odstraněn #} few {Odstraněny #} other {Odstraněno #}} z oblíbených",
"removed_from_stack": "Položka byla odebrána ze seskupení",
"removed_memory": "Vzpomínka odstraněna",
"removed_passkey": "Odstraněný přístupový klíč: {name}",
"removed_tagged_assets": "Odstraněná značka z {count, plural, one {# položky} other {# položek}}",
"rename": "Přejmenovat",
"repository": "Repozitář",
@@ -1986,11 +2002,9 @@
"setting_image_navigation_enable_subtitle": "Pokud je zapnuto, budete moci přejít na předchozí/další obrázek klepnutím do levé/pravé čtvrtiny obrazovky.",
"setting_image_navigation_enable_title": "Klepnutí pro navigaci",
"setting_image_navigation_title": "Navigace mezi obrázky",
"setting_image_viewer_help": "V prohlížeči detailů se nejprve načte malá miniatura, poté se načte náhled střední velikosti (je-li povolen) a nakonec se načte originál (je-li povolen).",
"setting_image_viewer_help": "V prohlížeči detailů se nejprve načte malá miniatura, poté se načte náhled střední velikosti a nakonec se načte originál (je-li povolen).",
"setting_image_viewer_original_subtitle": "Umožňuje načíst původní obrázek v plném rozlišení (velký!). Zakažte pro snížení využití dat (v síti i v mezipaměti zařízení).",
"setting_image_viewer_original_title": "Načíst původní obrázek",
"setting_image_viewer_preview_subtitle": "Umožňuje načíst obrázek se středním rozlišením. Zakažte, pokud chcete přímo načíst originál nebo použít pouze miniaturu.",
"setting_image_viewer_preview_title": "Načíst náhled obrázku",
"setting_languages_apply": "Použít",
"setting_languages_subtitle": "Změna jazyka aplikace",
"setting_notifications_notify_minutes": "{count} minut",
@@ -2010,8 +2024,9 @@
"share_everyone": "Sdílet všechny",
"share_everyone_description": "Poskytněte {user} přístup ke všem vašim uživatelům.",
"share_link": "Sdílet odkaz",
"share_logs": "Sdílet protokol",
"share_original": "Použít originál (velký)",
"share_preview": "Použít miniaturu (malý)",
"share_preview": "Použít náhled (malý)",
"share_quality_body": "Při sdílení stiskněte a podržte tlačítko sdílení pro výběr kvality obrázku.",
"share_quality_title": "Vyberte si kvalitu sdílení",
"share_with": "Sdílet přístup s",
@@ -2061,7 +2076,6 @@
"shared_link_password_description": "Vyžadovat heslo pro přístup k tomuto sdílenému odkazu",
"shared_links": "Sdílené odkazy",
"shared_links_description": "Sdílet fotky a videa pomocí odkazu",
"shared_photos_and_videos_count": "{assetCount, plural, one {# sdílená fotografie a video.} few {# sdílené fotografie a videa.} other {# sdílených fotografií a videí.}}",
"shared_with": "Sdíleno s",
"shared_with_count": "Sdíleno ({count})",
"shared_with_me": "Sdílené se mnou",
@@ -2189,6 +2203,8 @@
"theme": "Motiv",
"theme_selection": "Výběr motivu",
"theme_selection_description": "Automatické nastavení světlého nebo tmavého motivu podle systémových preferencí prohlížeče",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Při tažení posuvníku posouvat plynule, místo aby se posun zastavoval na jednotlivých měsících",
"theme_setting_asset_list_smooth_scrubbing_title": "Plynulé posouvání časové osy",
"theme_setting_asset_list_storage_indicator_title": "Zobrazit indikátor úložiště na dlaždicích položek",
"theme_setting_asset_list_tiles_per_row_title": "Počet položek na řádek ({count})",
"theme_setting_colorful_interface_subtitle": "Použít hlavní barvu na povrchy pozadí.",
@@ -2229,6 +2245,8 @@
"trigger": "Spouštěč",
"trigger_asset_metadata_extraction": "Extrakce metadat položek",
"trigger_asset_metadata_extraction_description": "Spustí se při extrakci EXIF metadat z mediálního souboru",
"trigger_asset_ocr_extraction": "Extrakce dat pomocí OCR",
"trigger_asset_ocr_extraction_description": "Spustí se, když je text OCR položky extrahováný",
"trigger_asset_tagged": "Označená položka",
"trigger_asset_tagged_description": "Spustí se, když je k položce přidána značka",
"trigger_asset_uploaded": "Nahrání položky",
@@ -2304,7 +2322,7 @@
"user": "Uživatel",
"user_has_been_deleted": "Tento uživatel byl smazán.",
"user_id": "ID uživatele",
"user_liked": "Uživateli {user} se {type, select, photo {líbila tato fotka} video {líbilo toto video} asset {líbila tato položka} other {to líbilo}}",
"user_liked": "Uživateli {user} se {type, select, image {líbil tento obrázek} video {líbilo toto video} other {to líbilo}}",
"user_pin_code_settings": "PIN kód",
"user_pin_code_settings_description": "Správa vašeho PIN kódu",
"user_privacy": "Ochrana soukromí uživatelů",
@@ -2340,6 +2358,7 @@
"view_group": "Zobrazit skupinu",
"view_in_timeline": "Zobrazit na časové ose",
"view_link": "Zobrazit odkaz",
"view_more": "Zobrazit více",
"view_name": "Zobrazit",
"view_next_asset": "Zobrazit další položku",
"view_previous_asset": "Zobrazit předchozí položku",
+17 -11
View File
@@ -191,7 +191,7 @@
"machine_learning_ocr_min_detection_score_description": "Minimums konfidensscore for tekst, der skal detekteres, fra 0-1. Lavere værdier vil detektere mere tekst, men kan resultere i falsk positiver.",
"machine_learning_ocr_min_recognition_score": "Minimum genkendelsesscore",
"machine_learning_ocr_min_score_recognition_description": "Minimum konfidensscore for genkendelse af registreret tekst er fra 0-1. Lavere værdier vil genkende mere tekst, men kan resultere i falsk positiver.",
"machine_learning_ocr_model": "OCR model",
"machine_learning_ocr_model": "OCR-model",
"machine_learning_ocr_model_description": "Server modeller er mere præcise end mobil modeller, men tager længer tid at processere og bruger mere hukommelse.",
"machine_learning_settings": "Maskinlæringsindstillinger",
"machine_learning_settings_description": "Administrer maskinlæringsfunktioner og indstillinger",
@@ -443,7 +443,7 @@
"transcoding_realtime_enabled_description": "Hvis deaktiveret, kan serveren ikke starte nye realtids transkodningssessioner.",
"transcoding_realtime_resolutions": "Opløsninger",
"transcoding_realtime_resolutions_description": "Opløsninger tilgængelig for realtidstranskodning. Højere opløsninger kan forårsage afspilningsproblemer, hvis serveren ikke kan transkode dem hurtigt nok.",
"transcoding_realtime_video_codecs": "Video codecs",
"transcoding_realtime_video_codecs": "Video-codecs",
"transcoding_realtime_video_codecs_description": "De video codecs, tilgængelig for realtidstranskodning. Klienter vælger den bedste mulighed, de understøtter under afspilning. AV1 er mere effektiv end HEVC, som er mere effektiv end H.264. Når du bruger hardwareacceleration, skal du kun vælge de codecs, som acceleratoren kan enkode. Når du bruger softwaretranskodning, skal du bemærke, at H.264 er hurtigere end AV1, som er hurtigere end HEVC.",
"transcoding_reference_frames": "Referencerammer",
"transcoding_reference_frames_description": "Antallet af frames, der skal refereres til, når en given frame komprimeres. Højere værdier forbedrer kompressionseffektiviteten, men gør indkodning langsommere. 0 sætter denne værdi automatisk.",
@@ -669,8 +669,9 @@
"biometric_no_options": "Ingen biometrisk adgangskontrol tilgængelig",
"biometric_not_available": "Biometrisk adgangskontrol er ikke tilgængelig på denne enhed",
"birthdate_set_description": "Fødselsdato bruges til at beregne denne persons alder på det tidspunkt, et billede er taget.",
"birthday_memory_age": "{age}. fødselsdag",
"birthday_memory_age": "{age, selectordinal, other {#}}-års fødselsdag",
"birthday_memory_title": "{name}'s fødselsdag",
"birthday_memory_title_with_age": "{name}s fødselsdag · {age, plural, one {# år} other {# år}}",
"blurred_background": "Sløret baggrund",
"browse_templates": "Gennemse skabeloner",
"bugs_and_feature_requests": "Fejl & forbedringsønsker",
@@ -914,7 +915,7 @@
"downloading": "Downloader",
"downloading_archive_filename_size": "Downloader arkiv {filename} (cirka {size})",
"downloading_asset_filename": "Downloader mediefil {filename}",
"downloading_filename": "Downloading {filename}",
"downloading_filename": "Downloader {filename}",
"downloading_from_icloud": "Downloading fra iCloud",
"downloading_media": "Download medier",
"drag_to_reorder": "Træk for at ændre rækkefølgen",
@@ -1116,7 +1117,7 @@
},
"errors_text": "Fejl",
"exclusion_pattern": "Udelukkelsesmønster",
"exif": "Exif",
"exif": "EXIF",
"exif_bottom_sheet_description": "Tilføj beskrivelse...",
"exif_bottom_sheet_description_error": "Fejl ved opdatering af beskrivelsen",
"exif_bottom_sheet_no_description": "Ingen beskrivelse",
@@ -1238,7 +1239,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Billede}} taget i {city}, {country} med {person1}, {person2}, og {person3} den {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Billede}} taget i {city}, {country} med {person1}, {person2}, og {additionalCount, number} andre den {date}",
"immich_logo": "Immich logo",
"immich_web_interface": "Immich webinterface",
"import_from_json": "Importér fra JSON",
"import_path": "Import-sti",
"in_albums": "I {count, plural, one {# album} other {# albummer}}",
@@ -1400,6 +1400,7 @@
"manage_media_access_title": "Mediestyringsadgang",
"manage_oauth_account": "Administrer OAuth-konto",
"manage_people": "Administrér personer",
"manage_people_access_description_v2": "Giv andre brugere adgang til {count, plural, one {denne person} other {dine personer}}, så de kan se eller administrere {count, plural, one {personen} other {dem}} på dine vegne. Denne handling deler ikke nogen elementer.",
"manage_person_access": "Administrér adgang",
"manage_sharing_with_other_users": "Administrer deling med andre brugere",
"manage_sharing_with_partners": "Administrér deling med partnere",
@@ -1655,6 +1656,7 @@
"paused": "Sat på pause",
"pending": "Afventer",
"people": "Personer",
"people_cluster_group": "Persongruppe",
"people_count": "{count, plural, one {# person} other {# personer}}",
"people_edits_count": "Redigeret {count, plural, one {# person} other {# people}}",
"people_feature_description": "Gennemse billeder og videoer grupperet efter personer",
@@ -1674,9 +1676,12 @@
"person_age_years": "{years, plural, other {# år}} gammel",
"person_birthdate": "Født den {date}",
"person_edit_only_change_for_me": "Ændr kun for mig",
"person_edit_only_change_for_me_description": "Markér for kun at anvende ændringerne på din egen bruger",
"person_hidden": "{name}{hidden, select, true { (skjult)} other {}}",
"person_recognized": "Person genkendt",
"person_share_no_users": "Alle brugere i din persongruppe har allerede adgang til denne person.",
"person_update_strategy": "Udfør ændringer på",
"person_update_strategy_description": "Vælg, hvilke personer der skal opdateres, når du ændrer navn eller fødselsdato",
"person_update_strategy_everyone": "Alle med delt adgang",
"person_update_strategy_self": "Kun mig",
"photo_shared_all_users": "Det ser ud til, at du har delt dine billeder med alle brugere, eller også har du ikke nogen bruger at dele med.",
@@ -1760,7 +1765,8 @@
"purchase_settings_server_activated": "Serverens produktnøgle administreres af administratoren",
"query_asset_id": "Forespørgsels Asset ID",
"queue_status": "Kø {count}/{total}",
"rate_asset": "Vurder filer",
"rate_asset": "Bedøm mediefil",
"rate_asset_description": "Brug tasterne 1 til 5 til hurtigt at bedømme en mediefil, og 0 til at fjerne bedømmelsen",
"rating": "Stjernebedømmelse",
"rating_clear": "Nulstil vurdering",
"rating_count": "{count, plural, =0 {Unrated} one {# stjerne} other {# stjerner}}",
@@ -1979,11 +1985,9 @@
"setting_image_navigation_enable_subtitle": "Hvis aktiveret, kan du navigere til det forrige/næste billede ved at trykke på den yderste venstre/højre fjerdedel af skærmen.",
"setting_image_navigation_enable_title": "Tryk for at navigere",
"setting_image_navigation_title": "Billednavigation",
"setting_image_viewer_help": "Detaljeret visning indlæser miniaturebilleder først. Herefter indlæses mediumstørrelse forhåndsvisning af billedet (hvis dette er slået til), for til sidst at vise originalen (hvis dette er slået til).",
"setting_image_viewer_help": "Detaljevisningen indlæser først det lille miniaturebillede, derefter en mellemstor forhåndsvisning og til sidst originalfilen (hvis funktionen er aktiveret).",
"setting_image_viewer_original_subtitle": "Slå indlæsning af originalbillede i fuld størrelse til (stort!). Deaktiver for at reducere dataforbruget (både på netværket og for enhedscache).",
"setting_image_viewer_original_title": "Indlæs originalbillede",
"setting_image_viewer_preview_subtitle": "Slå indlæsning af et mediumstørrelse billede til. Slå fra for enten direkte at indlæse originalen eller kun at bruge miniaturebilledet.",
"setting_image_viewer_preview_title": "Indlæs forhåndsvisning af billedet",
"setting_languages_apply": "Anvend",
"setting_languages_subtitle": "Ændr app-sprog",
"setting_notifications_notify_minutes": "{count} minutter",
@@ -1998,6 +2002,7 @@
"settings_saved": "Indstillinger er gemt",
"setup_pin_code": "Indstil en PIN kode",
"share": "Del",
"share_back": "Del tilbage",
"share_dialog_preparing": "Forbereder...",
"share_everyone": "Del alle",
"share_everyone_description": "Giv {user} adgang til alle dine personer.",
@@ -2053,8 +2058,8 @@
"shared_link_password_description": "Kodeord krævet for at få adgang til dette delte link",
"shared_links": "Delte links",
"shared_links_description": "Del billeder og videoer med et link",
"shared_photos_and_videos_count": "{assetCount, plural, other {# delte billeder & videoer.}}",
"shared_with": "Delt med",
"shared_with_count": "Delt ({count})",
"shared_with_me": "Delt med mig",
"shared_with_partner": "Delt med {partner}",
"sharing": "Delte",
@@ -2138,6 +2143,7 @@
"stop_motion_photo": "Stopmotionbillede",
"stop_photo_sharing": "Stop med at dele dine billeder?",
"stop_photo_sharing_description": "{partner} vil ikke længere kunne tilgå dine billeder.",
"stop_sharing_person_with_user": "Ophæv deling af denne person med denne bruger",
"stop_sharing_photos_with_user": "Afslut deling af dine fotos med denne bruger",
"storage": "Lagringsplads",
"storage_label": "Lagringsmærkat",
+29 -10
View File
@@ -86,7 +86,7 @@
"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 kleinere Dateien als JPEG, ist aber etwas langsamer in der Erstellung.",
"image_format_description": "WebP erzeugt kleinere Dateien als JPEG, die Kodierung dauert jedoch länger. JPEG XL ist die beste Option für eine hochwertige Komprimierung, wird jedoch nicht von allen Browsern und Apps unterstützt.",
"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 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.",
@@ -100,7 +100,7 @@
"image_preview_quality_description": "Vorschauqualität von 1-100. Ein höherer Wert ist besser, erzeugt dadurch aber größere Dateien und kann die Reaktionsfähigkeit der App beeinträchtigen. Die Einstellung eines niedrigen Wertes kann dafür aber die Qualität des maschinellen Lernens beeinträchtigen.",
"image_preview_title": "Vorschaueinstellungen",
"image_progressive": "Fortschrittlich",
"image_progressive_description": "JPEG-Bilder werden schrittweise kodiert, um ein stufenweises Laden zu ermöglichen. Dies hat keine Auswirkungen auf WebP-Bilder.",
"image_progressive_description": "JPEG-Bilder werden schrittweise kodiert, um ein stufenweises Laden zu ermöglichen. Dies hat keine Auswirkungen auf andere Dateiformate.",
"image_quality": "Qualität",
"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ößere Dateigrößen und können die Reaktionsfähigkeit von Anwendungen beeinträchtigen.",
@@ -323,6 +323,13 @@
"oauth_timeout": "Zeitüberschreitung bei Anfrage",
"oauth_timeout_description": "Zeitüberschreitung für Anfragen in Millisekunden",
"ocr_job_description": "Verwende Machine Learning zur Erkennung von Text in Bildern",
"passkey_additional_domains": "Zusätzliche Domains",
"passkey_additional_domains_description": "Zusätzliche, vertrauenswürdige Domänen(Related Origin Requests). Diese funktionieren nur, wenn auch die Passkey-Domäne erreichbar ist.",
"passkey_domain": "Passkex Domäne",
"passkey_domain_description": "Für Passkeys verwendete Domäne(Relying Party). Lassen Sie dieses Feld leer, damit der Wert aus dem Original-Header abgeleitet wird. Eine Änderung dieses Werts führt dazu, dass vorhandene Passkeys nicht mehr funktionieren.",
"passkey_enable_description": "Mit einem Passkey anmelden",
"passkey_settings": "Anmeldung per Passkey",
"passkey_settings_description": "Einstellungen für die Anmeldung per Passkey verwalten",
"password_enable_description": "Mit E-Mail und Passwort anmelden",
"password_settings": "Passwort-Anmeldung",
"password_settings_description": "Passwort-Anmeldeeinstellungen verwalten",
@@ -670,8 +677,8 @@
"biometric_not_available": "Die biometrische Authentifizierung ist auf diesem Gerät nicht verfügbar",
"birthdate_set_description": "Das Geburtsdatum wird verwendet, um das Alter dieser Person zum Zeitpunkt eines Fotos zu berechnen.",
"birthday_memory_age": "{age, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} Geburtstag",
"birthday_memory_title": "{name}'s Geburtstag",
"birthday_memory_title_with_age": "{name}'s Geburtstag· {age, plural, one {# Jahr alt} other {# Jahre alt}}",
"birthday_memory_title": "{name}s Geburtstag",
"birthday_memory_title_with_age": "{name}s Geburtstag· {age, plural, one {# Jahr alt} other {# Jahre alt}}",
"blurred_background": "Unscharfer Hintergrund",
"browse_templates": "Vorlagen durchsuchen",
"bugs_and_feature_requests": "Fehler & Verbesserungsvorschläge",
@@ -873,6 +880,8 @@
"delete_local_dialog_ok_backed_up_only": "Nur gesicherte Inhalte löschen",
"delete_local_dialog_ok_force": "Trotzdem löschen",
"delete_others": "Andere löschen",
"delete_passkey": "Passkey löschen",
"delete_passkey_prompt": "Sind Sie sicher, dass Sie diesen Passkey löschen wollen?",
"delete_permanently": "Endgültig löschen",
"delete_permanently_action_prompt": "{count} endgültig gelöscht",
"delete_shared_link": "geteilten Link löschen",
@@ -942,6 +951,7 @@
"edit_location_action_prompt": "{count} Geolokationen angepasst",
"edit_location_dialog_title": "Standort bearbeiten",
"edit_name": "Name bearbeiten",
"edit_passkey": "Passkey bearbeiten",
"edit_people": "Personen bearbeiten",
"edit_person": "Person bearbeiten",
"edit_tag": "Tag bearbeiten",
@@ -1239,7 +1249,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Bild}} aufgenommen in {city}, {country} mit {person1}, {person2}, und {person3} am {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Bild}} aufgenommen in {city}, {country} mit {person1}, {person2}, und {additionalCount, number} anderen am {date}",
"immich_logo": "Immich-Logo",
"immich_web_interface": "Immich-Web-Oberfläche",
"import_from_json": "Aus JSON importieren",
"import_path": "Importpfad",
"in_albums": "In {count, plural, one {# Album} other {# Alben}}",
@@ -1287,6 +1296,7 @@
"last": "Letzte",
"last_months": "{count, plural, one {Letzter Monat} other {Letzte # Monate}}",
"last_seen": "Zuletzt gesehen",
"last_used": "Zuletzt verwendet",
"latest_version": "Neuste Version",
"latitude": "Breitengrad",
"leave": "Verlassen",
@@ -1362,6 +1372,7 @@
"login_has_been_disabled": "Die Anmeldung wurde deaktiviert",
"login_password_changed_error": "Fehler beim Ändern deines Passwortes",
"login_password_changed_success": "Passwort erfolgreich geändert",
"login_with_passkey": "Anmeldung mit Passkey",
"logout_all_device_confirmation": "Bist du sicher, dass du alle Geräte abmelden willst?",
"logout_this_device_confirmation": "Bist du sicher, dass du dieses Gerät abmelden willst?",
"logs": "Protokolle",
@@ -1410,6 +1421,7 @@
"manage_your_api_keys": "Deine API-Schlüssel verwalten",
"manage_your_devices": "Deine eingeloggten Geräte verwalten",
"manage_your_oauth_connection": "Deine OAuth-Verknüpfung verwalten",
"manage_your_passkeys": "Verwalten Sie Ihre Passkeys",
"map": "Karte",
"map_assets_in_bounds": "{count, plural, =0 {Keine Fotos in diesem Bereich} one {# Foto} other {# Fotos}}",
"map_cannot_get_user_location": "Standort konnte nicht ermittelt werden",
@@ -1529,6 +1541,7 @@
"new_album": "Neues Album",
"new_api_key": "Neuer API-Schlüssel",
"new_feature": "Neue Funktion",
"new_passkey": "Neuer Passkey",
"new_password": "Neues Passwort",
"new_person": "Neue Person",
"new_pin_code": "Neuer PIN-Code",
@@ -1641,6 +1654,8 @@
"partner_sharing": "Partner-Sharing",
"partners": "Partner",
"partners_description": "Teile deine Fotos und Videos mit anderen Benutzern",
"passkeys": "{count, plural, one {Passkey} other {Passkeys}}",
"passkeys_not_supported": "Passkeys werden von diesem Browser nicht unterstützt",
"password": "Passwort",
"password_does_not_match": "Passwort stimmt nicht überein",
"password_required": "Passwort benötigt",
@@ -1825,6 +1840,7 @@
"removed_from_favorites_count": "{count, plural, other {#}} aus den Favoriten entfernt",
"removed_from_stack": "Datei aus Stapel entfernen",
"removed_memory": "Erinnerung entfernt",
"removed_passkey": "Passkey entfernt: {name}",
"removed_tagged_assets": "Tag von {count, plural, one {# Datei} other {# Dateien}} entfernt",
"rename": "Umbenennen",
"repository": "Repository",
@@ -1986,11 +2002,9 @@
"setting_image_navigation_enable_subtitle": "Aktivieren, um durch Tippen auf den linksäußeren/rechtsäußeren Bildschirmrand zum vorherigen/nächsten Bild zu navigieren.",
"setting_image_navigation_enable_title": "Tippen zum Navigieren",
"setting_image_navigation_title": "Bild Navigation",
"setting_image_viewer_help": "Der Detailbildbetrachter lädt zuerst ein (kleines) Vorschaubild, dann ein Vorschaubild in mittlerer Größe (falls aktiviert) und schließlich das Original (falls aktiviert).",
"setting_image_viewer_help": "Der Detailbildbetrachter lädt zuerst ein Vorschaubild, dann ein Vorschaubild mittlerer Größe und schließlich das Original (falls aktiviert).",
"setting_image_viewer_original_subtitle": "Aktivieren, um das Originalbild in voller Auflösung (groß!) zu laden. Deaktivieren, um den Datenverbrauch zu reduzieren (sowohl im Netzwerk als auch im Gerätespeicher).",
"setting_image_viewer_original_title": "Original laden",
"setting_image_viewer_preview_subtitle": "Aktivieren, um ein Bild mit mittlerer Auflösung zu laden. Deaktivieren, um entweder das Original direkt zu laden oder nur das Vorschaubild zu verwenden.",
"setting_image_viewer_preview_title": "Vorschaubild laden",
"setting_languages_apply": "Anwenden",
"setting_languages_subtitle": "App-Sprache ändern",
"setting_notifications_notify_minutes": "{count} Minuten",
@@ -2010,8 +2024,9 @@
"share_everyone": "Teilt es mit allen",
"share_everyone_description": "Gewähren Sie {user} Zugriff auf alle Ihre Personen.",
"share_link": "Link teilen",
"share_logs": "Logbücher teilen",
"share_original": "Original verwenden (groß)",
"share_preview": "Thumbnail verwenden (klein)",
"share_preview": "Vorschau anzeigen (klein)",
"share_quality_body": "Halte den Teilen Button gedrückt um die Bildqualität vor dem Teilen auszuwählen.",
"share_quality_title": "Wähle deine Qualität zum Teilen aus",
"share_with": "Zugriff freigeben für",
@@ -2061,7 +2076,6 @@
"shared_link_password_description": "Für den Zugriff auf diesen freigegebenen Link ist ein Passwort erforderlich",
"shared_links": "Geteilte Links",
"shared_links_description": "Teile Fotos und Videos mit einem Link",
"shared_photos_and_videos_count": "{assetCount, plural, one {# geteiltes Foto oder Video.} other {# geteilte Fotos & Videos.}}",
"shared_with": "Geteilt mit",
"shared_with_count": "Geteilt ({count})",
"shared_with_me": "Mit mir geteilt",
@@ -2189,6 +2203,8 @@
"theme": "Theme",
"theme_selection": "Themenauswahl",
"theme_selection_description": "Automatische Einstellung des Themes auf Hell oder Dunkel, je nach Systemeinstellung des Browsers",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Beim Ziehen des Scrollbalkens kontinuierlich scrollen, ohne an den Monaten einzurasten",
"theme_setting_asset_list_smooth_scrubbing_title": "Gleichmäßiges Verschieben auf der Zeitleiste",
"theme_setting_asset_list_storage_indicator_title": "Fortschrittsbalken der Sicherung auf dem Vorschaubild",
"theme_setting_asset_list_tiles_per_row_title": "Anzahl der Elemente pro Reihe ({count})",
"theme_setting_colorful_interface_subtitle": "Primärfarbe auf App-Hintergrund anwenden.",
@@ -2229,6 +2245,8 @@
"trigger": "Auslöser",
"trigger_asset_metadata_extraction": "Extraktion von Datei-Metadaten",
"trigger_asset_metadata_extraction_description": "Wird ausgelöst, wenn die EXIF-Daten einer Datei extrahiert werden",
"trigger_asset_ocr_extraction": "Auslesen von OCR-Text aus Dateien",
"trigger_asset_ocr_extraction_description": "Wird ausgelöst, wenn der OCR-Text einer Datei extrahiert wird",
"trigger_asset_tagged": "Datei getaggt",
"trigger_asset_tagged_description": "Wird ausgelöst, wenn einer Datei ein Tag zugewiesen wird",
"trigger_asset_uploaded": "Datei hochladen",
@@ -2340,6 +2358,7 @@
"view_group": "Gruppe ansehen",
"view_in_timeline": "In Zeitleiste anzeigen",
"view_link": "Link anzeigen",
"view_more": "Mehr anzeigen",
"view_name": "Ansicht",
"view_next_asset": "Nächste Datei anzeigen",
"view_previous_asset": "Vorherige Datei anzeigen",
+3 -2
View File
@@ -100,7 +100,7 @@
"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": "JPEG-Bilder schrittweise kodieren, um ein stufenweises Laden zu ermöglichen. Dies hat keine Auswirkungen auf WebP-Bilder.",
"image_progressive_description": "JPEG-Bilder schrittweise kodieren, um ein stufenweises Laden zu ermöglichen. Dies hat keine Auswirkungen auf andere Dateiformate.",
"image_quality": "Qualität",
"image_resolution": "Uflösig",
"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.",
@@ -248,7 +248,8 @@
"map_settings_description": "Karten- und GPS-Einstellungen verwalten",
"map_style_description": "URL zu einem style.json Karten-Theme",
"memory_cleanup_job": "Erinnerungen aufräumen",
"memory_generate_job": "Erinnerungen Generierung"
"memory_generate_job": "Erinnerungen Generierung",
"metadata_extraction_job": "Metadaten extrahieren"
},
"video_quality": "Videoqualität",
"videos": "Videos",
+27 -4
View File
@@ -1,5 +1,6 @@
{
"about": "Σχετικά",
"accept": "Αποδοχή",
"account": "Λογαριασμός",
"account_settings": "Ρυθμίσεις Λογαριασμού",
"acknowledge": "Έλαβα γνώση",
@@ -29,11 +30,13 @@
"add_to_album_bottom_sheet_added": "Προστέθηκε στο {album}",
"add_to_album_bottom_sheet_already_exists": "Ήδη στο {album}",
"add_to_album_bottom_sheet_some_local_assets": "Ορισμένα τοπικά στοιχεία δεν μπόρεσαν να προστεθούν στο άλμπουμ",
"add_to_album_item_count": "Προσθήκη {αριθμός, πληθυντικός, ένα {#αντικείμενο} άλλο {#αντικείμενα}} στο άλμπουμ",
"add_to_albums": "Προσθήκη στα άλμπουμ",
"add_to_albums_count": "Προσθήκη στα άλμπουμ ({count})",
"add_to_bottom_bar": "Προσθήκη σε",
"add_upload_to_stack": "Προσθήκη αρχείου στην ουρά",
"add_url": "Προσθήκη Συνδέσμου",
"add_user": "Προσθήκη χρήστη",
"added_to_archive": "Προστέθηκε στο αρχείο",
"added_to_favorites": "Προστέθηκε στα αγαπημένα",
"added_to_favorites_count": "Προστέθηκαν {count, number} στα αγαπημένα",
@@ -290,6 +293,8 @@
"notification_enable_email_notifications": "Ενεργοποίηση ειδοποιήσεων μέσω email",
"notification_settings": "Ρυθμίσεις ειδοποιήσεων",
"notification_settings_description": "Διαχείρηση ρυθμίσεων ειδοποιήσεων, συμπεριλαμβανομένου του email",
"oauth_account_management_url": "URL Διαχείρισης Λογαριασμού",
"oauth_account_management_url_description": "Τοποθεσία στον πάροχο εξωτερικής ταυτοποίησης όπου ο χρήστης μπορεί να διαχειριστεί τις ρυθμίσεις ή το προφίλ του.",
"oauth_allow_insecure_requests": "Να επιτρέπονται μη ασφαλή αιτήματα",
"oauth_allow_insecure_requests_description": "ΠΡΟΕΙΔΟΠΟΙΗΣΗ: Αυτό απενεργοποιεί την επαλήθευση πιστοποιητικών TLS για αιτήματα OAuth και μπορεί να σας εκθέσει σε επιθέσεις MITM.",
"oauth_auto_launch": "Αυτόματη εκκίνηση",
@@ -318,6 +323,13 @@
"oauth_timeout": "Χρονικό όριο Αιτήματος",
"oauth_timeout_description": "Χρονικό όριο Αιτήματος σε milliseconds",
"ocr_job_description": "Χρήση μηχανικής μάθησης για αναγνώριση κειμένου σε εικόνες",
"passkey_additional_domains": "Επιπλέον τομείς",
"passkey_additional_domains_description": "Επιπλέον, έμπιστοι τομείς (αιτήματα από σχετική προέλευση). Αυτοί θα λειτουργήσουν μόνο εάν είναι προσβάσιμος και ο τομέας του κλειδιού πρόσβασης (passkey).",
"passkey_domain": "Τομέας κλειδιού πρόσβασης (Passkey)",
"passkey_domain_description": "Τομέας που χρησιμοποιείται για τα κλειδιά πρόσβασης (passkeys) (εμπιστευόμενο μέρος). Αφήστε το κενό για να προσδιοριστεί αυτόματα από την κεφαλίδα προέλευσης. Η αλλαγή αυτής της τιμής θα έχει ως αποτέλεσμα τα υπάρχοντα κλειδιά πρόσβασης να πάψουν να λειτουργούν.",
"passkey_enable_description": "Σύνδεση με κλειδί πρόσβασης (passkey)",
"passkey_settings": "Είσοδος με κλειδί πρόσβασης",
"passkey_settings_description": "Διαχείριση ρυθμίσεων σύνδεσης με κλειδί πρόσβασης",
"password_enable_description": "Σύνδεση με ηλεκτρονικό ταχυδρομείο",
"password_settings": "Σύνδεση με κωδικό",
"password_settings_description": "Διαχείριση ρυθμίσεων σύνδεσης μέσω κωδικού πρόσβασης",
@@ -363,10 +375,17 @@
"storage_template_migration_info": "Το πρότυπο αποθήκευσης θα μετατρέψει όλες τις επεκτάσεις σε πεζά γράμματα. Οι αλλαγές στο πρότυπο θα ισχύουν μόνο για νέα περιουσιακά στοιχεία. Για να εφαρμόσετε αναδρομικά το πρότυπο σε περιουσιακά στοιχεία που έχουν μεταφορτωθεί προηγουμένως, εκτελέστε το <link>{job}</link>.",
"storage_template_migration_job": "Εργασία Μεταφοράς Προτύπων Αποθήκευσης",
"storage_template_more_details": "Για περισσότερες λεπτομέρειες σχετικά με αυτήν τη δυνατότητα, ανατρέξτε στο <template-link>Πρότυπο Αποθήκευσης</template-link> και στις <implications-link>συνέπειές</implications-link> του",
"storage_template_onboarding_description_v3": "Τα πρότυπα αποθήκευσης σας επιτρέπουν να ορίσετε έναν προσαρμοσμένο φάκελο στον δίσκο για τα αρχεία που ανεβάζετε. Αυτό μπορεί πάντα να αλλαχτεί ξανά αργότερα.",
"storage_template_option_custom": "Προσαρμοσμένος",
"storage_template_option_custom_description": "Δημιουργήστε την δική σας δομή φακέλων με τις πρότυπες μεταβλητές",
"storage_template_option_date_description": "Ταξινομήστε τα αρχεία μέσα σε φακέλους ανά χρόνο, μήνα και ημέρα",
"storage_template_option_none": "Κανένα πρότυπο",
"storage_template_option_none_description": "Διατήρηση της δομής φακέλων όπως την χρησιμοποιεί το Immich εξ'ορισμού",
"storage_template_path_length": "Όριο μήκους διαδρομής: <b>{length, number}</b>/{limit, number}, κατά προσέγγιση",
"storage_template_settings": "Πρότυπο αποθήκευσης",
"storage_template_settings_description": "Διαχείριση της δομής φακέλου και του ονόματος, του ανεβασμένου αρχείου",
"storage_template_user_label": "<code>{label}</code> είναι η Ετικέτα Αποθήκευσης του χρήστη",
"storage_template_variables": "Πρότυπες μεταβλητές",
"system_settings": "Ρυθμίσεις Συστήματος",
"tag_cleanup_job": "Καθαρισμός ετικετών",
"template_email_available_tags": "Μπορείτε να χρησιμοποιήσετε τις εξής μεταβλητές στο πρότυπό σας: {tags}",
@@ -489,6 +508,7 @@
"admin_password": "Κωδικός πρόσβασης Διαχειριστή",
"administration": "Διαχείριση",
"advanced": "Για προχωρημένους",
"advanced_filters": "Προηγμένα φίλτρα",
"advanced_settings_clear_image_cache": "Καθαρισμός προσωρινής μνήμης εικόνων",
"advanced_settings_clear_image_cache_error": "Αποτυχία καθαρισμού προσωρινής μνήμης εικόνων",
"advanced_settings_clear_image_cache_success": "Επιτυχής εκκαθάριση {size}",
@@ -543,6 +563,7 @@
"allow_public_user_to_download": "Επιτρέψτε σε δημόσιο χρήστη να κατεβάσει",
"allow_public_user_to_upload": "Επιτρέψτε στον δημόσιο χρήστη να ανεβάσει",
"allowed": "Επιτρεπόμενο",
"also_known_as": "γνωστό και ως",
"alt_text_qr_code": "Εικόνα κωδικού QR",
"always_keep": "Διατήρηση πάντα",
"always_keep_photos_hint": "Η «Απελευθέρωση χώρου» θα κρατήσει όλες τις φωτογραφίες σε αυτήν τη συσκευή.",
@@ -655,6 +676,7 @@
"biometric_no_options": "Δεν υπάρχουν διαθέσιμοι τρόποι βιομετρικής ταυτοποίησης",
"biometric_not_available": "Δεν υπάρχει διαθέσιμη βιομετρική ταυτοποίηση σε αυτή τη συσκευή",
"birthdate_set_description": "Η ημερομηνία γέννησης χρησιμοποιείται για τον υπολογισμό της ηλικίας αυτού του ατόμου, τη χρονική στιγμή μιας φωτογραφίας.",
"birthday_memory_title": "Του/Της {name} τα γενέθλια",
"blurred_background": "Θολό φόντο",
"browse_templates": "Περιήγηση σε πρότυπα",
"bugs_and_feature_requests": "Σφάλματα & Αιτήματα Λειτουργιών",
@@ -719,6 +741,8 @@
"clear_all_recent_searches": "Εκκαθάριση όλων των πρόσφατων αναζητήσεων",
"clear_failed_count": "Αποτυχία εκκαθάρισης ({count})",
"clear_file_cache": "Εκκαθάριση της Προσωρινής Μνήμης Αρχείων",
"clear_file_cache_error": "Αποτυχία διαγραφής προσωρινής μνήμης αρχείων",
"clear_file_cache_success": "Έγινε εκκαθάριση της προσωρινής μνήμης αρχείων",
"clear_message": "Εκκαθάριση μηνύματος",
"clear_value": "Εκκαθάριση τιμής",
"client_cert_import": "Εισαγωγή",
@@ -730,6 +754,9 @@
"client_cert_subtitle": "Υποστηρίζει μόνο τη μορφή PKCS12 (.p12, .pfx). Η εισαγωγή/αφαίρεση πιστοποιητικού είναι διαθέσιμη μόνο πριν από τη σύνδεση",
"client_cert_title": "Πιστοποιητικό SSL πελάτη [ΠΕΙΡΑΜΑΤΙΚΟ]",
"close": "Κλείσιμο",
"cluster_group_description": "Τα άτομα αναγνωρίζονται στις φωτογραφίες όλων των μελών του γκρουπ",
"cluster_group_facial_recognition": "Επαναφορά αναγνώρισης προσώπου",
"cluster_group_facial_recognition_prompt": "Επαναφέροντας την αναγνώριση προσώπου θα διαγραφούν όλα τα άτομα για όλους τους χρήστες σε αυτό το γκρουπ. Θα χαθούν και τα ήδη ρυθμισμένα ονόματα και ημερομηνίες γέννησης. Είστε σίγουρος ότι θέλετε να συνεχίσετε?",
"collapse": "Σύμπτυξη",
"collapse_all": "Σύμπτυξη όλων",
"color": "Χρώμα",
@@ -1204,7 +1231,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Βίντεο} other {Εικόνα}} τραβηγμένο στην/στον {city}, {country} με τον/την {person1}, τον/την {person2} και τον/την {person3} στις {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Βίντεο} other {Εικόνα}} τραβηγμένο στον/στην {city}, {country} με τον/την {person1}, {person2}, και {additionalCount, number} άλλους στις {date}",
"immich_logo": "Λογότυπο Immich",
"immich_web_interface": "Ιστότοπος Immich",
"import_from_json": "Εισαγωγή από αρχείο JSON",
"import_path": "Εισαγωγή διαδρομής",
"in_albums": "Μέσα σε {count, plural, one {# άλμπουμ} other {# άλμπουμ}}",
@@ -1908,8 +1934,6 @@
"setting_image_viewer_help": "Το πρόγραμμα προβολής λεπτομερειών φορτώνει πρώτα τη μικρογραφία, στη συνέχεια φορτώνει την προεπισκόπηση μεσαίου μεγέθους (αν είναι ενεργοποιημένη), τέλος φορτώνει το πρωτότυπο (αν είναι ενεργοποιημένο).",
"setting_image_viewer_original_subtitle": "Ενεργοποιήστε τη φόρτωση της πρωτότυπης εικόνας πλήρους ανάλυσης (μεγάλη!). Απενεργοποιήστε για να μειώσετε τη χρήση δεδομένων (τόσο στο δίκτυο όσο και στην κρυφή μνήμη της συσκευής).",
"setting_image_viewer_original_title": "Φόρτωση πρωτότυπης εικόνας",
"setting_image_viewer_preview_subtitle": "Ενεργοποιήστε τη φόρτωση μιας εικόνας μέσης ανάλυσης. Απενεργοποιήστε είτε για να φορτώνεται απευθείας το πρωτότυπο είτε για να χρησιμοποιείται μόνο η μικρογραφία.",
"setting_image_viewer_preview_title": "Φόρτωση εικόνας προεπισκόπησης",
"setting_languages_apply": "Εφαρμογή",
"setting_languages_subtitle": "Αλλάξτε τη γλώσσα της εφαρμογής",
"setting_notifications_notify_minutes": "{count} λεπτά",
@@ -1975,7 +1999,6 @@
"shared_link_password_description": "Απαίτηση κωδικού για πρόσβαση στον κοινοποιημένο σύνδεσμο",
"shared_links": "Κοινόχρηστοι σύνδεσμοι",
"shared_links_description": "Μοιραστείτε φωτογραφίες και βίντεο με σύνδεσμο",
"shared_photos_and_videos_count": "{assetCount, plural, other {# κοινόχρηστες φωτογραφίες & βίντεο.}}",
"shared_with_me": "Μοιρασμένα μαζί μου",
"shared_with_partner": "Σε κοινή χρήση με {partner}",
"sharing": "Κοινοποίηση",
+47 -5
View File
@@ -62,6 +62,12 @@
"backup_onboarding_title": "Backups",
"backup_settings": "Database Dump Settings",
"backup_settings_description": "Manage database dump settings.",
"backups": "Backups",
"backups_attempts": "Backup attempts",
"backups_hide_reminder": "Hide backups reminder",
"backups_hide_reminder_description": "If you already manage backups outside of Immich, you can hide the backup setup reminder.",
"backups_settings": "Backup settings",
"backups_snapshots": "Snapshots",
"cleared_jobs": "Cleared jobs for: {job}",
"config_set_by_file": "Config is currently set by a config file",
"confirm_delete_library": "Are you sure you want to delete {library} library?",
@@ -1023,6 +1029,7 @@
"failed_to_keep_this_delete_others": "Failed to keep this asset and delete the other assets",
"failed_to_load_asset": "Failed to load asset",
"failed_to_load_assets": "Failed to load assets",
"failed_to_load_database_backups": "Failed to load database backups",
"failed_to_load_notifications": "Failed to load notifications",
"failed_to_load_people": "Failed to load people",
"failed_to_remove_product_key": "Failed to remove product key",
@@ -1073,7 +1080,6 @@
"unable_to_empty_trash": "Unable to empty trash",
"unable_to_enter_fullscreen": "Unable to enter fullscreen",
"unable_to_exit_fullscreen": "Unable to exit fullscreen",
"unable_to_get_comments_number": "Unable to get number of comments",
"unable_to_get_shared_link": "Failed to get shared link",
"unable_to_hide_person": "Unable to hide person",
"unable_to_leave_cluster_group": "Unable to leave cluster group",
@@ -1168,6 +1174,31 @@
"file_name_or_extension": "File name or extension",
"file_name_text": "File name",
"file_size": "File size",
"file_vault": {
"conflict": "This name already exists. Rename the item or choose another folder.",
"description": "Personal documents and work folders",
"drop": "Drop files or folders here",
"empty": "This folder is empty",
"empty_trash": "Trash is empty",
"error": "The action failed. Check your connection and try again.",
"folder_name": "Folder name",
"invalid": "Check the name and destination folder. Names cannot contain slash characters.",
"limit": "Maximum file size",
"move_to": "Move to folder",
"new_folder": "New folder",
"private": "Only you can access these files.",
"purge_confirm": "Delete permanently? This item and its contents cannot be restored.",
"quota": "Insufficient storage space or the maximum file size was exceeded.",
"root": "My files",
"selected": "Files selected",
"title": "Files",
"trash_confirm": "Move to trash? The folder and its contents will be moved together.",
"unavailable": "This file or folder is unavailable. Refresh the list.",
"upload_files": "Upload files",
"upload_folder": "Upload folder",
"uploading": "Uploading",
"used": "Used"
},
"filename": "Filename",
"filetype": "Filetype",
"filter": "Filter",
@@ -1311,10 +1342,6 @@
"library": "Library",
"library_add_folder": "Add folder",
"library_edit_folder": "Edit folder",
"library_page_sort_asset_count": "Number of assets",
"library_page_sort_created": "Created date",
"library_page_sort_last_modified": "Last modified",
"library_page_sort_title": "Album title",
"licenses": "Licenses",
"light": "Light",
"light_theme": "Switch to light theme",
@@ -1401,10 +1428,12 @@
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
"main_menu": "Main menu",
"maintenance_action_restore": "Restoring Database",
"maintenance_action_rollback": "Restoring from Backup",
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
"maintenance_end": "End maintenance mode",
"maintenance_logged_in_as": "Currently logged in as {user}",
"maintenance_restore_from_backup": "Restore From Backup",
"maintenance_restore_latest_backup_description": "We'll restore your database from the most recent backup. You can also pick a different one.",
"maintenance_restore_library": "Restore Your Library",
"maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!",
"maintenance_restore_library_description": "Restoring Database",
@@ -1417,6 +1446,13 @@
"maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings",
"maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files",
"maintenance_restore_library_loading": "Loading integrity checks and heuristics…",
"maintenance_restore_loading_backups": "Loading backups…",
"maintenance_restore_no_backups": "There are no database backups.",
"maintenance_restore_select_another": "Select another backup",
"maintenance_restore_source_database_backup": "Database Backup",
"maintenance_restore_source_futo_backups": "FUTO Backups",
"maintenance_restore_source_title": "Where would you like to restore from?",
"maintenance_restore_upload_backup": "Upload a backup",
"maintenance_task_backup": "Creating a backup of the existing database…",
"maintenance_task_migrations": "Running database migrations…",
"maintenance_task_restore": "Restoring the chosen backup…",
@@ -1672,8 +1708,11 @@
"partner_sharing": "Partner Sharing",
"partners": "Partners",
"partners_description": "Share your photos and videos with other users",
"passkey_added": "Passkey added",
"passkeys": "{count, plural, one {Passkey} other {Passkeys}}",
"passkeys_not_supported": "Passkeys are not supported by this browser",
"passkeys_on_server": "Passkeys registered with the Immich server",
"passkeys_on_server_description": "Note: Removing a passkey from the Immich server does not remove it from your device",
"password": "Password",
"password_does_not_match": "Password does not match",
"password_required": "Password Required",
@@ -1789,6 +1828,8 @@
"purchase_panel_title": "Support the project",
"purchase_per_server": "Per server",
"purchase_per_user": "Per user",
"purchase_product_key_description": "Product keys support Immich development and give you a supporter badge.",
"purchase_product_key_excludes_backups": "Product keys do not include FUTO Backups.",
"purchase_remove_product_key": "Remove Product Key",
"purchase_remove_product_key_prompt": "Are you sure you want to remove the product key?",
"purchase_remove_server_product_key": "Remove Server product key",
@@ -2191,6 +2232,7 @@
"sunrise_on_the_beach": "Sunrise on the beach",
"support": "Support",
"support_and_feedback": "Support & Feedback",
"support_ensure_latest": "Before reaching out through official Immich support channels, please ensure you are using the <link>latest version</link> of Immich.",
"support_third_party_description": "Your Immich installation was packaged by a third-party. Issues you experience may be caused by that package, so please raise issues with them in the first instance using the links below.",
"supporter": "Supporter",
"swap_merge_direction": "Swap merge direction",
+6 -4
View File
@@ -368,6 +368,12 @@
"storage_template_migration_info": "The storage template will convert all extensions to lowercase. Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the <link>{job}</link>.",
"storage_template_migration_job": "Storage Template Migration Job",
"storage_template_more_details": "For more details about this feature, refer to the <template-link>Storage Template</template-link> and its <implications-link>implications</implications-link>",
"storage_template_onboarding_description_v3": "Storage templates allow you to define a custom folder structure on disk for your uploaded assets. This can always be changed later.",
"storage_template_option_custom": "Custom",
"storage_template_option_custom_description": "Build your own folder structure with template variables",
"storage_template_option_date_description": "Sort files into year, month, and day folders",
"storage_template_option_none": "No template",
"storage_template_option_none_description": "Keep the folder structure Immich uses by default",
"storage_template_path_length": "Approximate path length limit: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "Storage Template",
"storage_template_settings_description": "Manage the folder structure and file name of the upload asset",
@@ -1224,7 +1230,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1}, {person2}, and {person3} on {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1}, {person2}, and {additionalCount, number} others on {date}",
"immich_logo": "Immich Logo",
"immich_web_interface": "Immich Web Interface",
"import_from_json": "Import from JSON",
"import_path": "Import path",
"in_albums": "In {count, plural, one {# album} other {# albums}}",
@@ -1958,8 +1963,6 @@
"setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
"setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).",
"setting_image_viewer_original_title": "Load original image",
"setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.",
"setting_image_viewer_preview_title": "Load preview image",
"setting_languages_apply": "Apply",
"setting_languages_subtitle": "Change the app's language",
"setting_notifications_notify_minutes": "{count} minutes",
@@ -2025,7 +2028,6 @@
"shared_link_password_description": "Require a password to access this shared link",
"shared_links": "Shared links",
"shared_links_description": "Share photos and videos with a link",
"shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",
"shared_with_me": "Shared with me",
"shared_with_partner": "Shared with {partner}",
"sharing": "Sharing",
+10 -5
View File
@@ -1238,7 +1238,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} farita en {city}, {country} kun {person1}, {person2} kaj {person3} je {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} farita en {city}, {country} kun {person1}, {person2}, kaj {additionalCount, number} aliaj homoj je {date}",
"immich_logo": "Bildsimbolo de Immich",
"immich_web_interface": "Interfaco de Immich",
"import_from_json": "Importi el JSON-dosiero",
"import_path": "Vojo de importado",
"in_albums": "En {count, plural, one {# albumo} other {# albumoj}}",
@@ -1669,6 +1668,7 @@
"person_birthdate": "Naskiĝis je {date}",
"person_hidden": "{name}{hidden, select, true { (kaŝita)} other {}}",
"person_recognized": "Homo rekonita",
"person_update_strategy_self": "Nur mi",
"photo_shared_all_users": "Ŝajne vi dividis viajn fotojn kun ĉiuj uzantoj, aŭ povus esti ke ne ekzistas aliaj uzantoj.",
"photos": "Fotoj",
"photos_and_videos": "Fotoj kaj videoj",
@@ -1871,6 +1871,9 @@
"search_filter_camera_title": "Elektu tipon de fotilo",
"search_filter_date": "Dato",
"search_filter_date_interval": "de {start} ĝis {end}",
"search_filter_date_last_30_days": "Lastaj 30 tagoj",
"search_filter_date_last_year": "Pasintjare",
"search_filter_date_this_year": "Ĉi-jare",
"search_filter_date_title": "Elektu intervalon de datoj",
"search_filter_display_option_not_in_album": "Ne en albumo",
"search_filter_display_options": "Opcioj pri aranĝo sur ekrano",
@@ -1952,8 +1955,6 @@
"setting_image_viewer_help": "La detala montrilo unue montras la malgrandan bildeton, poste ŝargas la mezgrandan antaŭvidon (se ŝaltita), kaj fine ŝargas la originalan (se ŝaltita).",
"setting_image_viewer_original_subtitle": "Ŝalti por ŝargi la originalan bildon (povas esti tre granda!) Malŝalti por ŝpareme uzi datumojn (de retkonekto kaj memoro en via aparato).",
"setting_image_viewer_original_title": "Ŝargi originalan bildon",
"setting_image_viewer_preview_subtitle": "Ŝalti por ŝargi mez-distingivan bildon. Malŝalti por ŝargi aŭ la originalon aŭ la bildeton.",
"setting_image_viewer_preview_title": "Ŝargi antaŭvidan bildon",
"setting_languages_apply": "Validigi",
"setting_languages_subtitle": "Ŝanĝi lingvon de la apo",
"setting_notifications_notify_minutes": "{count} minutoj",
@@ -2017,7 +2018,6 @@
"shared_link_password_description": "Devigi uzon de pasvorto por uzi la ligilon",
"shared_links": "Dividitaj ligiloj",
"shared_links_description": "Dividi fotojn kaj videojn per ligilo",
"shared_photos_and_videos_count": "{assetCount, plural, other {# dividitaj fotoj kaj videoj.}}",
"shared_with_me": "Dividitaj kun mi",
"shared_with_partner": "Dividitaj kun {partner}",
"sharing": "Dividado",
@@ -2098,6 +2098,7 @@
"stop_photo_sharing": "Ĉu ĉesi dividi viajn fotojn?",
"stop_photo_sharing_description": "{partner} ne plu rajtos atingi viajn fotojn.",
"stop_sharing_photos_with_user": "Ĉesi dividi fotojn kun tiu ĉi uzanto",
"sync": "Sinkronigi",
"to_favorite": "Aldoni al preferataĵoj",
"unfavorite": "Forigi el preferataĵoj",
"unfavorite_action_prompt": "{count} forigita(j) el Preferataĵoj",
@@ -2110,6 +2111,9 @@
"waiting": "En atendovico",
"waiting_count": "En atendovico: {count}",
"week": "Semajno",
"welcome": "Bonvenon",
"welcome_to_immich": "Bonvenon al Immich",
"whats_new": "Kio estas nova",
"wifi_name": "Nomo de Vifireto",
"workflow_delete_prompt": "Ĉu vi certas, ke vi volas forigi tiun ĉi laborfluon?",
"workflow_deleted": "Laborfluo forigita",
@@ -2125,5 +2129,6 @@
"workflows_help_text": "Laborfluo aŭtomatigas agojn pri elementoj, laŭ ekigiloj kaj filtriloj",
"year": "Jaro",
"yes": "Jes",
"you_dont_have_any_shared_links": "Vi ne havas ajnan dividitan ligilon"
"you_dont_have_any_shared_links": "Vi ne havas ajnan dividitan ligilon",
"your_wifi_name": "Via Vifio-nomo"
}
+29 -13
View File
@@ -66,7 +66,7 @@
"confirm_delete_library": "¿Estás seguro de que quieres eliminar la biblioteca {library}?",
"confirm_delete_library_assets": "¿Estás seguro de que quieras eliminar esta biblioteca? Esto eliminará {count, plural, one {# recurso contenido} other {todos # recursos contenidos}} de Immich y no puede deshacerse. Los archivos permanecerán en disco.",
"confirm_email_below": "Para confirmar, escribe \"{email}\" a continuación",
"confirm_reprocess_all_faces": "¿Estás seguro de que deseas reprocesar todas las caras? Esto borrará a todas las personas que nombraste.",
"confirm_reprocess_all_faces": "¿Estás seguro de que deseas volver a procesar todos los rostros? Esto borrará a todas las personas que etiquetaste.",
"confirm_user_password_reset": "¿Estás seguro de que quieres restablecer la contraseña de {user}?",
"confirm_user_pin_code_reset": "¿Seguro que quieres restablecer el PIN de {user}?",
"copy_config_to_clipboard_description": "Copiar la configuración actual del sistema como un objeto JSON al",
@@ -80,13 +80,13 @@
"exclusion_pattern_description": "Los patrones de exclusión te permiten ignorar archivos y carpetas al escanear tu biblioteca. Es útil si tienes carpetas que contienen archivos que no deseas importar, por ejemplo archivos RAW.",
"export_config_as_json_description": "Descargar la configuración actual del sistema como un archivo JSON",
"external_libraries_page_description": "Página de biblioteca externa del administrador",
"face_detection": "Detección de caras",
"face_detection": "Detección de rostros",
"face_detection_description": "Detecta las caras en los recursos mediante aprendizaje automático. En el caso de los vídeos, solo se tiene en cuenta la miniatura. \"Actualizar\" (re)procesará todos los recursos. \"Restablecer\" borra además todos los datos de caras actuales. \"Faltante\" pone en cola los recursos que aún no se han procesado. Las caras detectadas se pondrán en cola para el reconocimiento facial una vez finalizada la detección, agrupándolos en personas existentes o nuevas.",
"facial_recognition_job_description": "Agrupa las caras detectadas en personas. Este paso se realiza después de completar la detección de caras. \"Restablecer\" (re)agrupa todas las caras. \"Faltante\" pone en cola las caras que no tienen una persona asignada.",
"facial_recognition_job_description": "Agrupa los rostros detectados. Este paso se realiza después de completar la detección de rostros. \"Restablecer\" (re)agrupa todos los rostros. \"Faltante\" pone en cola los rostros que no tienen una persona asignada.",
"failed_job_command": "El comando {command} ha fallado para la tarea: {job}",
"force_delete_user_warning": "CUIDADO: Esta acción eliminará inmediatamente el usuario y todos los recursos. Esta acción no se puede deshacer y los archivos no pueden ser recuperados.",
"image_format": "Formato",
"image_format_description": "WebP genera archivos más pequeños que JPEG, pero es más lento al codificarlos.",
"image_format_description": "WebP genera archivos más pequeños que JPEG, pero es más lento al codificarlos. JPEG XL es la mejor opción para obtener una compresión de alta calidad, pero no todos los navegadores y aplicaciones pueden mostrarlo.",
"image_fullsize_description": "Imagen de tamaño completo con metadatos removidos, usado cuando se hace zoom",
"image_fullsize_enabled": "Activar generación de imágenes a tamaño completo",
"image_fullsize_enabled_description": "Generar imágenes a tamaño completo para formatos no aptos para web. Cuando \"Preferir vista previa incrustada\" está activada, las vistas previas incrustadas se utilizan directamente sin conversión. No afecta a los formatos aptos para la web, como JPEG.",
@@ -100,7 +100,7 @@
"image_preview_quality_description": "Calidad de vista previa de 1 a 100. Es mejor cuanto más alta sea la calidad pero genera archivos más grandes y puede reducir la capacidad de respuesta de la aplicación. Establecer un valor bajo puede afectar la calidad del aprendizaje automático.",
"image_preview_title": "Ajustes de las vistas previas",
"image_progressive": "Progressivo",
"image_progressive_description": "Codifica imágenes JPEG progresivamente para una visualización con carga gradual. Esto no afecta a las imágenes WebP.",
"image_progressive_description": "Codifica imágenes JPEG progresivamente para una visualización con carga gradual. Esto no afecta a otros formatos.",
"image_quality": "Calidad",
"image_resolution": "Resolución",
"image_resolution_description": "Las resoluciones más altas pueden conservar más detalles pero requieren más tiempo para codificar, tienen tamaños de archivo más grandes y pueden afectar la capacidad de respuesta de la aplicación.",
@@ -323,6 +323,13 @@
"oauth_timeout": "Tiempo de espera de la solicitud agotado",
"oauth_timeout_description": "Tiempo de espera de solicitudes en milisegundos",
"ocr_job_description": "Usar aprendizaje automático para reconocer texto en imágenes",
"passkey_additional_domains": "Dominios adicionales",
"passkey_additional_domains_description": "Dominios de confianza adicionales (related origin requests). Estos solo servirán si el dominio de llaves de acceso tambien es accesible.",
"passkey_domain": "Dominio de llaves de acceso",
"passkey_domain_description": "Dominio usado para las llaves de acceso (relying party). Dejar en blanco para deducirlo en base al cabezal origin. Las llaves de acceso existentes dejaran de funcionar si se cambia este valor.",
"passkey_enable_description": "Inicia sesión con una llave de acceso",
"passkey_settings": "Login con llave de acceso",
"passkey_settings_description": "Administrar configuración de inicio de sesión con llaves de acceso",
"password_enable_description": "Iniciar sesión con correo electrónico y contraseña",
"password_settings": "Contraseña de inicio de sesión",
"password_settings_description": "Administrar la configuración de inicio de sesión con contraseña",
@@ -873,6 +880,8 @@
"delete_local_dialog_ok_backed_up_only": "Borrar solo las que tengan copia de seguridad",
"delete_local_dialog_ok_force": "Borrar de todos modos",
"delete_others": "Eliminar otros",
"delete_passkey": "Borrar llave de acceso",
"delete_passkey_prompt": "¿Esta seguro que desea borrar esta llave de acceso?",
"delete_permanently": "Eliminar permanentemente",
"delete_permanently_action_prompt": "{count} eliminados permanentemente",
"delete_shared_link": "Eliminar enlace compartido",
@@ -942,7 +951,8 @@
"edit_location_action_prompt": "{count} ubicaciones actualizadas",
"edit_location_dialog_title": "Ubicación",
"edit_name": "Cambiar nombre",
"edit_people": "Editar persona",
"edit_passkey": "Editar llave de acceso",
"edit_people": "Editar personas",
"edit_person": "Editar persona",
"edit_tag": "Editar etiqueta",
"edit_title": "Editar Titulo",
@@ -1239,7 +1249,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} tomada en {city}, {country} con {person1}, {person2}, y {person3} el {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} tomada en {city}, {country} con {person1}, {person2}, y {additionalCount, number} más el {date}",
"immich_logo": "Logo de Immich",
"immich_web_interface": "Interfaz web de Immich",
"import_from_json": "Importar desde JSON",
"import_path": "Importar ruta",
"in_albums": "En {count, plural, one {# álbum} other {# álbumes}}",
@@ -1287,6 +1296,7 @@
"last": "Último",
"last_months": "{count, plural, one {Último mes} other {Últimos # meses}}",
"last_seen": "Ultima vez visto",
"last_used": "Última vez usado",
"latest_version": "Última versión",
"latitude": "Latitud",
"leave": "Abandonar",
@@ -1362,6 +1372,7 @@
"login_has_been_disabled": "El inicio de sesión ha sido deshabilitado",
"login_password_changed_error": "Hubo un error actualizando la contraseña",
"login_password_changed_success": "Contraseña cambiado con éxito",
"login_with_passkey": "Iniciar sesión con llave de acceso",
"logout_all_device_confirmation": "¿Estás seguro de que quieres cerrar sesión en todos los dispositivos?",
"logout_this_device_confirmation": "¿Estás seguro de que quieres cerrar sesión en este dispositivo?",
"logs": "Registros",
@@ -1410,6 +1421,7 @@
"manage_your_api_keys": "Administre sus claves API",
"manage_your_devices": "Administre sus dispositivos conectados",
"manage_your_oauth_connection": "Administra tu conexión OAuth",
"manage_your_passkeys": "Administrar llaves de acceso",
"map": "Mapa",
"map_assets_in_bounds": "{count, plural, =0 {No hay fotos en esta área} one {# foto} other {# fotos}}",
"map_cannot_get_user_location": "No se pudo obtener la posición del usuario",
@@ -1529,6 +1541,7 @@
"new_album": "Nuevo álbum",
"new_api_key": "Nueva clave API",
"new_feature": "Nueva característica",
"new_passkey": "Nueva llave de acceso",
"new_password": "Nueva contraseña",
"new_person": "Nueva persona",
"new_pin_code": "Nuevo PIN",
@@ -1641,6 +1654,7 @@
"partner_sharing": "Compartir con compañeros",
"partners": "Miembros",
"partners_description": "Comparte tus fotos y vídeos con otros usuarios",
"passkeys_not_supported": "Este navegador no admite llaves de acceso",
"password": "Contraseña",
"password_does_not_match": "Las contraseñas no coinciden",
"password_required": "Contraseña requerida",
@@ -1825,6 +1839,7 @@
"removed_from_favorites_count": "{count, plural, other {Eliminados #}} de favoritos",
"removed_from_stack": "Recurso eliminado de la pila",
"removed_memory": "Recuerdo eliminado",
"removed_passkey": "Llave de acceso eliminada: {name}",
"removed_tagged_assets": "Etiqueta eliminada de {count, plural, one {# recurso} other {# recursos}}",
"rename": "Renombrar",
"repository": "Repositorio",
@@ -1986,11 +2001,9 @@
"setting_image_navigation_enable_subtitle": "Si está habilitado, puedes navegar a la imagen anterior/siguiente tocando una cuarta parte más a la izquierda/derecha de la pantalla.",
"setting_image_navigation_enable_title": "Toca para navegar",
"setting_image_navigation_title": "Navegación de imágenes",
"setting_image_viewer_help": "El visor de detalles carga primero la miniatura pequeña, luego carga la vista previa de tamaño mediano (si está habilitada), finalmente carga la original (si está habilitada).",
"setting_image_viewer_help": "El visor de detalles carga primero la miniatura pequeña, luego carga la vista previa de tamaño mediano, finalmente carga la original (si está habilitada).",
"setting_image_viewer_original_subtitle": "Activar para cargar la imagen en resolución original (¡muy grande!). Deshabilitar para reducir el consumo de datos (de red y caché).",
"setting_image_viewer_original_title": "Cargar imagen original",
"setting_image_viewer_preview_subtitle": "Activar para cargar una imagen de resolución media. Deshabilitar para cargar directamente la imagen original o usar una miniatura.",
"setting_image_viewer_preview_title": "Cargar imagen de previsualización",
"setting_languages_apply": "Aplicar",
"setting_languages_subtitle": "Cambia el idioma de la aplicación",
"setting_notifications_notify_minutes": "{count} minutos",
@@ -2010,6 +2023,7 @@
"share_everyone": "Compartir con todos",
"share_everyone_description": "Otorga a {user} acceso a todas tus personas.",
"share_link": "Compartir enlace",
"share_logs": "Compartir registros",
"share_original": "Usar original (grande)",
"share_preview": "Usar vista previa (pequeño)",
"share_quality_body": "Mantén pulsado el botón de compartir para elegir la calidad de la imagen antes de compartirla.",
@@ -2061,7 +2075,6 @@
"shared_link_password_description": "Requerir una contraseña para acceder a este enlace compartido",
"shared_links": "Enlaces compartidos",
"shared_links_description": "Comparte fotos y vídeos con un enlace",
"shared_photos_and_videos_count": "{assetCount, plural, other {# fotos y vídeos compartidos.}}",
"shared_with": "Compartido con",
"shared_with_count": "Compartido ({count})",
"shared_with_me": "Compartidos conmigo",
@@ -2189,6 +2202,8 @@
"theme": "Tema",
"theme_selection": "Selección de tema",
"theme_selection_description": "Establece el tema automáticamente como \"claro\" u \"oscuro\" según las preferencias del sistema/navegador",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Desplazar continuamente al mover la barra de desplazamiento en lugar de saltar de mes en mes",
"theme_setting_asset_list_smooth_scrubbing_title": "Desplazamiento suave de la linea de tiempo",
"theme_setting_asset_list_storage_indicator_title": "Mostrar indicador de almacenamiento en las miniaturas de los recursos",
"theme_setting_asset_list_tiles_per_row_title": "Número de recursos por fila ({count})",
"theme_setting_colorful_interface_subtitle": "Aplicar el color primario a las superficies de fondo.",
@@ -2207,7 +2222,7 @@
"timezone": "Zona horaria",
"to_archive": "Archivar",
"to_change_password": "Cambiar contraseña",
"to_favorite": "A los favoritos",
"to_favorite": "Añadir a favoritos",
"to_login": "Iniciar sesión",
"to_multi_select": "para multi selección",
"to_parent": "Ir a los padres",
@@ -2304,7 +2319,7 @@
"user": "Usuario",
"user_has_been_deleted": "Este usuario ha sido eliminado.",
"user_id": "Id. de usuario",
"user_liked": "{user} le gustó {type, select, photo {esta foto} video {este vídeo} asset {este recurso} other {esto}}",
"user_liked": "{user} le gustó {type, select, image {esta foto} video {este vídeo} other {esto}}",
"user_pin_code_settings": "PIN",
"user_pin_code_settings_description": "Gestione su PIN",
"user_privacy": "Privacidad del usuario",
@@ -2340,6 +2355,7 @@
"view_group": "Ver grupo",
"view_in_timeline": "Ver en la línea de tiempo",
"view_link": "Ver enlace",
"view_more": "Ver más",
"view_name": "Ver",
"view_next_asset": "Mostrar siguiente recurso",
"view_previous_asset": "Mostrar recurso anterior",
+27 -9
View File
@@ -86,7 +86,7 @@
"failed_job_command": "Käsk {command} ebaõnnestus töötes: {job}",
"force_delete_user_warning": "HOIATUS: See kustutab koheselt kasutaja ja kõik tema üksused. Tegevust ei saa tagasi võtta ja faile ei saa taastada.",
"image_format": "Vorming",
"image_format_description": "WebP failid on väiksemad kui JPEG, aga kodeerimine on aeglasem.",
"image_format_description": "WebP failid on väiksemad kui JPEG, aga kodeerimine on aeglasem. JPEG XL on parim valik kõrge kvaliteedi jaoks, aga kõik brauserid ja rakendused ei suuda seda kuvada.",
"image_fullsize_description": "Täismõõdus pilt ilma metaandmeteta, kasutatakse sisse suumimisel",
"image_fullsize_enabled": "Luba täismõõdus piltide genereerimine",
"image_fullsize_enabled_description": "Genereeri mitte-veebisõbralike vormingute jaoks täismõõdus pilt. Kui \"Eelista manustatud eelvaadet\" on lubatud, kasutatakse manustatud eelvaateid otse ilma teisendamiseta. Ei mõjuta veebisõbralikke vorminguid nagu JPEG.",
@@ -100,7 +100,7 @@
"image_preview_quality_description": "Eelvaate kvaliteet vahemikus 1-100. Kõrgem väärtus on parem, aga tekitab suuremaid faile ning võib mõjutada rakenduse töökiirust. Madal väärtus võib mõjutada masinõppe kvaliteeti.",
"image_preview_title": "Eelvaate seaded",
"image_progressive": "Progressiivne",
"image_progressive_description": "Kodeeri JPEG-pildid järk-järguliseks laadimiseks. See ei mõjuta WebP-pilte.",
"image_progressive_description": "Kodeeri JPEG-pildid järk-järguliseks laadimiseks. See ei mõjuta teisi formaate.",
"image_quality": "Kvaliteet",
"image_resolution": "Resolutsioon",
"image_resolution_description": "Kõrgemad resolutsioonid säilitavad rohkem detaile, aga kodeerimine võtab kauem aega, tekitab suuremaid faile ning võib mõjutada rakenduse töökiirust.",
@@ -323,6 +323,13 @@
"oauth_timeout": "Päringu ajalõpp",
"oauth_timeout_description": "Päringute ajalõpp millisekundites",
"ocr_job_description": "Kasuta piltidelt teksti tuvastamiseks masinõpet",
"passkey_additional_domains": "Täiendavad domeenid",
"passkey_additional_domains_description": "Täiendavad usaldatud domeenid. Need toimivad ainult siis, kui pääsuvõtme domeen on ka ligipääsetav.",
"passkey_domain": "Pääsuvõtme domeen",
"passkey_domain_description": "Pääsuvõtmete jaoks kasutatav domeen. Jäta tühjaks, et tuletada \"origin\" päisest. Selle väärtuse muutmisel lakkavad olemasolevad pääsuvõtmed töötamast.",
"passkey_enable_description": "Logi sisse pääsuvõtmega",
"passkey_settings": "Pääsuvõtmega sisenemine",
"passkey_settings_description": "Halda pääsuvõtmega sisenemise seadeid",
"password_enable_description": "Logi sisse e-posti aadressi ja parooliga",
"password_settings": "Parooliga sisselogimine",
"password_settings_description": "Halda parooliga sisselogimise seadeid",
@@ -873,6 +880,8 @@
"delete_local_dialog_ok_backed_up_only": "Kustuta ainult varundatud",
"delete_local_dialog_ok_force": "Kustuta sellegipoolest",
"delete_others": "Kustuta teised",
"delete_passkey": "Kustuta pääsuvõti",
"delete_passkey_prompt": "Kas oled kindel, et soovid selle pääsuvõtme kustutada?",
"delete_permanently": "Kustuta jäädavalt",
"delete_permanently_action_prompt": "{count} jäädavalt kustutatud",
"delete_shared_link": "Kustuta jagatud link",
@@ -942,6 +951,7 @@
"edit_location_action_prompt": "{count} asukoht muudetud",
"edit_location_dialog_title": "Asukoht",
"edit_name": "Muuda nime",
"edit_passkey": "Muuda pääsuvõtit",
"edit_people": "Muuda isikuid",
"edit_person": "Muuda isikut",
"edit_tag": "Muuda silti",
@@ -1239,7 +1249,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} kohas {city}, {country} koos isikutega {person1}, {person2} ja {person3}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} kohas {city}, {country} koos {person1}, {person2} ja veel {additionalCount, number} isikuga",
"immich_logo": "Immich'i logo",
"immich_web_interface": "Immich'i veebiliides",
"import_from_json": "Impordi JSON-formaadist",
"import_path": "Imporditee",
"in_albums": "{count, plural, one {# albumis} other {# albumis}}",
@@ -1287,6 +1296,7 @@
"last": "Viimane",
"last_months": "{count, plural, one {Eelmine kuu} other {Eelmised # kuud}}",
"last_seen": "Viimati nähtud",
"last_used": "Viimati kasutatud",
"latest_version": "Uusim versioon",
"latitude": "Laiuskraad",
"leave": "Lahku",
@@ -1362,6 +1372,7 @@
"login_has_been_disabled": "Sisselogimine on keelatud",
"login_password_changed_error": "Parooli muutmisel tekkis viga",
"login_password_changed_success": "Parool edukalt uuendatud",
"login_with_passkey": "Logi sisse pääsuvõtmega",
"logout_all_device_confirmation": "Kas oled kindel, et soovid kõigist seadmetest välja logida?",
"logout_this_device_confirmation": "Kas oled kindel, et soovid sellest seadmest välja logida?",
"logs": "Logid",
@@ -1401,6 +1412,7 @@
"manage_media_access_title": "Üksuste haldamise ligipääs",
"manage_oauth_account": "Halda OAuth kontot",
"manage_people": "Halda isikuid",
"manage_people_access_description_v2": "Anna teistele kasutajatele ligipääs {count, plural, one {sellele isikule} other {oma isikutele}}, lubades neil andmeid vaadata ja sinu nimel hallata. See tegevus ei jaga üksuseid.",
"manage_person_access": "Halda ligipääsu",
"manage_sharing_with_other_users": "Halda teiste kasutajatega jagamist",
"manage_sharing_with_partners": "Halda partneritega jagamist",
@@ -1409,6 +1421,7 @@
"manage_your_api_keys": "Halda oma API võtmeid",
"manage_your_devices": "Halda oma autenditud seadmeid",
"manage_your_oauth_connection": "Halda oma OAuth ühendust",
"manage_your_passkeys": "Halda oma pääsuvõtmeid",
"map": "Kaart",
"map_assets_in_bounds": "{count, plural, =0 {Selles piirkonnas fotosid ei ole} one {# foto} other {# fotot}}",
"map_cannot_get_user_location": "Ei saa kasutaja asukohta tuvastada",
@@ -1528,6 +1541,7 @@
"new_album": "Uus album",
"new_api_key": "Uus API võti",
"new_feature": "Uus funktsionaalsus",
"new_passkey": "Uus pääsuvõti",
"new_password": "Uus parool",
"new_person": "Uus isik",
"new_pin_code": "Uus PIN-kood",
@@ -1640,6 +1654,8 @@
"partner_sharing": "Partneriga jagamine",
"partners": "Partnerid",
"partners_description": "Jaga oma fotosid ja videosid teiste kasutajatega",
"passkeys": "{count, plural, one {Pääsuvõti} other {Pääsuvõtmed}}",
"passkeys_not_supported": "See veebilehitseja ei toeta pääsuvõtmeid",
"password": "Parool",
"password_does_not_match": "Parool ei klapi",
"password_required": "Parool on nõutud",
@@ -1824,6 +1840,7 @@
"removed_from_favorites_count": "{count, plural, other {# eemaldatud}} lemmikutest",
"removed_from_stack": "Üksus virnast eemaldatud",
"removed_memory": "Mäletus eemaldatud",
"removed_passkey": "Eemaldatud pääsuvõti: {name}",
"removed_tagged_assets": "Silt eemaldatud {count, plural, one {# üksuselt} other {# üksuselt}}",
"rename": "Nimeta ümber",
"repository": "Koodihoidla",
@@ -1985,11 +2002,9 @@
"setting_image_navigation_enable_subtitle": "Kui lubatud, saad liikuda eelmise/järgmise pildi juurde ekraani vasakut/paremat serva puudutades.",
"setting_image_navigation_enable_title": "Puudutusega navigeerimine",
"setting_image_navigation_title": "Piltide navigeerimine",
"setting_image_viewer_help": "Detailivaatur laadib kõigepealt väikese pisipildi, seejärel keskmises mõõdus eelvaate (kui lubatud) ja lõpuks algse pildi (kui lubatud).",
"setting_image_viewer_help": "Detailivaatur laadib kõigepealt väikese pisipildi, seejärel keskmises mõõdus eelvaate ning lõpuks algse pildi (kui lubatud).",
"setting_image_viewer_original_subtitle": "Lülita sisse, et laadida algne täisresolutsiooniga pilt (suur!). Lülita välja, et vähendada andmekasutust (nii võrgu kui seadme puhvri).",
"setting_image_viewer_original_title": "Laadi algne pilt",
"setting_image_viewer_preview_subtitle": "Luba keskmise resolutsiooniga pildi laadimine. Keela, et laadida kohe algne pilt või kasutada ainult pisipilti.",
"setting_image_viewer_preview_title": "Laadi pildi eelvaade",
"setting_languages_apply": "Rakenda",
"setting_languages_subtitle": "Muuda rakenduse keelt",
"setting_notifications_notify_minutes": "{count} minutit",
@@ -2009,8 +2024,9 @@
"share_everyone": "Jaga kõik",
"share_everyone_description": "Anna kasutajale {user} ligipääs kõigile oma isikutele.",
"share_link": "Jaga linki",
"share_logs": "Jaga logisid",
"share_original": "Kasuta algset (suur)",
"share_preview": "Kasuta pisipilti (väike)",
"share_preview": "Kasuta eelvaadet (väike)",
"share_quality_body": "Vajuta ja hoia jagamise nuppu, et valida enne jagamist pildifaili kvaliteet.",
"share_quality_title": "Vali jagamise kvaliteet",
"share_with": "Jaga ligipääsu",
@@ -2060,7 +2076,6 @@
"shared_link_password_description": "Nõua jagatud lingile ligi pääsemiseks parooli",
"shared_links": "Jagatud lingid",
"shared_links_description": "Jaga fotosid ja videosid lingiga",
"shared_photos_and_videos_count": "{assetCount, plural, other {# jagatud fotot ja videot.}}",
"shared_with": "Jagatud",
"shared_with_count": "Jagatud ({count})",
"shared_with_me": "Minuga jagatud",
@@ -2188,6 +2203,8 @@
"theme": "Teema",
"theme_selection": "Teema valik",
"theme_selection_description": "Sea automaatselt hele või tume teema vastavalt veebilehitseja eelistustele",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Liigu kerimisriba lohistamisel sujuvalt, mitte kuude kaupa",
"theme_setting_asset_list_smooth_scrubbing_title": "Sujuv ajajoone liigutamine",
"theme_setting_asset_list_storage_indicator_title": "Kuva üksuste ruutudel talletusindikaatorit",
"theme_setting_asset_list_tiles_per_row_title": "Üksuste arv reas ({count})",
"theme_setting_colorful_interface_subtitle": "Rakenda taustapindadele põhivärv.",
@@ -2303,7 +2320,7 @@
"user": "Kasutaja",
"user_has_been_deleted": "See kasutaja on kustutatud.",
"user_id": "Kasutaja ID",
"user_liked": "Kasutajale {user} meeldis {type, select, photo {see foto} video {see video} asset {see üksus} other {see}}",
"user_liked": "Kasutajale {user} meeldis {type, select, image {see foto} video {see video} other {see}}",
"user_pin_code_settings": "PIN-kood",
"user_pin_code_settings_description": "Halda oma PIN-koodi",
"user_privacy": "Kasutaja privaatsus",
@@ -2339,6 +2356,7 @@
"view_group": "Vaata gruppi",
"view_in_timeline": "Vaata ajajoonel",
"view_link": "Vaata linki",
"view_more": "Vaata rohkem",
"view_name": "Vaade",
"view_next_asset": "Vaata järgmist üksust",
"view_previous_asset": "Vaata eelmist üksust",
-4
View File
@@ -1206,7 +1206,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Bideoa} other {Irudia}} {city}-(e)n hartua, {country}-(e)n, {person1}, {person2} eta {person3}-(r)ekin {date}-(e)an",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Bideoa} other {Irudia}} {city}, {country}-(e)n hartua {person1}, {person2} eta beste {additionalCount, number}-(r)ekin hartua {date}-(e)an",
"immich_logo": "Immich-en logoa",
"immich_web_interface": "Immich Web interfazea",
"import_from_json": "JSON-etik inportatu",
"import_path": "Inportatzeko bidea",
"in_albums": "{count, plural, one {Album batean} other {# albumetan}}",
@@ -1910,8 +1909,6 @@
"setting_image_viewer_help": "Xehetasun-ikusleak miniatura kargatzen du lehenik, gero tamaina ertaineko aurrebista kargatzen du (gaituta badago), azkenik jatorrizkoa kargatzen du (gaituta badago).",
"setting_image_viewer_original_subtitle": "Gaitu bereizmen osoko jatorrizko irudia kargatzeko (handia!). Desgaitu datuen erabilera murrizteko (sarean eta gailuaren cachean).",
"setting_image_viewer_original_title": "Jatorrizko irudia kargatu",
"setting_image_viewer_preview_subtitle": "Gaitu bereizmen ertaineko irudiak kargatzeko. Desgaitu jatorrizkoa zuzenean kargatzeko edo miniatura soilik erabiltzeko.",
"setting_image_viewer_preview_title": "Aurrebista irudia kargatu",
"setting_languages_apply": "Ezarri",
"setting_languages_subtitle": "Aplikazioaren hizkuntza aldatu",
"setting_notifications_notify_minutes": "{count, plural, one {Minutu bat} other {# minutu}}",
@@ -1975,7 +1972,6 @@
"shared_link_password_description": "Eskatu pasahitz bat partekatutako esteka honetara sartzeko",
"shared_links": "Partekatutako estekak",
"shared_links_description": "Partekatu argazkiak eta bideoak esteka batekin",
"shared_photos_and_videos_count": "{assetCount, plural, one {Partekatutako argazki eta bideo bat.} other {# partekatutako argazki eta bideo.}}",
"shared_with_me": "Nirekin partekatutakoak",
"shared_with_partner": "{partner}-(r)ekin partekatuta",
"sharing": "Partekatzen",
+9 -4
View File
@@ -323,6 +323,13 @@
"oauth_timeout": "مهلت درخواست",
"oauth_timeout_description": "مهلت درخواست به میلی‌ثانیه",
"ocr_job_description": "استفاده از یادگیری ماشین برای تشخیص متن در تصاویر",
"passkey_additional_domains": "دامنه های بیشتر",
"passkey_additional_domains_description": "این دامنه‌ها فقط زمانی کار می‌کنند که دامنه اصلیِ Passkey هم قابل دسترسی باشد.",
"passkey_domain": "Passkey دامنه‌",
"passkey_domain_description": "دامنه‌ای که برای کلیدهای عبور (Passkey) استفاده می‌شود. اگر این قسمت را خالی بگذارید، دامنه به‌صورت خودکار از اطلاعات درخواست تشخیص داده می‌شود.توجه: با تغییر این دامنه، کلیدهای عبوری که قبلاً ساخته‌اید دیگر کار نخواهند کرد.",
"passkey_enable_description": "ورود با phasskey",
"passkey_settings": "ورود با passkey",
"passkey_settings_description": "تنظیمات مدیریت ورود با passkey",
"password_enable_description": "ورود با ایمیل و گذرواژه",
"password_settings": "ورود با گذرواژه",
"password_settings_description": "مدیریت تنظیمات ورود با گذرواژه",
@@ -368,6 +375,8 @@
"storage_template_migration_info": "الگوی ذخیره‌سازی همهٔ پسوندها را به حروف کوچک تبدیل می‌کند. تغییرات الگو فقط روی دارایی‌های جدید اعمال می‌شود. برای اعمال عطف‌به‌ماسبق الگو روی دارایی‌های قبلاً بارگذاری‌شده، <link>{job}</link> را اجرا کنید.",
"storage_template_migration_job": "وظیفهٔ مهاجرت الگوی ذخیره‌سازی",
"storage_template_more_details": "برای جزئیات بیشتر دربارهٔ این ویژگی، به <template-link>الگوی ذخیره‌سازی</template-link> و <implications-link>پیامدهای</implications-link> آن مراجعه کنید",
"storage_template_onboarding_description_v3": "با استفاده از الگوهای ذخیره‌سازی، می‌توانید مشخص کنید فایل‌های آپلودشده در چه پوشه‌هایی و با چه ساختاری روی دیسک ذخیره شوند. این تنظیمات را هر زمان که بخواهید می‌توانید تغییر دهید.",
"storage_template_option_custom": "سفارشی",
"storage_template_path_length": "حداکثر طول مسیر تقریبی: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "الگوی ذخیره‌سازی",
"storage_template_settings_description": "مدیریت ساختار پوشه و نام فایل دارایی بارگذاری‌شده",
@@ -1224,7 +1233,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {ویدیو} other {تصویر}} گرفته‌شده در {city}، {country} با {person1}، {person2} و {person3} در {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {ویدیو} other {تصویر}} گرفته‌شده در {city}، {country} با {person1}، {person2} و {additionalCount, number} نفر دیگر در {date}",
"immich_logo": "لوگوی Immich",
"immich_web_interface": "رابط وب Immich",
"import_from_json": "واردات از JSON",
"import_path": "مسیر واردات",
"in_albums": "در {count, plural, one {# آلبوم} other {# آلبوم}}",
@@ -1959,8 +1967,6 @@
"setting_image_viewer_help": "نمایشگر جزئیات ابتدا تصویر بندانگشتی کوچک، سپس پیش‌نمایش با اندازهٔ متوسط (در صورت فعال‌بودن) و در نهایت تصویر اصلی (در صورت فعال‌بودن) را بارگذاری می‌کند.",
"setting_image_viewer_original_subtitle": "فعال‌سازی برای بارگذاری تصویر اصلی با وضوح کامل (بسیار بزرگ!). غیرفعال‌سازی برای کاهش مصرف داده (هم شبکه و هم کش دستگاه).",
"setting_image_viewer_original_title": "بارگذاری تصویر اصلی",
"setting_image_viewer_preview_subtitle": "فعال‌سازی برای بارگذاری تصویر با وضوح متوسط. غیرفعال‌سازی برای بارگذاری مستقیم تصویر اصلی یا فقط استفاده از تصویر بندانگشتی.",
"setting_image_viewer_preview_title": "بارگذاری تصویر پیش‌نمایش",
"setting_languages_apply": "اعمال",
"setting_languages_subtitle": "تغییر زبان برنامه",
"setting_notifications_notify_minutes": "{count} دقیقه",
@@ -2026,7 +2032,6 @@
"shared_link_password_description": "برای دسترسی به این پیوند هم‌رسانی، نیاز به گذرواژه است",
"shared_links": "پیوند‌های هم‌رسانی",
"shared_links_description": "هم‌رسانی عکس‌ها و ویدیوها با یک پیوند",
"shared_photos_and_videos_count": "{assetCount, plural, other {# عکس و ویدیوی مشترک.}}",
"shared_with_me": "مشترک با من",
"shared_with_partner": "مشترک با {partner}",
"sharing": "هم‌رسانی",
+12 -5
View File
@@ -193,9 +193,11 @@
"machine_learning_smart_search_enabled": "Ota käyttöön älykäs haku",
"machine_learning_smart_search_enabled_description": "Jos ei käytössä, kuvia ei koodata älykkäälle etsinnälle.",
"machine_learning_url_description": "Koneoppimispalvelimen URL-osoite. Jos lisätään useampi kuin yksi URL-osoite, kutakin osoitetta kohden yritetään kerran, kunnes yksi niistä vastaa. Yritykset tehdään järjestyksessä ensimmäisestä viimeiseen. Palvelimet, jotka eivät vastaa, ohitetaan tilapäisesti, kunnes ne ovat taas tavoitettavissa.",
"maintenance_backup_management": "varmuuskopioiden hallinta",
"maintenance_delete_backup": "Poista varmuuskopio",
"maintenance_delete_backup_description": "Tämä tiedosto poistetaan pysyvästi.",
"maintenance_delete_error": "Varmuuskopion poistaminen epäonnistui.",
"maintenance_integrity_missing_file": "Puuttuvat tiedostot",
"maintenance_restore_backup": "Palauta varmuuskopio",
"maintenance_restore_backup_description": "Immich tyhjennetään ja palautetaan valitusta varmuuskopiosta. Ennen jatkamista luodaan varmuuskopio.",
"maintenance_restore_backup_different_version": "Tämä varmuuskopio luotiin Immichin eri versiolla!",
@@ -270,6 +272,7 @@
"notification_enable_email_notifications": "Ota käyttöön sähköposti-ilmoitukset",
"notification_settings": "Ilmoitusasetukset",
"notification_settings_description": "Hallitse ilmoitusasetuksia, myös sähköpostin",
"oauth_account_management_url": "Käyttäjien hallinnan URL-osoite",
"oauth_allow_insecure_requests": "Salli turvattomat pyynnöt",
"oauth_allow_insecure_requests_description": "VAROITUS: Tämä poistaa TLS-varmenteen tarkistuksen OAuth-pyynnöiltä ja voi altistaa sinut väliintulohyökkäyksille (MITM).",
"oauth_auto_launch": "Automaattinen käynnistys",
@@ -1071,6 +1074,7 @@
"failed": "Epäonnistui",
"failed_count": "Epäonnistuneita: {count}",
"failed_to_authenticate": "Todennus epäonnistui",
"failed_to_delete_file": "Tiedoston poistaminen epäonnistui",
"failed_to_load_assets": "Kohteiden lataus epäonnistui",
"failed_to_load_folder": "Kansion lataaminen epäonnistui",
"favorite": "Suosikki",
@@ -1102,6 +1106,7 @@
"free_up_space_description": "Siirrä varmuuskopioidut valokuvat ja videot laitteen roskakoriin vapauttaaksesi tilaa. Palvelimella olevat kopiosi säilyvät turvassa.",
"free_up_space_settings_subtitle": "Vapauta laitteen tilaa",
"full_path": "Koko polku: {path}",
"full_path_or_folder": "Koko tiedostopolku tai kansio",
"gcast_enabled": "Google Cast",
"gcast_enabled_description": "Ominaisuus lataa ulkoisia kohteita Googlelta toimiakseen.",
"general": "Yleinen",
@@ -1160,7 +1165,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n, {person2}n ja {person3}n kanssa {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n, {person2}n ja {additionalCount, number} muun kanssa {date}",
"immich_logo": "Immich-logo",
"immich_web_interface": "Immich-verkkokäyttöliittymä",
"import_from_json": "Tuo JSON-tiedostosta",
"import_path": "Tuontipolku",
"in_albums": "{count, plural, one {# albumissa} other {# albumissa}}",
@@ -1183,6 +1187,7 @@
"invalid_date_format": "Virheellinen päivämäärämuoto",
"invite_people": "Kutsu ihmisiä",
"invite_to_album": "Kutsu albumiin",
"iso": "ISO",
"items_count": "{count, plural, one {# kpl} other {# kpl}}",
"jobs": "Taustatehtävät",
"keep": "Säilytä",
@@ -1210,6 +1215,7 @@
"latitude": "Leveysaste",
"leave": "Poistu",
"leave_album": "Poistu albumista",
"leave_group": "Poistu ryhmästä",
"lens_model": "Objektiivin malli",
"let_others_respond": "Anna muiden vastata",
"level": "Taso",
@@ -1231,6 +1237,7 @@
"link_to_oauth": "Linkki OAuth",
"linked_oauth_account": "Linkitetty OAuth-tili",
"list": "Lista",
"load_more": "Lataa lisää",
"loading": "Ladataan",
"loading_search_results_failed": "Hakutulosten lataaminen epäonnistui",
"local": "Paikallinen",
@@ -1313,6 +1320,7 @@
"manage_media_access_settings": "Avaa asetukset",
"manage_media_access_subtitle": "Anna Immich-sovellukselle lupa hallinoida ja siirtää mediatiedostoja.",
"manage_media_access_title": "Lupa median hallinnointiin",
"manage_oauth_account": "Hallitse OAuth käyttäjää",
"manage_sharing_with_partners": "Hallitse jakamista kumppaneille",
"manage_the_app_settings": "Hallitse sovelluksen asetuksia",
"manage_your_account": "Hallitse tiliäsi",
@@ -1341,8 +1349,10 @@
"marked_all_as_read": "Merkitty kaikki luetuiksi",
"matches": "Osumia",
"matching_assets": "Vastaava sisältö",
"media": "Media",
"media_chrome": {
"captions": "Tekstitykset",
"captions_off": "Pois",
"disable_captions": "Poista tekstitykset käytöstä",
"enable_captions": "Ota tekstitykset käyttöön",
"enter_fullscreen_mode": "Siirry koko ruudun tilaan",
@@ -1822,11 +1832,9 @@
"setting_image_navigation_enable_subtitle": "Jos tämä toiminto on käytössä, voit siirtyä edelliseen/seuraavaan kuvaan napauttamalla näytön vasenta/oikeaa reunaa.",
"setting_image_navigation_enable_title": "Napauta navigoidaksesi",
"setting_image_navigation_title": "Kuvien selaus",
"setting_image_viewer_help": "Kuvaa katseltaessa ensin ladataan pikkukuva, sitten keskilaatuinen pikkukuva (jos käytössä) ja lopuksi alkuperäinen (jos käytössä).",
"setting_image_viewer_help": "Kuvaa katseltaessa ensin ladataan pikkukuva, sitten keskilaatuinen pikkukuva ja lopuksi alkuperäinen (jos käytössä).",
"setting_image_viewer_original_subtitle": "Ota käyttöön ladataksesi alkuperäinen täysitarkkuuksinen kuva (suuri!). Poista käytöstä vähentääksesi datan käyttöä (sekä verkossa että laitteen välimuistissa).",
"setting_image_viewer_original_title": "Lataa alkuperäinen kuva",
"setting_image_viewer_preview_subtitle": "Ota käyttöön ladataksesi keskitarkkuuksinen kuva. Poista käytöstä ladataksesi alkuperäinen kuva tai käyttääksesi vain esikatselukuvaa.",
"setting_image_viewer_preview_title": "Lataa esikatselukuva",
"setting_languages_apply": "Käytä",
"setting_languages_subtitle": "Vaihda sovelluksen kieli",
"setting_notifications_notify_minutes": "{count} minuuttia",
@@ -1886,7 +1894,6 @@
"shared_link_password_description": "Vaadi salasana tämän jakolinkin käyttämiseksi",
"shared_links": "Jaetut linkit",
"shared_links_description": "Jaa kuvia ja videoita linkin avulla",
"shared_photos_and_videos_count": "{assetCount, plural, other {# jaettua kuvaa ja videota.}}",
"shared_with_me": "Jaettu minulle",
"shared_with_partner": "Jaa kumppanin {partner} kanssa",
"sharing": "Jakaminen",
+28 -11
View File
@@ -86,7 +86,7 @@
"failed_job_command": "La commande {command} a échoué pour la tâche : {job}",
"force_delete_user_warning": "ATTENTION : Cette opération entraîne la suppression immédiate de l'utilisateur et de tous ses médias. Cette opération ne peut être annulée et les fichiers ne peuvent être récupérés.",
"image_format": "Format",
"image_format_description": "WebP produit des fichiers plus petits que JPEG, mais son encodage est plus lent.",
"image_format_description": "WebP produit des fichiers plus petits que JPEG, mais son encodage est plus lent. JPEG XL est la meilleure option pour la compression de grande qualité, mais certains navigateurs ou applis ne savent pas l'afficher.",
"image_fullsize_description": "Image en taille réelle, sans métadonnées, utilisée lors d'un zoom",
"image_fullsize_enabled": "Activer la génération d'image en taille d'origine",
"image_fullsize_enabled_description": "Générer une image en taille réelle pour les formats non compatibles avec le web. Lorsque l'option « Préférer l'aperçu intégré » est activée, les aperçus intégrés sont utilisés directement sans conversion. Cette option n'affecte pas les formats compatibles avec le web tels que JPEG.",
@@ -98,9 +98,9 @@
"image_prefer_wide_gamut_setting_description": "Utiliser Display P3 pour les miniatures. Cela préserve mieux la vivacité des images avec des espaces colorimétriques étendus, mais les images peuvent apparaître différemment sur les anciens appareils avec une ancienne version du navigateur. Conserver les images sRGB en sRGB pour éviter les décalages de couleur.",
"image_preview_description": "Image de taille moyenne avec métadonnées retirées, utilisée lors de la visualisation d'un seul média et pour l'apprentissage automatique",
"image_preview_quality_description": "Qualité de l'aperçu : de 1 à 100. Une valeur plus élevée produit de meilleurs résultats, mais elle produit des fichiers plus volumineux et peut réduire la réactivité de l'application. Une valeur trop basse peut affecter la qualité de l'apprentissage automatique.",
"image_preview_title": "Paramètres de prévisualisation",
"image_preview_title": "Paramètres de l'aperçu",
"image_progressive": "Progressif",
"image_progressive_description": "Encode les images JPEG de manière progressive pour un affichage graduel. Cela n'a pas d'effet sur les images en WebP.",
"image_progressive_description": "Encode les images JPEG de manière progressive pour un affichage graduel. Cela n'a pas d'effet sur les autres formats.",
"image_quality": "Qualité",
"image_resolution": "Résolution",
"image_resolution_description": "Les résolutions plus élevées permettent de préserver davantage de détails, mais l'encodage est plus long, les fichiers sont plus volumineux et la réactivité de l'application peut s'en trouver réduite.",
@@ -323,6 +323,13 @@
"oauth_timeout": "Expiration de la durée de la requête",
"oauth_timeout_description": "Délai d'expiration des requêtes en millisecondes",
"ocr_job_description": "Utiliser un modèle d'apprentissage automatique pour reconnaitre le texte dans les images",
"passkey_additional_domains": "Domaines additionnels",
"passkey_additional_domains_description": "Domaines supplémentaires et de confiance (requêtes d'origine liées). Ces domaines ne fonctionneront que si le domaine à clé d'accès est également accessible.",
"passkey_domain": "Domaine à clé d'accès",
"passkey_domain_description": "Domaine utilisé pour les clés d'accès (partie dépendante). Laissez vide pour déduire à partir de l'en-tête d'origine. Changer cette valeur fera en sorte que les clés d'accès existantes ne fonctionnent plus.",
"passkey_enable_description": "Se connecter avec une clé d'accès",
"passkey_settings": "Connexion par clé d'accès",
"passkey_settings_description": "Gérer les paramètres de connexion de la clé d'accès",
"password_enable_description": "Connexion avec courriel et mot de passe",
"password_settings": "Connexion par mot de passe",
"password_settings_description": "Gérer les paramètres de connexion par mot de passe",
@@ -873,6 +880,8 @@
"delete_local_dialog_ok_backed_up_only": "Suppression des données sauvegardées uniquement",
"delete_local_dialog_ok_force": "Supprimer tout de même",
"delete_others": "Supprimer les autres",
"delete_passkey": "Supprimer la clé d'accès",
"delete_passkey_prompt": "Êtes-vous sûr de vouloir supprimer cette clé d'accès?",
"delete_permanently": "Supprimer définitivement",
"delete_permanently_action_prompt": "{count} supprimé(s) définitivement",
"delete_shared_link": "Supprimer le lien partagé",
@@ -942,6 +951,7 @@
"edit_location_action_prompt": "{count} localisation(s) mise(s) à jour",
"edit_location_dialog_title": "Localisation",
"edit_name": "Modifier le nom",
"edit_passkey": "Modifier la clé d'accès",
"edit_people": "Modifier les personnes",
"edit_person": "Modifier la personne",
"edit_tag": "Modifier l'étiquette",
@@ -1239,7 +1249,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} prise à {city}, {country} avec {person1}, {person2}, et {person3} le {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} prise à {city}, {country} avec {person1}, {person2} et {additionalCount, number} autres personnes le {date}",
"immich_logo": "Logo Immich",
"immich_web_interface": "Interface Web Immich",
"import_from_json": "Importer depuis un fichier JSON",
"import_path": "Chemin d'importation",
"in_albums": "Dans {count, plural, one {# album} other {# albums}}",
@@ -1287,6 +1296,7 @@
"last": "Dernier",
"last_months": "{count, plural, one {Dernier mois} other {Derniers # mois}}",
"last_seen": "Dernièrement utilisé",
"last_used": "Dernière utilisation",
"latest_version": "Dernière version",
"latitude": "Latitude",
"leave": "Quitter",
@@ -1362,6 +1372,7 @@
"login_has_been_disabled": "La connexion a été désactivée",
"login_password_changed_error": "Une erreur s'est produite lors de la mise à jour de votre mot de passe",
"login_password_changed_success": "Mot de passe mis à jour avec succès",
"login_with_passkey": "Se connecter avec la clé d'accès",
"logout_all_device_confirmation": "Êtes-vous sûr de vouloir déconnecter tous les appareils ?",
"logout_this_device_confirmation": "Êtes-vous sûr de vouloir déconnecter cet appareil ?",
"logs": "Journaux",
@@ -1410,10 +1421,11 @@
"manage_your_api_keys": "Gérer vos clés API",
"manage_your_devices": "Gérer vos appareils",
"manage_your_oauth_connection": "Gérer votre connexion OAuth",
"manage_your_passkeys": "Gérez vos clés d'accès",
"map": "Carte",
"map_assets_in_bounds": "{count, plural, =0 {Aucune photo dans cette zone} one {# photo} other {# photos}}",
"map_cannot_get_user_location": "Impossible d'obtenir la localisation de l'utilisateur",
"map_location_picker_page_use_location": "Utiliser ma position",
"map_location_picker_page_use_location": "Utiliser cette position",
"map_location_service_disabled_content": "Le service de localisation doit être activé pour afficher les médias de votre emplacement actuel. Souhaitez-vous l'activer maintenant ?",
"map_location_service_disabled_title": "Service de localisation désactivé",
"map_marker_for_image": "Marqueur de carte pour une image prise à {city}, {country}",
@@ -1529,6 +1541,7 @@
"new_album": "Nouvel Album",
"new_api_key": "Nouvelle clé API",
"new_feature": "Nouvelle fonctionnalité",
"new_passkey": "Nouvelle clé d'accès",
"new_password": "Nouveau mot de passe",
"new_person": "Nouvelle personne",
"new_pin_code": "Nouveau code PIN",
@@ -1641,6 +1654,8 @@
"partner_sharing": "Partage avec les partenaires",
"partners": "Partenaires",
"partners_description": "Partager vos photos et vos vidéos avec d'autres utilisateurs",
"passkeys": "{count, plural, one {Clé d'accès} other {Clés d'accès}}",
"passkeys_not_supported": "Les clés d'accès ne sont pas prises en charge par ce navigateur",
"password": "Mot de passe",
"password_does_not_match": "Les mots de passe ne correspondent pas",
"password_required": "Mot de passe obligatoire",
@@ -1825,6 +1840,7 @@
"removed_from_favorites_count": "{count, plural, one {# supprimé} other {# supprimés}} des favoris",
"removed_from_stack": "Supprimer le média de la pile",
"removed_memory": "Souvenir supprimé",
"removed_passkey": "Clé d'accès supprimée : {name}",
"removed_tagged_assets": "Étiquette supprimée de {count, plural, one {# média} other {# médias}}",
"rename": "Renommer",
"repository": "Dépôt",
@@ -1986,11 +2002,9 @@
"setting_image_navigation_enable_subtitle": "Si activé, vous pouvez naviguer vers l'image précédente / suivante en appuyant sur le quart le plus à gauche / à droite de l'écran.",
"setting_image_navigation_enable_title": "Appuyer pour naviguer",
"setting_image_navigation_title": "Navigation dans les images",
"setting_image_viewer_help": "Le visualiseur de détails charge d'abord la petite miniature, puis l'aperçu de taille moyenne (s'il est activé), enfin l'original (s'il est activé).",
"setting_image_viewer_help": "Le visualiseur de détails charge d'abord la petite miniature, puis l'aperçu de taille moyenne, enfin l'original (s'il est activé).",
"setting_image_viewer_original_subtitle": "Activez cette option pour charger l'image en résolution originale (fichier volumineux !). Désactiver pour réduire l'utilisation des données (réseau et cache de l'appareil).",
"setting_image_viewer_original_title": "Charger l'image originale",
"setting_image_viewer_preview_subtitle": "Activer pour charger une image de résolution moyenne. Désactiver pour charger directement l'original ou utiliser uniquement la miniature.",
"setting_image_viewer_preview_title": "Charger l'image d'aperçu",
"setting_languages_apply": "Appliquer",
"setting_languages_subtitle": "Changer la langue de l'application",
"setting_notifications_notify_minutes": "{count} minutes",
@@ -2010,8 +2024,9 @@
"share_everyone": "Partager tout le monde",
"share_everyone_description": "Donner à {user} accès à toutes vos personnes.",
"share_link": "Partager le lien",
"share_logs": "Partager les journaux",
"share_original": "Utiliser l'originale (grand)",
"share_preview": "Utiliser la miniature (petite)",
"share_preview": "Utiliser l'aperçu (petit)",
"share_quality_body": "Appuyez longtemps sur le bouton partager pour choisir la qualité d'image avant de partager.",
"share_quality_title": "Choisissez votre qualité d'envoi",
"share_with": "Partager l'accès avec",
@@ -2061,7 +2076,6 @@
"shared_link_password_description": "Demander un mot de passe pour accéder à ce lien partagé",
"shared_links": "Liens partagés",
"shared_links_description": "Partager les photos et vidéos via un lien",
"shared_photos_and_videos_count": "{assetCount, plural, other {# photos et vidéos partagées.}}",
"shared_with": "Partagé avec",
"shared_with_count": "Partagé ({count})",
"shared_with_me": "Partagé avec moi",
@@ -2189,6 +2203,8 @@
"theme": "Thème",
"theme_selection": "Sélection du thème",
"theme_selection_description": "Ajuster automatiquement le thème clair ou sombre via les préférences système",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Faites défiler en continu en glissant la barre de défilement au lieu de passer à plusieurs mois",
"theme_setting_asset_list_smooth_scrubbing_title": "Nettoyage fluide de la timeline",
"theme_setting_asset_list_storage_indicator_title": "Afficher l'indicateur de stockage sur les tuiles des éléments",
"theme_setting_asset_list_tiles_per_row_title": "Nombre de médias par ligne ({count})",
"theme_setting_colorful_interface_subtitle": "Appliquer la couleur principale sur les surfaces d'arrière-plan.",
@@ -2304,7 +2320,7 @@
"user": "Utilisateur",
"user_has_been_deleted": "Cet utilisateur à été supprimé.",
"user_id": "ID Utilisateur",
"user_liked": "{user} a aimé {type, select, photo {cette photo} video {cette vidéo} asset {ce média} other {ceci}}",
"user_liked": "{user} a aimé {type, select, image {cette photo} video {cette vidéo} other {ceci}}",
"user_pin_code_settings": "Code PIN",
"user_pin_code_settings_description": "Gérer votre code PIN",
"user_privacy": "Vie privée pour l'utilisateur",
@@ -2340,6 +2356,7 @@
"view_group": "Voir le groupe",
"view_in_timeline": "Voir dans la vue chronologique",
"view_link": "Voir le lien",
"view_more": "Voir plus",
"view_name": "Vue",
"view_next_asset": "Voir le média suivant",
"view_previous_asset": "Voir le média précédent",
+24 -6
View File
@@ -323,6 +323,13 @@
"oauth_timeout": "Iarratas Ama",
"oauth_timeout_description": "Am críochnaithe d'iarratais i milleasoicindí",
"ocr_job_description": "Úsáid foghlaim meaisín chun téacs in íomhánna a aithint",
"passkey_additional_domains": "Fearainn bhreise",
"passkey_additional_domains_description": "Fearainn bhreise iontaofa (iarratais ó bhunús gaolmhar). Ní oibreoidh siad seo ach amháin má tá rochtain ar fhearann an eochair phas (passkey) chomh maith.",
"passkey_domain": "Fearann eochrach pas",
"passkey_domain_description": "Fearann a úsáidtear le haghaidh eochracha rochtana (páirtí ag brath). Fág bán é chun é a dhíorthú ón gceanntásc tionscnaimh. Má athraítear an luach seo, ní bheidh na heochracha rochtana atá ann cheana inúsáidte a thuilleadh.",
"passkey_enable_description": "Logáil isteach le heochair phas",
"passkey_settings": "Logáil Isteach Eochair-Phas",
"passkey_settings_description": "Bainistigh socruithe logála isteach le heochracha pas",
"password_enable_description": "Logáil isteach le ríomhphost agus pasfhocal",
"password_settings": "Pasfhocal Logáil Isteach",
"password_settings_description": "Bainistigh socruithe logála isteach le pasfhocal",
@@ -873,6 +880,8 @@
"delete_local_dialog_ok_backed_up_only": "Scrios Cúltaca Amháin",
"delete_local_dialog_ok_force": "Scrios Ar Aon Slí",
"delete_others": "Scrios cinn eile",
"delete_passkey": "Scrios eochair phas",
"delete_passkey_prompt": "An bhfuil tú cinnte gur mian leat an eochair phas seo a scriosadh?",
"delete_permanently": "Scrios go buan",
"delete_permanently_action_prompt": "{count} scriosta go buan",
"delete_shared_link": "Scrios nasc comhroinnte",
@@ -942,6 +951,7 @@
"edit_location_action_prompt": "suíomh {count} curtha in eagar",
"edit_location_dialog_title": "Suíomh",
"edit_name": "Cuir ainm in eagar",
"edit_passkey": "Cuir pas-eochair in eagar",
"edit_people": "Cuir daoine in eagar",
"edit_person": "Cuir duine in eagar",
"edit_tag": "Cuir an clib in eagar",
@@ -1239,7 +1249,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Íomhá}} tógtha i {city}, {country} le {person1}, {person2}, agus {person3} ar {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} tógtha i {city}, {country} le {person1}, {person2}, agus {additionalCount, number} daoine eile ar {date}",
"immich_logo": "Lógó Immich",
"immich_web_interface": "Comhéadan Gréasáin Immich",
"import_from_json": "Iompórtáil ó JSON",
"import_path": "Cosán allmhairithe",
"in_albums": "I {count, plural, one {# albam} other {# albaim}}",
@@ -1287,6 +1296,7 @@
"last": "Deireanach",
"last_months": "{count, plural, one {An mhí seo caite} other {An # mhí seo caite}}",
"last_seen": "Chonaic mé an uair dheireanach",
"last_used": "Úsáideadh go deireanach",
"latest_version": "Leagan is Déanaí",
"latitude": "Domhanleithead",
"leave": "Fág",
@@ -1362,6 +1372,7 @@
"login_has_been_disabled": "Tá logáil isteach díchumasaithe",
"login_password_changed_error": "Tharla earráid agus do phasfhocal á nuashonrú",
"login_password_changed_success": "Nuashonraíodh an focal faire go rathúil",
"login_with_passkey": "Logáil isteach le heochair phas",
"logout_all_device_confirmation": "An bhfuil tú cinnte gur mian leat logáil amach as gach gléas?",
"logout_this_device_confirmation": "An bhfuil tú cinnte gur mian leat logáil amach as an ngléas seo?",
"logs": "Logaí",
@@ -1401,6 +1412,7 @@
"manage_media_access_title": "Rochtain Bainistíochta Meán",
"manage_oauth_account": "Bainistigh Cuntas OAuth",
"manage_people": "Daoine a bhainistiú",
"manage_people_access_description_v2": "Tabhair rochtain d’úsáideoirí eile ar {count, plural, one {an duine seo} other {do dhaoine}}, ionas gur féidir leo iad a fheiceáil nó a bhainistiú thar do cheann. Ní roinntear aon sócmhainní leis an ngníomh seo.",
"manage_person_access": "Bainistigh rochtain",
"manage_sharing_with_other_users": "Bainistigh comhroinnt le húsáideoirí eile",
"manage_sharing_with_partners": "Bainistigh comhroinnt le comhpháirtithe",
@@ -1409,6 +1421,7 @@
"manage_your_api_keys": "Bainistigh d’eochracha API",
"manage_your_devices": "Bainistigh do ghléasanna logáilte isteach",
"manage_your_oauth_connection": "Bainistigh do nasc OAuth",
"manage_your_passkeys": "Bainistigh do chuid eochracha pas",
"map": "Léarscáil",
"map_assets_in_bounds": "{count, plural, =0 {Gan aon ghrianghraif sa cheantar seo} one {# grianghraf} other {# grianghraf}}",
"map_cannot_get_user_location": "Ní féidir suíomh an úsáideora a fháil",
@@ -1528,6 +1541,7 @@
"new_album": "Albam Nua",
"new_api_key": "Eochair API Nua",
"new_feature": "Gné Nua",
"new_passkey": "Eochair phas nua",
"new_password": "Pasfhocal nua",
"new_person": "Duine nua",
"new_pin_code": "Cód PIN nua",
@@ -1640,6 +1654,8 @@
"partner_sharing": "Comhroinnt Chomhpháirtíochta",
"partners": "Comhpháirtithe",
"partners_description": "Comhroinn do chuid grianghraf agus físeán le húsáideoirí eile",
"passkeys": "{count, plural, one {Eochair phas} other {Eochracha phas}}",
"passkeys_not_supported": "Ní thacaíonn an brabhsálaí seo le pas-eochracha",
"password": "Pasfhocal",
"password_does_not_match": "Ní hionann an focal faire",
"password_required": "Pasfhocal Riachtanach",
@@ -1824,6 +1840,7 @@
"removed_from_favorites_count": "{count, plural, other {Baineadh #}} ó ceanáin",
"removed_from_stack": "Bhain mé an sócmhainn den charn",
"removed_memory": "Cuimhne bainte",
"removed_passkey": "Eochair rochtana bainte: {name}",
"removed_tagged_assets": "Baineadh an clib as {count, plural, one {# sócmhainn} other {# sócmhainní}}",
"rename": "Athainmnigh",
"repository": "Stór",
@@ -1985,11 +2002,9 @@
"setting_image_navigation_enable_subtitle": "Más cumasaithe é, is féidir leat nascleanúint a dhéanamh chuig an íomhá roimhe seo/chéad íomhá eile trí thapáil ar an gceathrú is faide ar chlé/ar dheis den scáileán.",
"setting_image_navigation_enable_title": "Tapáil chun nascleanúint a dhéanamh",
"setting_image_navigation_title": "Nascleanúint Íomhá",
"setting_image_viewer_help": "Lódálann an breathnóir sonraí an mionsamhail bheag ar dtús, ansin luchtaíonn sé an réamhamharc meánmhéide (más cumasaithe), agus ar deireadh luchtaíonn sé an bunleagan (más cumasaithe).",
"setting_image_viewer_help": "Lódálann an breathnóir mionsonraí an mioníomhá bheag ar dtús, ansin an réamhamharc meánmhéide, agus ar deireadh an bunleagan (má tá sé cumasaithe).",
"setting_image_viewer_original_subtitle": "Cumasaigh chun an íomhá lántaifigh bhunaidh (mór!) a luchtú. Díchumasaigh chun úsáid sonraí a laghdú (ar an líonra agus ar an taisce ar an ngléas araon).",
"setting_image_viewer_original_title": "Luchtaigh an íomhá bhunaidh",
"setting_image_viewer_preview_subtitle": "Cumasaigh chun íomhá meántaifigh a luchtú. Díchumasaigh chun an bunleagan a luchtú go díreach nó chun an mionsamhail amháin a úsáid.",
"setting_image_viewer_preview_title": "Luchtaigh íomhá réamhamhairc",
"setting_languages_apply": "Cuir isteach",
"setting_languages_subtitle": "Athraigh teanga an aip",
"setting_notifications_notify_minutes": "{count} nóiméad",
@@ -2009,8 +2024,9 @@
"share_everyone": "Roinn le gach duine é",
"share_everyone_description": "Tabhair rochtain do {user} ar do chuid daoine go léir.",
"share_link": "Comhroinn an Nasc",
"share_logs": "Comhroinn Logaí",
"share_original": "Úsáid an bunleagan (mór)",
"share_preview": "Úsáid mionsamhail (beag)",
"share_preview": "Bain úsáid as an réamhamharc (beag)",
"share_quality_body": "Brúigh agus coinnigh an cnaipe comhroinnte chun cáilíocht na híomhá a roghnú sula roinneann tú.",
"share_quality_title": "Roghnaigh do cháilíocht roinnte",
"share_with": "Roinn rochtain le",
@@ -2060,7 +2076,6 @@
"shared_link_password_description": "Éiligh pasfhocal chun rochtain a fháil ar an nasc comhroinnte seo",
"shared_links": "Naisc chomhroinnte",
"shared_links_description": "Roinn grianghraif agus físeáin le nasc",
"shared_photos_and_videos_count": "{assetCount, plural, other {# grianghraif & físeáin chomhroinnte.}}",
"shared_with": "Roinnte le",
"shared_with_count": "Roinnte ({count})",
"shared_with_me": "Roinnte liom",
@@ -2188,6 +2203,8 @@
"theme": "Téama",
"theme_selection": "Rogha téama",
"theme_selection_description": "Socraigh an téama go huathoibríoch go geal nó dorcha bunaithe ar rogha chórais do bhrabhsálaí",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Scrollaigh go leanúnach agus an barra scrollaithe á tharraingt, in áit léim go míonna ar leith",
"theme_setting_asset_list_smooth_scrubbing_title": "Scrobáil líne ama go réidh",
"theme_setting_asset_list_storage_indicator_title": "Taispeáin táscaire stórála ar thíleanna sócmhainní",
"theme_setting_asset_list_tiles_per_row_title": "Líon na sócmhainní in aghaidh an ró ({count})",
"theme_setting_colorful_interface_subtitle": "Cuir an dath príomhúil i bhfeidhm ar dhromchlaí cúlra.",
@@ -2339,6 +2356,7 @@
"view_group": "Féach ar an ngrúpa",
"view_in_timeline": "Féach san amlíne",
"view_link": "Féach ar an nasc",
"view_more": "Féach tuilleadh",
"view_name": "Amharc",
"view_next_asset": "Féach ar an gcéad tsócmhainn eile",
"view_previous_asset": "Féach ar an tsócmhainn roimhe seo",
+30 -9
View File
@@ -86,7 +86,7 @@
"failed_job_command": "O comando {command} fallou para o traballo: {job}",
"force_delete_user_warning": "AVISO: Isto eliminará inmediatamente o usuario e todos os seus activos. Esta acción non se pode desfacer e os ficheiros non se poderán recuperar.",
"image_format": "Formato",
"image_format_description": "WebP produce ficheiros máis pequenos que JPEG, pero é máis lento de codificar.",
"image_format_description": "WebP produce ficheiros máis pequenos que JPEG, pero é máis lento de codificar. JPEG XL é a mellor opción para obter unha compresión de alta calidade, mais non todos os buscadores e aplicacións poden mostralo.",
"image_fullsize_description": "Imaxe a tamaño completo con metadatos eliminados, usada ao facer zoom",
"image_fullsize_enabled": "Activar a xeración de imaxes a tamaño completo",
"image_fullsize_enabled_description": "Xerar imaxe a tamaño completo para formatos non compatibles coa web. Cando \"Preferir vista previa incrustada\" está activado, as vistas previas incrustadas utilízanse directamente sen conversión. Non afecta a formatos compatibles coa web como JPEG.",
@@ -100,7 +100,7 @@
"image_preview_quality_description": "Calidade da vista previa de 1 a 100. Canto máis alto, mellor, pero produce ficheiros máis grandes e pode reducir a capacidade de resposta da aplicación. Establecer un valor baixo pode afectar á calidade da aprendizaxe automática.",
"image_preview_title": "Configuración da vista previa",
"image_progressive": "Progresivo",
"image_progressive_description": "Codifica imaxes JPEG progresivamente para unha visualización con carga gradual. Isto non ten ningún efecto en imaxes WebP.",
"image_progressive_description": "Codifica imaxes JPEG progresivamente para unha visualización con carga gradual. Isto non ten ningún efecto en outros formatos.",
"image_quality": "Calidade",
"image_resolution": "Resolución",
"image_resolution_description": "Resolucións máis altas poden preservar máis detalles pero tardan máis en codificarse, teñen tamaños de ficheiro máis grandes e poden reducir a capacidade de resposta da aplicación.",
@@ -323,6 +323,13 @@
"oauth_timeout": "Tempo máximo de espera da solicitude",
"oauth_timeout_description": "Tempo máximo de espera para as solicitudes en milisegundos",
"ocr_job_description": "Emprega aprendizaxe automática para recoñecer texto en imaxes",
"passkey_additional_domains": "Dominios adicionais",
"passkey_additional_domains_description": "Dominios adicionais de confianza (solicitudes de orixe relacionadas). Estes só funcionarán se o dominio da clave de acceso tamén é accesible.",
"passkey_domain": "Dominio da clave de acceso",
"passkey_domain_description": "Dominio empregado para as claves de acceso (parte de confianza). Déixeo en branco para deducilo da cabeceira da orixe. Cambiar este valor fará que as claves de acceso existentes deixen de funcionar.",
"passkey_enable_description": "Iniciar sesión cunha contrasinal",
"passkey_settings": "Acceso da chave de acceso",
"passkey_settings_description": "Xestionar a configuración de inicio de sesión con contrasinal",
"password_enable_description": "Iniciar sesión con correo electrónico e contrasinal",
"password_settings": "Inicio de sesión con contrasinal",
"password_settings_description": "Xestionar a configuración de inicio de sesión con contrasinal",
@@ -671,6 +678,7 @@
"birthdate_set_description": "A data de nacemento úsase para calcular a idade desta persoa no momento dunha foto.",
"birthday_memory_age": "{age, selectordinal, one {#º} two {#º} few {#º} other {#º}} aniversario",
"birthday_memory_title": "O aniversario de {name}",
"birthday_memory_title_with_age": "Aniversario de {name} · {age, plural, one {# ano de idade} other {# anos de idade}}",
"blurred_background": "Fondo borroso",
"browse_templates": "Explorar modelos",
"bugs_and_feature_requests": "Erros e Solicitudes de Funcións",
@@ -872,6 +880,8 @@
"delete_local_dialog_ok_backed_up_only": "Eliminar Só con Copia de Seguridade",
"delete_local_dialog_ok_force": "Eliminar Igualmente",
"delete_others": "Eliminar outros",
"delete_passkey": "Eliminar chave de acceso",
"delete_passkey_prompt": "Estás seguro que queres eliminar esta chave de acceso?",
"delete_permanently": "Eliminar permanentemente",
"delete_permanently_action_prompt": "{count} eliminado(s) permanentemente",
"delete_shared_link": "Eliminar ligazón compartida",
@@ -941,6 +951,7 @@
"edit_location_action_prompt": "{count} localización(s) editada(s)",
"edit_location_dialog_title": "Localización",
"edit_name": "Editar nome",
"edit_passkey": "Editar chave de acceso",
"edit_people": "Editar persoas",
"edit_person": "Editar persoa",
"edit_tag": "Editar etiqueta",
@@ -1238,7 +1249,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo} other {Imaxe}} tomado/a en {city}, {country} con {person1}, {person2} e {person3} o {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo} other {Imaxe}} tomado/a en {city}, {country} con {person1}, {person2} e {additionalCount, number} outros/as o {date}",
"immich_logo": "Logo de Immich",
"immich_web_interface": "Interface Web de Immich",
"import_from_json": "Importar desde JSON",
"import_path": "Ruta de importación",
"in_albums": "En {count, plural, one {# álbum} other {# álbums}}",
@@ -1286,6 +1296,7 @@
"last": "Último/a",
"last_months": "{count, plural, one {O mes pasado} other {Os últimos # meses}}",
"last_seen": "Visto por última vez",
"last_used": "Empregado por última vez",
"latest_version": "Última Versión",
"latitude": "Latitude",
"leave": "Saír",
@@ -1361,6 +1372,7 @@
"login_has_been_disabled": "O inicio de sesión foi desactivado",
"login_password_changed_error": "Houbo un erro ao actualizar o seu contrasinal",
"login_password_changed_success": "Contrasinal actualizado correctamente",
"login_with_passkey": "Iniciar sesión ca chave de acceso",
"logout_all_device_confirmation": "Está seguro de que quere pechar sesión en todos os dispositivos?",
"logout_this_device_confirmation": "Está seguro de que quere pechar sesión neste dispositivo?",
"logs": "Rexistros",
@@ -1400,6 +1412,7 @@
"manage_media_access_title": "Acceso á xestión de medios",
"manage_oauth_account": "Administrar conta OAuth",
"manage_people": "Xestionar persoas",
"manage_people_access_description_v2": "Conceder a outros usuarios o acesso {count, plural, one {a esta persoa} other {as túas persoas}}, permitindo que estes vexan ou fagan alteracións. Esta acción non comparte ningún ficheiro.",
"manage_person_access": "Xestionar o acceso",
"manage_sharing_with_other_users": "Administrar o uso compartido con outros usuarios",
"manage_sharing_with_partners": "Xestionar compartición con compañeiros/as",
@@ -1408,6 +1421,7 @@
"manage_your_api_keys": "Xestionar as súas claves API",
"manage_your_devices": "Xestionar os seus dispositivos con sesión iniciada",
"manage_your_oauth_connection": "Xestionar a súa conexión OAuth",
"manage_your_passkeys": "Xestuniar as túas chaves de acceso",
"map": "Mapa",
"map_assets_in_bounds": "{count, plural, =0 {Non hai fotos nesta área} one {# foto} other {# fotos}}",
"map_cannot_get_user_location": "Non se pode obter a localización do usuario",
@@ -1527,6 +1541,7 @@
"new_album": "Novo Álbum",
"new_api_key": "Nova Chave API",
"new_feature": "Nova función",
"new_passkey": "Nova chave de acceso",
"new_password": "Novo contrasinal",
"new_person": "Nova persoa",
"new_pin_code": "Novo código PIN",
@@ -1639,6 +1654,8 @@
"partner_sharing": "Compartición con Compañeiro/a",
"partners": "Compañeiros/as",
"partners_description": "Comparte as túas fotos e vídeos con outros usuarios",
"passkeys": "{count, plural, one {Chave de acceso} other {Chaves de acceso}}",
"passkeys_not_supported": "Este navegador non soporta chaves de acceso",
"password": "Contrasinal",
"password_does_not_match": "O contrasinal non coincide",
"password_required": "Requírese Contrasinal",
@@ -1823,6 +1840,7 @@
"removed_from_favorites_count": "{count, plural, other {Eliminados #}} de favoritos",
"removed_from_stack": "Elemento eliminado da pila",
"removed_memory": "Recordo eliminado",
"removed_passkey": "Chave de acceso {name} borrada",
"removed_tagged_assets": "Etiqueta eliminada de {count, plural, one {# activo} other {# activos}}",
"rename": "Renomear",
"repository": "Repositorio",
@@ -1984,11 +2002,9 @@
"setting_image_navigation_enable_subtitle": "Se está activado, podes navegar entre as imáxenes seguintes e anteriores ao tocar na parte esquerda e dereita da pantalla.",
"setting_image_navigation_enable_title": "Toca para navegar",
"setting_image_navigation_title": "Navegación de Imaxes",
"setting_image_viewer_help": "O visor de detalles carga primeiro a miniatura pequena, despois carga a vista previa de tamaño medio (se está activada), e finalmente carga o orixinal (se está activado).",
"setting_image_viewer_help": "O visor de detalles carga primeiro a miniatura pequena, despois carga a vista previa de tamaño medio, e finalmente carga o orixinal (se está activado).",
"setting_image_viewer_original_subtitle": "Activar para cargar a imaxe orixinal a resolución completa (grande!). Desactivar para reducir o uso de datos (tanto na rede como na caché do dispositivo).",
"setting_image_viewer_original_title": "Cargar imaxe orixinal",
"setting_image_viewer_preview_subtitle": "Activar para cargar unha imaxe de resolución media. Desactivar para cargar directamente o orixinal ou usar só a miniatura.",
"setting_image_viewer_preview_title": "Cargar imaxe de vista previa",
"setting_languages_apply": "Aplicar",
"setting_languages_subtitle": "Cambiar a lingua da aplicación",
"setting_notifications_notify_minutes": "{count} minutos",
@@ -2008,8 +2024,9 @@
"share_everyone": "Compartir con todos",
"share_everyone_description": "Dálle a {user} acceso a todas as túas persoas.",
"share_link": "Ligazón para Compartir",
"share_logs": "Compartir rexistros",
"share_original": "Utilizar orixinal (grande)",
"share_preview": "Utilizar miniatura (pequena)",
"share_preview": "Usar vista previa (pequeña)",
"share_quality_body": "Mantén premido o botón de compartir para escoller a calidade da imaxe antes de compartila.",
"share_quality_title": "Escolle a calidade da compartición",
"share_with": "Compartir o acceso con",
@@ -2059,7 +2076,6 @@
"shared_link_password_description": "Requirir un contrasinal para acceder a esta ligazón compartida",
"shared_links": "Ligazóns compartidas",
"shared_links_description": "Compartir fotos e vídeos cunha ligazón",
"shared_photos_and_videos_count": "{assetCount, plural, other {# fotos e vídeos compartidos.}}",
"shared_with": "Compartido con",
"shared_with_count": "Compartido ({count})",
"shared_with_me": "Compartido comigo",
@@ -2187,6 +2203,8 @@
"theme": "Tema",
"theme_selection": "Selección de tema",
"theme_selection_description": "Establecer automaticamente o tema a claro ou escuro baseándose na preferencia do sistema do seu navegador",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Desprazamento continuo ao arrastrar a barra de desprazamento en lugar de axustarse aos meses",
"theme_setting_asset_list_smooth_scrubbing_title": "Fregado suave da liña de tempo",
"theme_setting_asset_list_storage_indicator_title": "Mostrar indicador de almacenamento nas celas de activos",
"theme_setting_asset_list_tiles_per_row_title": "Número de activos por fila ({count})",
"theme_setting_colorful_interface_subtitle": "Aplicar cor primaria ás superficies de fondo.",
@@ -2227,6 +2245,8 @@
"trigger": "Disparador",
"trigger_asset_metadata_extraction": "Extracción de metadatos de activos",
"trigger_asset_metadata_extraction_description": "Activado cando se extraen os metadatos EXIF dun recurso",
"trigger_asset_ocr_extraction": "Extracción de activos empregando OCR",
"trigger_asset_ocr_extraction_description": "Activado cando se extrae o texto OCR dun recurso",
"trigger_asset_tagged": "Recurso etiquetado",
"trigger_asset_tagged_description": "Actívase cando se engade unha etiqueta a un recurso",
"trigger_asset_uploaded": "Carga de activos",
@@ -2302,7 +2322,7 @@
"user": "Usuario",
"user_has_been_deleted": "Este usuario foi eliminado.",
"user_id": "ID de Usuario",
"user_liked": "A {user} gustoulle {type, select, photo {esta foto} video {este vídeo} asset {este activo} other {isto}}",
"user_liked": "A {user} gustoulle {type, select, image {esta foto} video {este vídeo} other {isto}}",
"user_pin_code_settings": "Código PIN",
"user_pin_code_settings_description": "Xestionar seu código PIN",
"user_privacy": "Privacidade do usuario",
@@ -2338,6 +2358,7 @@
"view_group": "Ver grupo",
"view_in_timeline": "Ver na liña de tempo",
"view_link": "Ver ligazón",
"view_more": "Ver máis",
"view_name": "Vista",
"view_next_asset": "Ver seguinte activo",
"view_previous_asset": "Ver activo anterior",
-1
View File
@@ -1077,7 +1077,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Bild}} ufgnoo in {city}, {country} mit {person1}, {person2} und {person3} am {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Bild}} ufgnoo in {city}, {country} mit {person1}, {person2} und {additionalCount, number} andere am {date}",
"immich_logo": "Immich-Logo",
"immich_web_interface": "Immich-Web-Oberflächi",
"import_from_json": "Us JSON importierä",
"import_path": "Importpfad",
"in_albums": "In {count, plural, one {# Album} other {# Albe}}",
+26 -6
View File
@@ -671,6 +671,7 @@
"birthdate_set_description": "תאריך לידה משמש לחישוב הגיל של האדם הזה בזמן תצלום.",
"birthday_memory_age": "{age, selectordinal, other {יום ההולדת ה־#}}",
"birthday_memory_title": "יום ההולדת של {name}",
"birthday_memory_title_with_age": "יום ההולדת של {name} · גיל {age, plural, one {#} other {#}}",
"blurred_background": "רקע מטושטש",
"browse_templates": "עיון בתבניות",
"bugs_and_feature_requests": "באגים & בקשות לתכונות",
@@ -1238,7 +1239,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {סרטון שצולם} other {תמונה שצולמה}} ב-{city}, {country} עם {person1}, {person2}, ו-{person3} ב-{date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {סרטון שצולם} other {תמונה שצולמה}} ב-{city}, {country} עם {person1}, {person2}, ו-{additionalCount, number} אחרים ב-{date}",
"immich_logo": "הלוגו של Immich",
"immich_web_interface": "ממשק האינטרנט של Immich",
"import_from_json": "ייבוא מ-JSON",
"import_path": "נתיב ייבוא",
"in_albums": "ב{count, plural, one {אלבום #} other {# אלבומים}}",
@@ -1399,6 +1399,9 @@
"manage_media_access_subtitle": "אפשר לאפליקציית Immich לנהל ולהזיז קבצי מדיה.",
"manage_media_access_title": "גישה לניהול מדיה",
"manage_oauth_account": "ניהול חשבון OAuth",
"manage_people": "ניהול אנשים",
"manage_people_access_description_v2": "מתן גישה למשתמשים אחרים אל {count, plural, one {האדם הזה} other {האנשים שלך}}, כך שיוכלו להציג או לנהל אותם בשמך. פעולה זו אינה משתפת תמונות.",
"manage_person_access": "ניהול גישה",
"manage_sharing_with_other_users": "ניהול שיתוף עם משתמשים אחרים",
"manage_sharing_with_partners": "ניהול שיתוף עם שותפים",
"manage_the_app_settings": "ניהול הגדרות האפליקציה",
@@ -1551,6 +1554,7 @@
"no_exif_info_available": "אין מידע זמין על מטא-נתונים (exif)",
"no_explore_results_message": "העלה תמונות נוספות כדי לחקור את האוסף שלך.",
"no_favorites_message": "הוסף מועדפים כדי למצוא במהירות את התמונות והסרטונים הכי טובים שלך",
"no_filter": "ללא סינון",
"no_libraries_message": "צור ספרייה חיצונית כדי לראות את התמונות והסרטונים שלך",
"no_local_assets_found": "לא נמצאו תמונות עם Checksum זהה",
"no_locked_photos_message": "תמונות וסרטונים בתיקייה הנעולה מוסתרים ולא יופיעו בזמן הגלישה או החיפוש בספרייה שלך.",
@@ -1635,6 +1639,7 @@
"partner_page_stop_sharing_content": "{partner} לא יוכל יותר לגשת לתמונות שלך.",
"partner_sharing": "שיתוף שותפים",
"partners": "שותפים",
"partners_description": "שיתוף התמונות והסרטונים שלך עם משתמשים אחרים",
"password": "סיסמה",
"password_does_not_match": "הסיסמה אינה תואמת",
"password_required": "נדרשת סיסמה",
@@ -1651,6 +1656,7 @@
"paused": "מושהה",
"pending": "ממתין",
"people": "אנשים",
"people_cluster_group": "קבוצת אשכול אנשים",
"people_count": "{count, plural, one {אדם #} other {# אנשים}}",
"people_edits_count": "{count, plural, one {אדם # נערך} other {# אנשים נערכו}}",
"people_feature_description": "עיון בתמונות וסרטונים שקובצו על ידי אנשים",
@@ -1669,8 +1675,15 @@
"person_age_year_months": "בגיל שנה ו{months, plural, one {חודש #} other {# חודשים}}",
"person_age_years": "בגיל {years, plural, one {שנה} other {# שנים}}",
"person_birthdate": "נולד בתאריך {date}",
"person_edit_only_change_for_me": "שינוי רק עבורי",
"person_edit_only_change_for_me_description": "סמן כדי להחיל את השינויים רק על המשתמש שלך",
"person_hidden": "{name}{hidden, select, true { (מוסתר)} other {}}",
"person_recognized": "זוהה אדם",
"person_share_no_users": "כל משתמש בקבוצת האשכול של האנשים שלך כבר יש לו גישה לאדם הזה.",
"person_update_strategy": "החלת השינויים על",
"person_update_strategy_description": "אילו אנשים לעדכן כאשר משנים שם או תאריך לידה",
"person_update_strategy_everyone": "כל מי שיש לו גישה משותפת",
"person_update_strategy_self": "רק אני",
"photo_shared_all_users": "נראה ששיתפת את התמונות שלך עם כל המשתמשים או שאין לך אף משתמש לשתף איתו.",
"photos": "תמונות",
"photos_and_videos": "תמונות & סרטונים",
@@ -1753,6 +1766,7 @@
"query_asset_id": "שאילתה על מזהה הפריט",
"queue_status": "{count} מתוך {total} עומדים בתור",
"rate_asset": "דירוג תמונה",
"rate_asset_description": "השתמש במקשים 1 עד 5 כדי לדרג תמונה במהירות, וב־0 כדי לנקות את הדירוג",
"rating": "דירוג כוכב",
"rating_clear": "נקה דירוג",
"rating_count": "{count, plural, =0 {ללא דירוג} one {כוכב אחד} other {# כוכבים}}",
@@ -1971,11 +1985,9 @@
"setting_image_navigation_enable_subtitle": "אם הגדרה זו מופעלת, ניתן לנווט לתמונה הקודמת/הבאה על ידי הקשה על הרבע הימני/השמאלי ביותר של המסך.",
"setting_image_navigation_enable_title": "הקשה לניווט",
"setting_image_navigation_title": "ניווט תמונה",
"setting_image_viewer_help": "מציג הפרטים טוען את התמונה הממוזערת הקטנה קודם, לאחר מכן טוען את התצוגה המקדימה בגודל בינוני (אם מופעל), לבסוף טוען את המקורית (אם מופעל).",
"setting_image_viewer_help": "מציג הפרטים טוען תחילה את התמונה הממוזערת הקטנה, לאחר מכן את התצוגה המקדימה בגודל בינוני, ולבסוף את המקור (אם מופעל).",
"setting_image_viewer_original_subtitle": "אפשר לטעון את התמונה המקורית ברזלוציה מלאה (גדולה!). השבת כדי להקטין שימוש בנתונים (גם בשרת וגם בזיכרון המטמון שבמכשיר).",
"setting_image_viewer_original_title": "טען תמונה מקורית",
"setting_image_viewer_preview_subtitle": "אפשר לטעון תמונה ברזלוציה בינונית. השבת כדי או לטעון את המקורית או רק להשתמש בתמונה הממוזערת.",
"setting_image_viewer_preview_title": "טען תמונת תצוגה מקדימה",
"setting_languages_apply": "החל",
"setting_languages_subtitle": "שינוי שפת היישום",
"setting_notifications_notify_minutes": "{count} דקות",
@@ -1990,12 +2002,16 @@
"settings_saved": "ההגדרות נשמרו",
"setup_pin_code": "הגדר קוד PIN",
"share": "שתף",
"share_back": "שיתוף חוזר",
"share_dialog_preparing": "מכין...",
"share_everyone": "שיתוף עם כולם",
"share_everyone_description": "מתן גישה ל־{user} לכל האנשים שלך.",
"share_link": "שתף קישור",
"share_original": "השתמש במקור (גדול)",
"share_preview": "השתמש בתמונה ממוזערת (קטנה)",
"share_preview": "שימוש בתצוגה מקדימה (קטנה)",
"share_quality_body": "לחץ לחיצה ארוכה על כפתור השיתוף כדי לבחור את איכות התמונה לפני השיתוף.",
"share_quality_title": "בחר את איכות השיתוף שלך",
"share_with": "שיתוף גישה עם",
"shared": "משותף",
"shared_album_activities_input_disable": "התגובה מושבתת",
"shared_album_activity_remove_content": "האם ברצונך למחוק את הפעילות הזאת?",
@@ -2003,6 +2019,7 @@
"shared_album_section_people_action_error": "שגיאה בעזיבה/הסרה מאלבום",
"shared_album_section_people_title": "אנשים",
"shared_by": "משותף על ידי",
"shared_by_me": "משותף על ידי",
"shared_by_user": "משותף על ידי {user}",
"shared_by_you": "משותף על ידך",
"shared_from_partner": "תמונות מאת {partner}",
@@ -2041,7 +2058,8 @@
"shared_link_password_description": "דרוש סיסמה כדי לגשת לקישור המשותף הזה",
"shared_links": "קישורים משותפים",
"shared_links_description": "שתף תמונות וסרטונים עם קישור",
"shared_photos_and_videos_count": "{assetCount, plural, other {# תמונות וסרטונים משותפים.}}",
"shared_with": "משותף עם",
"shared_with_count": "משותף ({count})",
"shared_with_me": "משותף איתי",
"shared_with_partner": "משותף עם {partner}",
"sharing": "שיתוף",
@@ -2125,6 +2143,7 @@
"stop_motion_photo": "עצור תמונה עם תנועה",
"stop_photo_sharing": "להפסיק לשתף את התמונות שלך?",
"stop_photo_sharing_description": "{partner} לא יוכל לגשת לתמונות שלך יותר.",
"stop_sharing_person_with_user": "הפסקת השיתוף של האדם הזה עם המשתמש הזה",
"stop_sharing_photos_with_user": "הפסק לשתף את התמונות שלך עם משתמש זה",
"storage": "שטח אחסון",
"storage_label": "תווית אחסון",
@@ -2317,6 +2336,7 @@
"view_group": "הצג קבוצה",
"view_in_timeline": "ראה בציר הזמן",
"view_link": "הצג קישור",
"view_more": "הצגת עוד",
"view_name": "הצג",
"view_next_asset": "הצג את התמונה הבאה",
"view_previous_asset": "הצג את התמונה הקודמת",
+15 -14
View File
@@ -5,17 +5,17 @@
"account_settings": "खाता सेटिंग्स",
"acknowledge": "स्वीकार करें",
"action": "कार्रवाई",
"action_common_update": "अद्यतन",
"actions": "कार्यवाहियां",
"active": "सक्रिय",
"active_count": "सक्रिय: {count}",
"action_common_update": "अपडेट",
"actions": "एक्शन्स",
"active": "एक्टिव",
"active_count": "एक्टिव: {count}",
"activity": "गतिविधि",
"add": "डालें",
"add_a_description": "एक विवरण डालें",
"add_a_location": "एक स्थान डालें",
"add": "जोड़ें",
"add_a_description": "विवरण डालें",
"add_a_location": "स्थान डालें",
"add_a_name": "नाम डालें",
"add_a_title": "एक शीर्षक डालें",
"add_action": "कार्रवाई डालें",
"add_a_title": "शीर्षक डालें",
"add_action": "ऐक्शन जोड़ें",
"add_assets": "एसेट्स जोड़ें",
"add_birthday": "अपने जन्मदिन का उल्लेख करें",
"add_endpoint": "endpoint डालें",
@@ -30,6 +30,7 @@
"add_to_album_bottom_sheet_added": "{album} में डालें",
"add_to_album_bottom_sheet_already_exists": "{album} में पहले से है",
"add_to_album_bottom_sheet_some_local_assets": "कुछ स्थानीय एसेट एल्बम में नहीं जोड़े जा सके",
"add_to_album_item_count": "एल्बम में {count, plural, one {# आइटम} other {# आइटम्स}} जोड़ें",
"add_to_albums": "एकाधिक एल्बम में डाले",
"add_to_albums_count": "एल्बमों में डालें ({count})",
"add_to_bottom_bar": "इसमें जोड़ें",
@@ -63,7 +64,7 @@
"cleared_jobs": "{job}: के लिए कार्य साफ़ कर दिए गए",
"config_set_by_file": "Config वर्तमान में एक config फ़ाइल द्वारा सेट किया गया है",
"confirm_delete_library": "क्या आप वाकई {library} लाइब्रेरी को हटाना चाहते हैं?",
"confirm_delete_library_assets": "क्या आप वाकई इस लाइब्रेरी को हटाना चाहते हैं? यह इम्मीच से {count, plural, one {# contained asset} other {all # contained assets}} हटा दिया जाएगा और इसे पूर्ववत नहीं किया जा सकेगा। फ़ाइलें डिस्क पर रहेंगी।",
"confirm_delete_library_assets": "क्या आप वाकई इस लाइब्रेरी को हटाना चाहते हैं? यह Immich से {count, plural, one {# contained asset} other {all # contained assets}} हटा दिया जाएगा और इसे पूर्ववत नहीं किया जा सकेगा। फ़ाइलें डिस्क पर रहेंगी।",
"confirm_email_below": "पुष्टि करने के लिए नीचे \"{email}\" टाइप करें",
"confirm_reprocess_all_faces": "क्या आप वाकई सभी चेहरों को दोबारा संसाधित करना चाहते हैं? इससे नामित लोग भी साफ हो जायेंगे।",
"confirm_user_password_reset": "क्या आप वाकई {user} का पासवर्ड रीसेट करना चाहते हैं?",
@@ -109,6 +110,10 @@
"image_thumbnail_quality_description": "थंबनेल की गुणवत्ता 1-100 तक। उच्चतर बेहतर है, लेकिन बड़ी फ़ाइलें बनाता है और ऐप की प्रतिक्रियाशीलता को कम कर सकता है।",
"image_thumbnail_title": "थंबनेल सेटिंग्स",
"import_config_from_json_description": "JSON कॉन्फ़िगरेशन फ़ाइल अपलोड करके सिस्टम कॉन्फ़िगरेशन इंपोर्ट करें",
"integrity_checks_checksum_files": "चेकसम फाइलें",
"integrity_checks_checksum_files_description": "चेकसम जांच कॉन्फ़िगर करें",
"integrity_checks_checksum_files_enable_description": "चेकसम जांच चालू करें",
"integrity_checks_checksum_files_percentage_limit": "प्रतिशत सीमा",
"integrity_checks_checksum_files_time_limit": "समय सीमा",
"job_concurrency": "{job} समरूपता",
"job_created": "नौकरी बनाई गई",
@@ -1131,7 +1136,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} {city}, {country} में {person1}, {person2}, और {person3} के साथ {date} को लिया गया",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} {city}, {country} में {person1}, {person2}, और {additionalCount, number} अन्य लोगों के साथ {date} को लिया गया",
"immich_logo": "Immich लोगो",
"immich_web_interface": "इमिच वेब इंटरफ़ेस",
"import_from_json": "JSON से आयात करें",
"import_path": "आयात पथ",
"in_albums": "{count, plural, one {# album} other {# albums}} में",
@@ -1757,8 +1761,6 @@
"setting_image_viewer_help": "डिटेल व्यूअर पहले छोटा थंबनेल लोड करता है, फिर मीडियम-साइज़ प्रीव्यू लोड करता है (अगर इनेबल है), और आखिर में ओरिजिनल लोड करता है (अगर इनेबल है)।",
"setting_image_viewer_original_subtitle": "ओरिजिनल फुल-रिज़ॉल्यूशन इमेज (बड़ी!) लोड करने के लिए इनेबल करें। डेटा का इस्तेमाल कम करने के लिए डिसेबल करें (नेटवर्क और डिवाइस कैश दोनों)।",
"setting_image_viewer_original_title": "मूल छवि लोड करें",
"setting_image_viewer_preview_subtitle": "मीडियम-रिज़ॉल्यूशन इमेज लोड करने के लिए चालू करें। ओरिजिनल इमेज को सीधे लोड करने या सिर्फ़ थंबनेल इस्तेमाल करने के लिए बंद करें।",
"setting_image_viewer_preview_title": "पूर्वावलोकन छवि लोड करें",
"setting_languages_apply": "आवेदन करना",
"setting_languages_subtitle": "ऐप की भाषा बदलें",
"setting_notifications_notify_minutes": "{count} मिनट",
@@ -1818,7 +1820,6 @@
"shared_link_password_description": "इस शेयर किए गए लिंक को एक्सेस करने के लिए पासवर्ड ज़रूरी है",
"shared_links": "साझा किए गए लिंक",
"shared_links_description": "लिंक के साथ फ़ोटो और वीडियो शेयर करें",
"shared_photos_and_videos_count": "{assetCount, plural, other {# साझा फ़ोटो और वीडियो}}",
"shared_with_me": "मेरे साथ साझा किया गया",
"shared_with_partner": "{partner} के साथ शेयर किया गया",
"sharing": "शेयरिंग",
-4
View File
@@ -1213,7 +1213,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1}, {person2} i {person3} {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1}, {person2} i {additionalCount, number} drugih {date}",
"immich_logo": "Immich Logo",
"immich_web_interface": "Immich Web Sučelje",
"import_from_json": "Uvoz iz JSON-a",
"import_path": "Putanja uvoza",
"in_albums": "U {count, plural, one {# album} other {# albuma}}",
@@ -1918,8 +1917,6 @@
"setting_image_viewer_help": "Preglednik detalja prvo učitava malu sličicu, zatim učitava pregled srednje veličine (ako je omogućen), te na kraju učitava original (ako je omogućen).",
"setting_image_viewer_original_subtitle": "Omogućite za učitavanje originalne slike pune rezolucije (velika!). Onemogućite za smanjenje potrošnje podataka (i mrežne i na predmemoriji uređaja).",
"setting_image_viewer_original_title": "Učitaj originalnu sliku",
"setting_image_viewer_preview_subtitle": "Omogućite za učitavanje slike srednje rezolucije. Onemogućite za izravno učitavanje originala ili korištenje samo sličice.",
"setting_image_viewer_preview_title": "Učitaj sliku za pregled",
"setting_languages_apply": "Primijeni",
"setting_languages_subtitle": "Promijeni jezik aplikacije",
"setting_notifications_notify_minutes": "{count} minuta",
@@ -1985,7 +1982,6 @@
"shared_link_password_description": "Zahtjevaj loziku za pristup ovom dijeljenom linku",
"shared_links": "Dijeljene poveznice",
"shared_links_description": "Podijelite fotografije i videozapise putem poveznice",
"shared_photos_and_videos_count": "{assetCount, plural, one {# podijeljena fotografija ili videozapis.} few {# podijeljene fotografije i videozapisa.} other {# podijeljenih fotografija i videozapisa.}}",
"shared_with_me": "Podijeljeno sa mnom",
"shared_with_partner": "Podijeljeno s {partner}",
"sharing": "Dijeljenje",
+29 -9
View File
@@ -86,7 +86,7 @@
"failed_job_command": "A(z) {command} parancs nem sikerült a következő feladathoz: {job}",
"force_delete_user_warning": "FIGYELEM: Ez azonnal eltávolítja a felhasználót és az összes hozzá tartozó elemet. A művelet nem visszavonható és a fájlokat sem lehet később visszanyerni.",
"image_format": "Formátum",
"image_format_description": "WebP a JPEG-nél kisebb fájlokat készít, de lassabban.",
"image_format_description": "WebP a JPEG-nél kisebb fájlokat készít, de lassabban. A JPEG XL a legjobb opció a magas minőségű tömörítéshez, de nem minden böngésző és app tudja megjeleníteni.",
"image_fullsize_description": "Teljes méretű kép eltávolított metaadatokkal, nagyításkor használva",
"image_fullsize_enabled": "Teljes méretű képgenerálás engedélyezése",
"image_fullsize_enabled_description": "Teljes méretű kép generálása nem webbarát formátumokhoz. Ha a „Beágyazott előnézet preferálása” engedélyezve van, a beágyazott előnézetek közvetlenül, átalakítás nélkül kerülnek felhasználásra. Nem érinti a webbarát formátumokat, például a JPEG-et.",
@@ -100,7 +100,7 @@
"image_preview_quality_description": "Előnézet minősége 1-100 között. A magasabb szám jobb minőséget, de nagyobb fájlokat eredményez és belassíthatja az alkalmazást. Túl alacsony érték befolyásolhatja a gépi tanulás pontosságát.",
"image_preview_title": "Előnézet beállítások",
"image_progressive": "Progresszív",
"image_progressive_description": "JPEG képek progresszív kódolása a fokozatos megjelenítéshez betöltéskor. Nincs hatással a WebP képekre.",
"image_progressive_description": "JPEG képek progresszív kódolása a fokozatos megjelenítéshez betöltéskor. Nincs hatással más formátumú képekre.",
"image_quality": "Minőség",
"image_resolution": "Felbontás",
"image_resolution_description": "A nagyobb felbontás több részletet őriz meg, de lassabb létrehozni, nagyobb fájlt eredményez és belassíthatja az alkalmazást.",
@@ -323,6 +323,13 @@
"oauth_timeout": "Kérés időkorlátja",
"oauth_timeout_description": "Kérések időkorlátja milliszekundumban",
"ocr_job_description": "Gépi tanulás használata a képeken lévő szövegek felismerésére",
"passkey_additional_domains": "További domainek",
"passkey_additional_domains_description": "További megbízható domainek (related origin kérések). Ezek csak akkor működnek, ha az azonosítókulcs domain is elérhető.",
"passkey_domain": "Azonosítókulcs domain",
"passkey_domain_description": "Azonosítókulcsok használatához használt domain (relying party). Hagyd üresen, ha az Origin fejlécből szeretnéd automatikusan meghatározni. Az érték módosítása után a meglévő azonosítókulcsok nem fognak működni.",
"passkey_enable_description": "Belépés azonosítókulccsal",
"passkey_settings": "Azonosítókulcsos belépés",
"passkey_settings_description": "Azonosítókulcsos belépés kezelése",
"password_enable_description": "Bejelentkezés e-maillel és jelszóval",
"password_settings": "Jelszavas bejelentkezés",
"password_settings_description": "Jelszavas bejelentkezés beállítások kezelése",
@@ -873,6 +880,8 @@
"delete_local_dialog_ok_backed_up_only": "Csak a biztonsági mentés törlése",
"delete_local_dialog_ok_force": "Törlés mindenképp",
"delete_others": "Többi törlése",
"delete_passkey": "Azonosítókulcs törlése",
"delete_passkey_prompt": "Biztosan törölni szeretnéd ezt az azonosítókulcsot?",
"delete_permanently": "Törlés véglegesen",
"delete_permanently_action_prompt": "{count} törölve véglegesen",
"delete_shared_link": "Megosztott link törlése",
@@ -942,6 +951,7 @@
"edit_location_action_prompt": "{count} hely változtatva",
"edit_location_dialog_title": "Hely",
"edit_name": "Név módosítása",
"edit_passkey": "Azonosítókulcs módosítása",
"edit_people": "Személyek módosítása",
"edit_person": "Személy szerkesztése",
"edit_tag": "Címke módosítása",
@@ -1239,7 +1249,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, velük: {person1}, {person2} és {person3} (készült: {date})",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, velük: {person1}, {person2} és további {additionalCount, number} személy (készült: {date})",
"immich_logo": "Immich logó",
"immich_web_interface": "Immich webes felület",
"import_from_json": "Importálás JSON-ből",
"import_path": "Importálási útvonal",
"in_albums": "{count, plural, other {# albumban}}",
@@ -1287,6 +1296,7 @@
"last": "Utolsó",
"last_months": "{count, plural, one {Elmúlt hónap} other {Elmúlt # hónap}}",
"last_seen": "Utoljára bejelentkezve",
"last_used": "Utoljára használva",
"latest_version": "Legfrissebb verzió",
"latitude": "Szélesség",
"leave": "Elhagyás",
@@ -1362,6 +1372,7 @@
"login_has_been_disabled": "A bejelentkezés le van tiltva",
"login_password_changed_error": "Nem sikerült módosítani a jelszót",
"login_password_changed_success": "Jelszó sikeresen módosítva",
"login_with_passkey": "Belépés azonosítókulccsal",
"logout_all_device_confirmation": "Biztos, hogy minden eszközön ki szeretnél jelentkezni?",
"logout_this_device_confirmation": "Biztos, hogy ki szeretnél jelentkezni ezen az eszközön?",
"logs": "Naplók",
@@ -1401,6 +1412,7 @@
"manage_media_access_title": "Médiakezelés hozzáférés",
"manage_oauth_account": "OAuth-fiók kezelése",
"manage_people": "Személyek kezelése",
"manage_people_access_description_v2": "Más felhasználók számára hozzáférés biztosítása {count, plural, one {ehhez a személyhez} other {a személyeidhez}}, lehetővé téve számukra, hogy a nevedben megtekintsék vagy kezeljék őket. Ez a művelet nem oszt meg semmilyen médiatartalmat.",
"manage_person_access": "Hozzáférés kezelése",
"manage_sharing_with_other_users": "Más felhasználókkal való megosztások kezelése",
"manage_sharing_with_partners": "Partnerekkel való megosztás kezelése",
@@ -1409,6 +1421,7 @@
"manage_your_api_keys": "Saját API-kulcsok kezelése",
"manage_your_devices": "Bejelentkezett eszközök kezelése",
"manage_your_oauth_connection": "OAuth kapcsolódás kezelése",
"manage_your_passkeys": "Azonosítókulcsaid kezelése",
"map": "Térkép",
"map_assets_in_bounds": "{count, plural, =0 {Nincsenek fotók ezen a területen} one {# fotó} other {# fotó}}",
"map_cannot_get_user_location": "A helymeghatározás nem sikerült",
@@ -1528,6 +1541,7 @@
"new_album": "Új album",
"new_api_key": "Új API-kulcs",
"new_feature": "Új funkció",
"new_passkey": "Új azonosítókulcs",
"new_password": "Új jelszó",
"new_person": "Új személy",
"new_pin_code": "Új PIN-kód",
@@ -1640,6 +1654,8 @@
"partner_sharing": "Partnerrel megosztás",
"partners": "Partnerek",
"partners_description": "Fotóid és videóid megosztása más felhasználókkal",
"passkeys": "{count, plural, one {Azonosítókulcs} other {Azonosítókulcsok}}",
"passkeys_not_supported": "Ez a böngésző nem támogatja az azonosítókulcsok használatát",
"password": "Jelszó",
"password_does_not_match": "A jelszavak nem egyeznek",
"password_required": "Jelszó szükséges",
@@ -1824,6 +1840,7 @@
"removed_from_favorites_count": "A kedvencekből {count, plural, other {# elem}} eltávolítva",
"removed_from_stack": "Elem eltávolítva a kötegből",
"removed_memory": "Emlék eltávolítva",
"removed_passkey": "Eltávolított azonosítókulcs: {name}",
"removed_tagged_assets": "Címke eltávolítva {count, plural, other {# elemről}}",
"rename": "Átnevezés",
"repository": "Tároló",
@@ -1985,11 +2002,9 @@
"setting_image_navigation_enable_subtitle": "Ha be van kapcsolva, a képernyő bal, illetve jobb szélének érintésével léphetsz az előző vagy a következő képre.",
"setting_image_navigation_enable_title": "Koppints a navigációhoz",
"setting_image_navigation_title": "Képnavigáció",
"setting_image_viewer_help": "Az elemnézegető először a kis bélyegképet tölti be, majd a közepes méretű előnézetet (ha engedélyezett), végül az eredetit (ha engedélyezett).",
"setting_image_viewer_help": "Az elemnézegető először a kis bélyegképet tölti be, majd a közepes méretű előnézetet, végül az eredetit (ha engedélyezett).",
"setting_image_viewer_original_subtitle": "Engedélyezi az eredeti teljes felbontású kép betöltését (nagy!). Kikapcsolva csökkenti az adathasználatot (a neten és az eszköz gyorsítótárán is).",
"setting_image_viewer_original_title": "Eredeti kép betöltése",
"setting_image_viewer_preview_subtitle": "Engedélyezi a közepes felbontású kép betöltését. Kikapcsolva vagy az eredeti kép töltődik be, vagy csak a bélyegkép.",
"setting_image_viewer_preview_title": "Előnézet betöltése",
"setting_languages_apply": "Alkalmaz",
"setting_languages_subtitle": "Az alkalmazás nyelvének megváltoztatása",
"setting_notifications_notify_minutes": "{count} perc",
@@ -2009,8 +2024,9 @@
"share_everyone": "Minden személy megosztása",
"share_everyone_description": "Hozzáférés biztosítása az összes személyedhez {user} számára.",
"share_link": "Link megosztása",
"share_logs": "Naplók megosztása",
"share_original": "Használd az eredetit (nagy)",
"share_preview": "Használd a miniatűrt (kicsi)",
"share_preview": "Használd az előnèzetet (kicsi)",
"share_quality_body": "A megosztás előtt tartsd lenyomva a megosztás gombot, hogy kiválaszd a képminőséget.",
"share_quality_title": "Válaszd ki a megosztási minőséget",
"share_with": "Hozzáférés megosztása velük",
@@ -2060,7 +2076,6 @@
"shared_link_password_description": "Jelszó megadása szükséges ehhez a megosztott linkhez",
"shared_links": "Megosztott linkek",
"shared_links_description": "Fotók és videók megosztása linkkel",
"shared_photos_and_videos_count": "{assetCount, plural, other {# megosztott kép és videó}}",
"shared_with": "Megosztva velük",
"shared_with_count": "Megosztva ({count})",
"shared_with_me": "Velem megosztva",
@@ -2188,6 +2203,8 @@
"theme": "Téma",
"theme_selection": "Témaválasztás",
"theme_selection_description": "A téma automatikus beállítása világosra vagy sötétre a böngésző rendszerbeállítása alapján",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Görgetés folyamatosan a görgetősáv húzásakor, a hónapokra ugrás helyett",
"theme_setting_asset_list_smooth_scrubbing_title": "Folyamatos, sima idővonal-görgetés",
"theme_setting_asset_list_storage_indicator_title": "Tárhelyjelző megjelenítése az elemeken",
"theme_setting_asset_list_tiles_per_row_title": "Elemek száma soronként ({count})",
"theme_setting_colorful_interface_subtitle": "Elsődleges szín használata a háttérfelületeken.",
@@ -2228,6 +2245,8 @@
"trigger": "Eseményindító",
"trigger_asset_metadata_extraction": "Elem metaadatainak kinyerése",
"trigger_asset_metadata_extraction_description": "Az elem EXIF-adatainak kinyerésekor indul el",
"trigger_asset_ocr_extraction": "Elem OCR-kinyerése",
"trigger_asset_ocr_extraction_description": "Akkor lép működésbe, amikor egy médiatartalom OCR-szövege kinyerésre kerül",
"trigger_asset_tagged": "Elem megcímkézve",
"trigger_asset_tagged_description": "Akkor aktiválódik, amikor egy elemhez címke kerül hozzáadásra",
"trigger_asset_uploaded": "Elem feltöltve",
@@ -2303,7 +2322,7 @@
"user": "Felhasználó",
"user_has_been_deleted": "Ezt a felhasználót töröltük.",
"user_id": "Felhasználó azonosítója",
"user_liked": "{user} felhasználónak {type, select, photo {ez a fotó} video {ez a videó} asset {ez az elem} other {ez}} tetszik",
"user_liked": "{user} kedvelte {type, select, image {ezt a fotót} video {ezt a videót} other {ezt}}",
"user_pin_code_settings": "PIN-kód",
"user_pin_code_settings_description": "PIN-kód kezelése",
"user_privacy": "Felhasználói adatvédelem",
@@ -2339,6 +2358,7 @@
"view_group": "Csoport megtekintése",
"view_in_timeline": "Megtekintés az idővonalon",
"view_link": "Link megtekintése",
"view_more": "Több megjelenítése",
"view_name": "Megtekintés",
"view_next_asset": "Következő elem megtekintése",
"view_previous_asset": "Előző elem megtekintése",
-1
View File
@@ -97,7 +97,6 @@
"search_no_people": "Ոչ մի անձ",
"select_photos": "Ընտրե նկարներ",
"setting_image_viewer_original_title": "Բեռնել բնագիր նկարը",
"setting_image_viewer_preview_title": "Բեռնել նախադիտման նկարը",
"setting_notifications_notify_seconds": "{count} վայրկյան",
"setting_video_viewer_original_video_title": "Ստիպել բնագիր տեսահոլովակը",
"shared_album_activities_input_disable": "Մեկնաբանություններն անջատված են",
-4
View File
@@ -1238,7 +1238,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Gambar}} diambil di {city}, {country} dengan {person1}, {person2}, dan {person3} pada {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Gambar}} diambil di {city}, {country} berisi {person1}, {person2}, dan {additionalCount, number} lainnya pada {date}",
"immich_logo": "Logo Immich",
"immich_web_interface": "Antarmuka Web Immich",
"import_from_json": "Impor dari JSON",
"import_path": "Jalur pengimporan",
"in_albums": "Dalam {count, plural, one {# album} other {# album}}",
@@ -1987,8 +1986,6 @@
"setting_image_viewer_help": "Penampil detail akan terlebih dahulu memuat gambar mini berukuran kecil, kemudian memuat pratinjau berukuran sedang (jika diaktifkan), dan akhirnya memuat file asli (jika diaktifkan).",
"setting_image_viewer_original_subtitle": "Aktifkan untuk memuat gambar asli dengan resolusi penuh (berukuran besar!). Nonaktifkan untuk mengurangi penggunaan data (baik jaringan maupun cache perangkat).",
"setting_image_viewer_original_title": "Muat gambar kualitas asli",
"setting_image_viewer_preview_subtitle": "Aktifkan untuk memuat gambar dengan resolusi sedang. Nonaktifkan jika ingin langsung memuat gambar asli atau hanya ingin memuat gambar kecil.",
"setting_image_viewer_preview_title": "Muat gambar pratinjau",
"setting_languages_apply": "Terapkan",
"setting_languages_subtitle": "Mengubah bahasa pada aplikasi",
"setting_notifications_notify_minutes": "{count} menit",
@@ -2059,7 +2056,6 @@
"shared_link_password_description": "Wajibkan sandi untuk mengakses tautan bersama ini",
"shared_links": "Tautan bersama",
"shared_links_description": "Bagikan foto dan video dengan tautan",
"shared_photos_and_videos_count": "{assetCount, plural, other {# foto & video bersama.}}",
"shared_with": "Dibagikan kepada saya",
"shared_with_count": "Dibagikan ({count})",
"shared_with_me": "Dibagikan kepada saya",
-1
View File
@@ -1171,7 +1171,6 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Myndband} other {Mynd}} í {city}, {country} með {person1}, {person2}, og {person3} á {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} í {city}, {country} með {person1}, {person2}, og {additionalCount, number} öðrum á {date}",
"immich_logo": "Immich merkið",
"immich_web_interface": "Vefviðmót Immich",
"import_from_json": "Flytja inn úr JSON",
"import_path": "Slóð innflutnings",
"in_albums": "Í {count, plural, one {# safni} other {# söfnum}}",
Loaded 100 of 1030 files, more files were not shown because too many files have changed in this diff. Show more