Compare commits

...

282 Commits
v2.2.3 ... main

Author SHA1 Message Date
Timon c9b58f5893
fix(web): auto-start slideshow when confirming settings modal (#24629)
feat(web): auto-start slideshow when confirming settings modal
2025-12-18 21:58:22 +00:00
Timon 640fd7308b
fix(mobile): infinite loading screen when hiding UI in map viewer on iOS (#24563)
* fix with logging

* remove logging

* analyze
2025-12-18 21:07:58 +00:00
shenlong 557a79f747
chore(mobile): log failures from share upload intent (#24680)
chore: log failures from share intent upload

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-18 14:30:55 -06:00
Yaros 5ade152bc5
fix(web): shared link expiry does not save (#24569)
* fix(web): shared link expiry does not save

* chore: fix lint errors

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-18 06:19:31 +00:00
bo0tzz 827bf1ef18
fix: pass bumped version through outputs (#24649) 2025-12-17 17:06:54 -06:00
github-actions a02adbb828 chore: version v2.4.0 2025-12-17 17:44:24 +00:00
Weblate (bot) ab7520c167
chore(web): update translations (#24004)
* chore(web): update translations

Co-authored-by: 100daysummer <bobbydochev@gmail.com>
Co-authored-by: Abhijeet Bonde <abhijeetbonde19@gmail.com>
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Adrián Calleros <acalleros@protonmail.com>
Co-authored-by: Ahmed Khaleel Shihab <ahmed91shihab@gmail.com>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Alberto Serluca <alberto.ser11@gmail.com>
Co-authored-by: Amin <amnsharif@gmail.com>
Co-authored-by: Antonio Jurkić <antoniojurkic@hotmail.com>
Co-authored-by: Aravinth <aravinth@tuta.io>
Co-authored-by: Arno Deceuninck <mc.bluedragon990@gmail.com>
Co-authored-by: Beans <leey0818@gmail.com>
Co-authored-by: Björn Felgner <bjoern@felgner.ch>
Co-authored-by: Bruno Lopes <brandaolopes.dev@gmail.com>
Co-authored-by: CT Ewe <chunte@gmail.com>
Co-authored-by: Cheng Chien <jamesqian1999@gmail.com>
Co-authored-by: Ciprriann <cipriannebeja@gmail.com>
Co-authored-by: Cristi Stoicescu <stoicescucristi93@gmail.com>
Co-authored-by: DERGON <dergonokay@gmail.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: Daniel Rieiro <daniel@danielrieiro.com>
Co-authored-by: Davide Vegliante <davidevegliante@gmail.com>
Co-authored-by: Denis Pacquier <denis.pacquier@gmail.com>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: Dragon Fly <2025dragonfly2025@gmail.com>
Co-authored-by: Dusan Hlavaty <dhlavaty@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Haru Ijima <haruijimakun@gmail.com>
Co-authored-by: Henning <me@unbekannt3.eu>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Hurricane-32 <rodrigorimo@hotmail.com>
Co-authored-by: Hồ Nhất Duy <axicenia@gmail.com>
Co-authored-by: Indrek Haav <indrek.haav@hotmail.com>
Co-authored-by: Ivan Dimitrov <idimitrov08@gmail.com>
Co-authored-by: JIMMY WONG <jim2y512@gmail.com>
Co-authored-by: Javi Marina <javmarina@gmail.com>
Co-authored-by: Javier Villanueva García <jvg2203@gmail.com>
Co-authored-by: Jeppe Nellemann <jepnel@proton.me>
Co-authored-by: Jozef Gaal <preklady@mayday.sk>
Co-authored-by: Julius Lehmann <julius.lehmann.privat@gmail.com>
Co-authored-by: Junghyuk Kwon <kwon@junghy.uk>
Co-authored-by: Kanchnak Khorn <Kanchnakkhorn@gmail.com>
Co-authored-by: Kiril Panayotov <eccyboo@protonmail.com>
Co-authored-by: Koen van Wijnen <koen@van-wijnen.com>
Co-authored-by: Kristján Bjarni Guðmundsson <kristjanbjarni@gmail.com>
Co-authored-by: Leo Bottaro <github@leobottaro.com>
Co-authored-by: Liviu Roman <contact@liviuroman.com>
Co-authored-by: Lucas Jaksys <lucas3033@gmail.com>
Co-authored-by: Lukas Konsin <lukaskonsin@proton.me>
Co-authored-by: Marc Casillas <mcasillassu@gmail.com>
Co-authored-by: Matjaž T. <matjaz@moj-svet.si>
Co-authored-by: Mees Frensel <meesfrensel@gmail.com>
Co-authored-by: Mihai Grama <mihai.grama.81@gmail.com>
Co-authored-by: Mladen Jablanovic <jablan@gmail.com>
Co-authored-by: Mohsin <mohsin.bouhout.inami@gmail.com>
Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Co-authored-by: Nguyen Minh Anh <nguyenminhanh165@gmail.com>
Co-authored-by: Olaf Nielsen <solluh@mail.de>
Co-authored-by: Oleksandr Yurov <oyurov@icloud.com>
Co-authored-by: Petri Hämäläinen <petri.hamalainen@mailbox.org>
Co-authored-by: Philip Goto <philip.goto@gmail.com>
Co-authored-by: PontusÖsterlindh <pontus@osterlindh.com>
Co-authored-by: Prasanth Baskar <bupdprasanth@gmail.com>
Co-authored-by: Radoslav <5v.klas.2019@gmail.com>
Co-authored-by: Rahees <ahdrahees.dev@gmail.com>
Co-authored-by: Rohit <rohitss786@gmail.com>
Co-authored-by: Roi Gabay <roigby@gmail.com>
Co-authored-by: S M, Aravinth (A.) <asm1@ford.com>
Co-authored-by: Severin Engelbracht <s.engelbracht@gmail.com>
Co-authored-by: Shawn <xiaxinx@gmail.com>
Co-authored-by: Sparkle <sparkle@sparklebox.net>
Co-authored-by: Stefan Ovcharov <SeecretA@outlook.com>
Co-authored-by: Stein Milder <info@steinmilder.nl>
Co-authored-by: Styrmir Magnússon <styrmirmag@gmail.com>
Co-authored-by: Sylvain Pichon <service@spichon.fr>
Co-authored-by: TV Box <realceday.tvbox@gmail.com>
Co-authored-by: Taiki M. <vexingly-many-mace@duck.com>
Co-authored-by: Tanishq <weblate.impure434@passinbox.com>
Co-authored-by: Tarsis <br.tmvdl@gmail.com>
Co-authored-by: Tedy25879 <tedy25879@gmail.com>
Co-authored-by: Tek Dara <tekdara@me.com>
Co-authored-by: Temuri Doghonadze <temuri.doghonadze@gmail.com>
Co-authored-by: Tobias Kronthaler <tobias.kronthaler@diemayrei.de>
Co-authored-by: TomVet <dion.tom94@gmail.com>
Co-authored-by: User 123456789 <user123456789@users.noreply.hosted.weblate.org>
Co-authored-by: Vatsal <gajjar.vatsal10602@gmail.com>
Co-authored-by: Vegard Fladby <vegard@fladby.org>
Co-authored-by: Visual Vincent <github-vv@mydoomsite.com>
Co-authored-by: adri1m64 <adrien.melle@laposte.net>
Co-authored-by: chamdim <chamdim@protonmail.com>
Co-authored-by: gablilli <gabriele.lilli0511@gmail.com>
Co-authored-by: idubnori <i.dub.nori@gmail.com>
Co-authored-by: isidorjokull <isidorjokull@gmail.com>
Co-authored-by: jstmrby <jstmrby@gmail.com>
Co-authored-by: l m <virtuamoo@gmail.com>
Co-authored-by: makfreeman <m.a.k.freeman@gmail.com>
Co-authored-by: miiyuh <itsazripp2@gmail.com>
Co-authored-by: pyccl <changcongliang@163.com>
Co-authored-by: rezi nagro <rezinagro@hotmail.com>
Co-authored-by: rubi taz <sisilia.rauzyth@gmail.com>
Co-authored-by: vamshi Thaduri <tvamshi292001@gmail.com>
Co-authored-by: veilside03 <veilside03@gmail.com>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
Co-authored-by: Þorsteinn Jón <thorsteinn-weblate@hb15.is>
Co-authored-by: Дмитро Савушкін <dimas4996@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/immich/immich/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ar/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/az/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/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/es/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/et/
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/gsw/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/gu/
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/hr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hu/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/id/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/is/
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/ka/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/km/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ko/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/lv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ml/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/mr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ms/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/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/sr_Latn/
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/te/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/tr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/uk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ur/
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_Hant/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/
Translation: Immich/immich

* fix: test

---------

Co-authored-by: 100daysummer <bobbydochev@gmail.com>
Co-authored-by: Abhijeet Bonde <abhijeetbonde19@gmail.com>
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Adrián Calleros <acalleros@protonmail.com>
Co-authored-by: Ahmed Khaleel Shihab <ahmed91shihab@gmail.com>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Alberto Serluca <alberto.ser11@gmail.com>
Co-authored-by: Amin <amnsharif@gmail.com>
Co-authored-by: Antonio Jurkić <antoniojurkic@hotmail.com>
Co-authored-by: Aravinth <aravinth@tuta.io>
Co-authored-by: Arno Deceuninck <mc.bluedragon990@gmail.com>
Co-authored-by: Beans <leey0818@gmail.com>
Co-authored-by: Björn Felgner <bjoern@felgner.ch>
Co-authored-by: Bruno Lopes <brandaolopes.dev@gmail.com>
Co-authored-by: CT Ewe <chunte@gmail.com>
Co-authored-by: Cheng Chien <jamesqian1999@gmail.com>
Co-authored-by: Ciprriann <cipriannebeja@gmail.com>
Co-authored-by: Cristi Stoicescu <stoicescucristi93@gmail.com>
Co-authored-by: DERGON <dergonokay@gmail.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: Daniel Rieiro <daniel@danielrieiro.com>
Co-authored-by: Davide Vegliante <davidevegliante@gmail.com>
Co-authored-by: Denis Pacquier <denis.pacquier@gmail.com>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: Dragon Fly <2025dragonfly2025@gmail.com>
Co-authored-by: Dusan Hlavaty <dhlavaty@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Haru Ijima <haruijimakun@gmail.com>
Co-authored-by: Henning <me@unbekannt3.eu>
Co-authored-by: Hurricane-32 <rodrigorimo@hotmail.com>
Co-authored-by: Hồ Nhất Duy <axicenia@gmail.com>
Co-authored-by: Indrek Haav <indrek.haav@hotmail.com>
Co-authored-by: Ivan Dimitrov <idimitrov08@gmail.com>
Co-authored-by: JIMMY WONG <jim2y512@gmail.com>
Co-authored-by: Javi Marina <javmarina@gmail.com>
Co-authored-by: Javier Villanueva García <jvg2203@gmail.com>
Co-authored-by: Jeppe Nellemann <jepnel@proton.me>
Co-authored-by: Jozef Gaal <preklady@mayday.sk>
Co-authored-by: Julius Lehmann <julius.lehmann.privat@gmail.com>
Co-authored-by: Junghyuk Kwon <kwon@junghy.uk>
Co-authored-by: Kanchnak Khorn <Kanchnakkhorn@gmail.com>
Co-authored-by: Kiril Panayotov <eccyboo@protonmail.com>
Co-authored-by: Koen van Wijnen <koen@van-wijnen.com>
Co-authored-by: Kristján Bjarni Guðmundsson <kristjanbjarni@gmail.com>
Co-authored-by: Leo Bottaro <github@leobottaro.com>
Co-authored-by: Liviu Roman <contact@liviuroman.com>
Co-authored-by: Lucas Jaksys <lucas3033@gmail.com>
Co-authored-by: Lukas Konsin <lukaskonsin@proton.me>
Co-authored-by: Marc Casillas <mcasillassu@gmail.com>
Co-authored-by: Matjaž T. <matjaz@moj-svet.si>
Co-authored-by: Mees Frensel <meesfrensel@gmail.com>
Co-authored-by: Mihai Grama <mihai.grama.81@gmail.com>
Co-authored-by: Mladen Jablanovic <jablan@gmail.com>
Co-authored-by: Mohsin <mohsin.bouhout.inami@gmail.com>
Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Co-authored-by: Nguyen Minh Anh <nguyenminhanh165@gmail.com>
Co-authored-by: Olaf Nielsen <solluh@mail.de>
Co-authored-by: Oleksandr Yurov <oyurov@icloud.com>
Co-authored-by: Petri Hämäläinen <petri.hamalainen@mailbox.org>
Co-authored-by: Philip Goto <philip.goto@gmail.com>
Co-authored-by: PontusÖsterlindh <pontus@osterlindh.com>
Co-authored-by: Prasanth Baskar <bupdprasanth@gmail.com>
Co-authored-by: Radoslav <5v.klas.2019@gmail.com>
Co-authored-by: Rahees <ahdrahees.dev@gmail.com>
Co-authored-by: Rohit <rohitss786@gmail.com>
Co-authored-by: Roi Gabay <roigby@gmail.com>
Co-authored-by: S M, Aravinth (A.) <asm1@ford.com>
Co-authored-by: Severin Engelbracht <s.engelbracht@gmail.com>
Co-authored-by: Shawn <xiaxinx@gmail.com>
Co-authored-by: Sparkle <sparkle@sparklebox.net>
Co-authored-by: Stefan Ovcharov <SeecretA@outlook.com>
Co-authored-by: Stein Milder <info@steinmilder.nl>
Co-authored-by: Styrmir Magnússon <styrmirmag@gmail.com>
Co-authored-by: Sylvain Pichon <service@spichon.fr>
Co-authored-by: TV Box <realceday.tvbox@gmail.com>
Co-authored-by: Taiki M. <vexingly-many-mace@duck.com>
Co-authored-by: Tanishq <weblate.impure434@passinbox.com>
Co-authored-by: Tarsis <br.tmvdl@gmail.com>
Co-authored-by: Tedy25879 <tedy25879@gmail.com>
Co-authored-by: Tek Dara <tekdara@me.com>
Co-authored-by: Temuri Doghonadze <temuri.doghonadze@gmail.com>
Co-authored-by: Tobias Kronthaler <tobias.kronthaler@diemayrei.de>
Co-authored-by: TomVet <dion.tom94@gmail.com>
Co-authored-by: User 123456789 <user123456789@users.noreply.hosted.weblate.org>
Co-authored-by: Vatsal <gajjar.vatsal10602@gmail.com>
Co-authored-by: Vegard Fladby <vegard@fladby.org>
Co-authored-by: Visual Vincent <github-vv@mydoomsite.com>
Co-authored-by: adri1m64 <adrien.melle@laposte.net>
Co-authored-by: chamdim <chamdim@protonmail.com>
Co-authored-by: gablilli <gabriele.lilli0511@gmail.com>
Co-authored-by: idubnori <i.dub.nori@gmail.com>
Co-authored-by: isidorjokull <isidorjokull@gmail.com>
Co-authored-by: jstmrby <jstmrby@gmail.com>
Co-authored-by: l m <virtuamoo@gmail.com>
Co-authored-by: makfreeman <m.a.k.freeman@gmail.com>
Co-authored-by: miiyuh <itsazripp2@gmail.com>
Co-authored-by: pyccl <changcongliang@163.com>
Co-authored-by: rezi nagro <rezinagro@hotmail.com>
Co-authored-by: rubi taz <sisilia.rauzyth@gmail.com>
Co-authored-by: vamshi Thaduri <tvamshi292001@gmail.com>
Co-authored-by: veilside03 <veilside03@gmail.com>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
Co-authored-by: Þorsteinn Jón <thorsteinn-weblate@hb15.is>
Co-authored-by: Дмитро Савушкін <dimas4996@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-12-17 17:42:28 +00:00
Alex de1b448639
chore: show Select All button for iOS in backup album selection page (#24647) 2025-12-17 16:41:44 +00:00
Daniel Dietzler c15998e805
fix: asset update race condition (#24384)
* fix: asset update race condition

* fix: asset update race condition

* single statement

* update sql

* missed one

* fix `none` handling

* fix: tests

* chore: simplify update all assets

* fix: updating lockable properties

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-12-17 09:23:13 -06:00
Alex f0b069adb9
fix: shared link expiration and small styling (#24566)
* fix: shared link expiration and small styling

* Use text color of enable/disable shared link properties
2025-12-16 16:41:12 +00:00
Hai Sullivan 276d02e12b
fix(mobile): better UI for metadata panel (#24428)
* change drag bar and animation position

* ensure bottom bar is below the metadata panel - move the bottom bar from bottomNavigationBar into the Stack

* change some parameters

* add background color for night mode

* background color

* change default position

* minor changes

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-16 16:25:01 +00:00
Yaros ded9535434
fix(mobile): local delete missing from sheet on some routes (#24505)
* fix(mobile): local delete missing from album sheet

* chore: remove hasLocal
2025-12-16 09:54:53 -06:00
idubnori 997aec2441
feat: replace heart icons to thumbs-up across activity (#24590)
* feat: replace heart icons to thumbs-up across activity

* fix: update thumb_up icon color to use primaryColor in activity components

* chore: web colors

* chore: modify colors

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-16 15:27:09 +00:00
Ben cb2bd47816
fix(web): immich logo in shared links (#24618)
* fix(web): immich logo in shared links

* chore: apply changes for individual shared link as well

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-12-16 14:59:17 +00:00
renovate[bot] f1c8377ca0
chore(deps): update dependency @types/node to ^24.10.3 (#24605)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 12:23:52 +01:00
Alex 8416397589
chore: revert Svelte 5.43.3 (#24509) 2025-12-16 04:03:53 +00:00
Yaros dc29635b67
chore(mobile): changed default album sort to match with web (#24526)
chore(mobile): matched default album sort with web
2025-12-15 21:18:45 -06:00
Min Idzelis 00290e1e71
feat: make OCR store reentrant-safe (#24419) 2025-12-15 21:06:04 -06:00
Yaros 3ef4c4f315
feat(web): slideshow feature on shared albums (#24598) 2025-12-15 20:49:50 -06:00
idubnori b10a8baf53
feat(mobile): move buttons in the bottom sheet to the kebabu menu (#24175)
* refactor: remove bottom sheet buttons

* feat: add iconOnly and menuItem parameters to action buttons

* feat: enhance action button context and kebab menu integration

* feat: use ActionButtonContext

* fix: add missing options in some cases

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-15 16:44:27 -06:00
Mees Frensel 77926383db
fix(server): only extract image's duration if format supports animation (#24587) 2025-12-15 12:36:46 -05:00
Yaros 35eda735c8
fix(web): recent search doesn't use search type (#24578)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-12-15 12:44:00 +01:00
Diogo Correia 8f7a71d1cf
fix(web): download panel being hidden by admin sidebar (#24583) 2025-12-15 12:29:18 +01:00
Yaros 33cdea88aa
fix(mobile): birthday off by one day on remote (#24527) 2025-12-11 21:23:11 -06:00
Alex 4b345e02ff
fix: refresh appear in list after asset is added to a current or new album (#24523) 2025-12-11 11:06:53 -06:00
Yaros 8cf900bafa
fix(mobile): local videos with '#' don't play on android (#24373)
* fix(mobile): videos with '#' don't play on android

* refactor: one line

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

* fix: depend on platform

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2025-12-11 10:57:37 -06:00
Yaros 59a3f0f455
feat(mobile): create new album from add to modal (#24431)
* feat(mobile): create new album from add to modal

* refactor: use statefulwidget instead of hook

* chore: rename createalbumbutton
2025-12-11 09:47:31 -06:00
Sergey Katsubo c5d99711f7
fix(web): show inferred timezone in date editor (#24513)
fix(web): show inferred timezone of asset in date editor
2025-12-11 09:20:51 -06:00
Yaros 4c0a41723f
feat(web): asset selection bar in tags view (#24522)
* feat(web): asset selection tab in tags view

* chore: remove unused imports
2025-12-11 15:20:29 +00:00
Bart van Velden f73511a754
fix(docs): typo in maintenance mode command (#24518) 2025-12-11 09:19:33 -06:00
hubert-taieb e637387082
fix(server): prevent metadata extraction failures on large video files (#24094)
* prevent  metadata extraction failures on large video files

Increases ExifTool timeout from 20s to 120s to prevent GPS metadata
extraction failures on large video files (>2GB, 10+ minutes).

Issue: Large videos timeout during metadata extraction, causing GPS
coordinates to be lost even though ExifTool can extract them given
enough time.

Testing: 2.6GB, 10:52min video that previously timed out now
successfully extracts GPS metadata.

* redundant comment

Increased task timeout for processing large videos.

* chore: lint

---------

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-12-11 15:18:19 +00:00
renovate[bot] baad38f0e6
fix(deps): update typescript-projects (#24476)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-12-11 00:13:06 +00:00
Min Idzelis 161147af51
feat: timeline-manager improvement to use AssetResponseDto efficiently (#24421) 2025-12-11 01:07:31 +01:00
renovate[bot] cbdf5011f9
chore(deps): update docker.io/valkey/valkey:9 docker digest to fb8d272 (#24474)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:39 +01:00
renovate[bot] f0f1d279c4
chore(deps): update prom/prometheus docker digest to d936808 (#24475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:20 +01:00
renovate[bot] 5821f2fe61
chore(deps): update github-actions (#24477)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:03 +01:00
Noel S 4cbce072be
fix(docs): slow upload speed with example nginx reverse proxy config (#24490)
* increase buffer size

* increase further

* increase buffer further
2025-12-10 22:29:36 +00:00
idubnori 5e5bb7e87d
fix(mobile): versionStatus.message text overflow (#24504) 2025-12-10 16:18:55 -06:00
shenlong b052893a1e
feat(mobile): immich-ui icon button (#24502)
* feat(mobile): immich-ui icon button

* fix lint

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-10 16:18:01 -06:00
Kurt McKee 15e58595fd
fix(mobile): iOS local permission dialog extra whitespace (#24491)
Fix a iOS rendering issue caused by extra whitespace
2025-12-10 16:17:08 -06:00
Alex 6d499c782a
chore: update ui lib (#24483) 2025-12-09 17:27:01 -06:00
idubnori 7af99b8606
feat(mobile): move top bar buttons into kebabu menu in AssetViewer (#24461)
* chore(mobile):  i18n: "open_asset_info" in viewer kebab menu

* feat(mobile): move some top buttons into kebabu menu

* refactor(mobile): viewer kebab menu to use context-based button generation

* feat(mobile): refactor action button and kebab menu to use ConsumerWidget for improved state management

* feat(mobile): pass original theme to ViewerKebabMenu for consistent styling

* chore: styling

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-09 18:26:28 +00:00
Arnau Mora 01e39277e0
feat(mobile): Localized backup upload details page (#21136)
* Localized backup details page

# Conflicts:
#	i18n/en.json

* Format

* format fix

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-09 11:23:01 -06:00
Yaros 06e79703da
fix(mobile): timeline bottom padding on selection (#24480) 2025-12-09 09:19:41 -06:00
Yaros c360781565
fix(mobile): fix overflow text in backup card (#24448)
* fix(mobile): fix overflow text in backup card

* refactor: use intrinsicheight

* chore: fix spelling of entitycounttile
2025-12-09 09:03:29 -06:00
idubnori 287f6d5c94
fix(mobile): buttons inside AddActionButton color is the same as background color (#24460)
* fix: icon & text color in AddActionButton

* fix: use Divider
2025-12-08 14:29:31 -06:00
Simon Kubiak fe9125a3d1
fix(web): [album table view] long album title overflows table row (#24450)
fix(web): long album title overflows vertically on album page in table view
2025-12-08 15:35:58 +00:00
Yaros 8b31936bb6
fix(mobile): cannot create album while name field is focused (#24449)
fix(mobile): create album disabled when focused
2025-12-08 09:33:01 -06:00
Sergey Katsubo 19958dfd83
fix(server): building docker image for different platforms on the same host (#24459)
Fix building docker image for different platforms on the same host

Use per-platform mise cache to avoid 'sh: 1: extism-js: not found'
This happens due to re-using cached installed binary for another platform
2025-12-08 09:15:43 -06:00
Alex 1e1cf0d1fe
fix: build iOS fastlane installation (#24408) 2025-12-06 14:55:53 -06:00
Min Idzelis 879e0ea131
fix: thumbnail doesnt send mouseLeave events properly (#24423) 2025-12-06 21:52:06 +01:00
Sergey Katsubo 42136f9091
fix(server): update exiftool-vendored to v34 for more robust metadata extraction (#24424) 2025-12-06 14:45:59 -06:00
Harrison 1109c32891
fix(docs): websockets in nginx example (#24411)
Co-authored-by: Harrison <frith.harry@gmail.com>
2025-12-06 16:28:12 +00:00
idubnori 3c80049192
chore(mobile): add kebabu menu in asset viewer (#24387)
* feat(mobile): implement viewer kebab menu with about option

* feat: revert exisitng buttons, adjust label name

* unify MenuAnchor usage

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-05 19:51:59 +00:00
Hai Sullivan 8f1669efbe
chore(mobile): smoother UI experience for iOS devices (#24397)
allows the tab pages to use the standard Material page transition during push/pop navigation
2025-12-05 11:02:04 -06:00
Robert Schäfer 146bf65d02
refactor(dev): remove ulimits for rootless docker (#24393)
Description
-----------

When I follow the [developer setup](https://docs.immich.app/developer/setup) I run into a permission error using rootless docker. A while ago I asked on Discord in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327/1442974448776122592) about these ulimits.

I suggest to remove the `ulimits` altogether. It seems that @ItalyPaleAle has left the setting just hoping that it could help somebody in the future. See the [PR description](https://github.com/immich-app/immich/pull/4556).

How Has This Been Tested?
-------------------------

Using rootless docker:

```
$ docker context ls
NAME         DESCRIPTION                               DOCKER ENDPOINT                     ERROR
default                                                unix:///var/run/docker.sock
rootless *                                             unix:///run/user/1000/docker.sock
```

Running `make` will fail because of permission errors:
```
$  docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
...
Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: error setting rlimits for ready process: error setting rlimit type 7: operation not permitted
```

On my machine I have the following hard limit for "Maximum number of open file descriptors":
```
$ ulimit -nH
524288
```

I can confirm that the permission error is caused by the security restrictions of the operating system mentioned above:

Changing `docker/docker-compose.dev.yml` like ..

```
    ulimits:
      nofile:
        soft: 524289
        hard: 524289
```

.. will lead to a permission error whereas this ..

```
    ulimits:
      nofile:
        soft: 524288
        hard: 524288
```

.. starts fine.

Apparently the defaults for these limits are coming from [systemd](26b2085d54/man/systemd.exec.xml (L1122)) which is used on nearly every linux distribution. So my assumption is that almost any linux user who uses rootless docker will run into a permission error when starting the development setup.

Checklist:
----------

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation if applicable
- [x] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
2025-12-05 09:26:20 -05:00
Daniel Dietzler 75a7c9c06c
feat: sql tools array as default value (#24389) 2025-12-04 12:54:20 -05:00
Daniel Dietzler ae8f5a6673
fix: prettier (#24386) 2025-12-04 16:10:42 +00:00
Jason Rasmussen 31f2c7b505
feat: header context menu (#24374) 2025-12-04 11:09:38 -05:00
Yaros ba6687dde9
feat(web): search type selection dropdown (#24091)
* feat(web): search type selection dropdown

* chore: implement suggestions

* lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-04 04:10:12 +00:00
shenlong bbba1bfe8c
fix: use adjustment time in iOS for hash reset (#24047)
* use adjustment time in iOS for hash reset

* migration

* fix equals check

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-03 21:15:58 -06:00
Robert Schäfer 4be9a5ebf8
fix(docs): obsolete docs about rootless docker (#24376)
Description
-----------

The documentation lies about comments in `docker/docker-compose.dev.yml`.

Reason: in 689c6aa276 these docs were added
but the comments in this file are removed in
b9e2590752 and the docs weren't updated.

How Has This Been Tested?
-------------------------
```
$ git log -S rootless

commit b9e2590752
Author: Jason Rasmussen <jason@rasm.me>
Date:   Tue Sep 16 12:48:44 2025 -0400

    chore: simplify (#22082)

commit 689c6aa276
Author: Rudolf Horváth <R-Rudolf@users.noreply.github.com>
Date:   Thu Nov 21 13:25:45 2024 +0100

    docs: add developer notes about rootless docker setup (#13250)
```

Checklist:
----------

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation if applicable
- [x] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
2025-12-03 18:34:08 -06:00
Omar I d41921247b
fix(web): Add minimum content size to logo for consistent visual on small screens (#24372) 2025-12-03 21:35:48 +00:00
Nicholas 853a024f0f
fix: prevent OOM on nginx reverse proxy servers (#24351)
Prevent OOM on reverse proxy servers

Added configuration to disable buffering for uploads.
2025-12-03 14:30:28 -06:00
Alex 4fe494776e
fix: local full sync on Android on resume (#24348) 2025-12-03 20:22:07 +00:00
Justin Forseth 76b4adf276
fix: Adjust the zoom level (#24353)
Adjust the zoom level
2025-12-03 14:19:57 -06:00
Alex 75dde0d076
fix: exposure info and better readability (#24344)
fix: exposure info and better readabilit
2025-12-03 20:19:45 +00:00
Mert cffb68d1c4
fix(server): do not delete offline assets (#24355)
* do not delete isOffline assets

* update sql

* add medium test

* add normal delete test

* formatting
2025-12-03 14:19:26 -06:00
Jason Rasmussen 45f68f73a9
feat: queue detail page (#24352) 2025-12-03 13:39:32 -05:00
renovate[bot] 4f93eda8d8
fix(deps): update typescript-projects (#24329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-12-02 23:28:12 +01:00
Alex f5df5fa98d
chore: change workflow column name (#24349)
chore-change-workflow-column-name
2025-12-02 14:40:17 -06:00
renovate[bot] f07d1441ea
chore(deps): update github-actions (#24331)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 20:13:02 +01:00
Jonathan Jogenfors 1bcf28c062
chore(server): sidecars in asset_files (#21199)
* fix: sidecar check job

* feat: move sidecars to asset_files

* feat: combine with handleSidecarCheck

* fix(server): improved method signatures for stack and sidecar copying

* fix(server): improved method signatures for stack and sidecar copying

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-12-02 13:31:43 -05:00
Jonathan Jogenfors 62628dfcfa
fix(web): folder view sort oder (#24337)
fix: folder view sort oder
2025-12-02 11:48:12 -06:00
Hai Sullivan b11aecd184
fix(mobile): use correct timezone displayed in the info sheet (#24310)
* fixed the timezone issue in the Immich mobile app's metadata sheet to match the web app's behavior

* format dart

* now uses the shared applyTimezoneOffset() utility function from mobile/lib/utils/timezone.dart

* add tests

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-02 16:37:19 +00:00
Jason Rasmussen 116012f6f8
feat: less asset-metadata validation (#24342) 2025-12-02 10:56:31 -05:00
renovate[bot] 7594136050
chore(deps): update dependency express to v5.2.0 [security] (#24323)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 09:27:12 -05:00
renovate[bot] bb341cc774
chore(deps): update docker.io/valkey/valkey docker tag to v9 (#24336)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 09:26:39 -05:00
Dionysius af1d4afb95
fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT (#24335) 2025-12-02 09:25:39 -05:00
renovate[bot] 75b1ef2c57
chore(deps): update machine-learning (#24334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 01:01:17 -05:00
renovate[bot] 1e37f7c8c8
chore(deps): update dependency nodemailer to v7.0.11 [security] (#24330)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 23:16:59 -05:00
Yaros a32f450059
feat(mobile): persist album sorting & layout in settings (#22133)
* fix(mobile): persist album sorting in settings

* fix(mobile): persist album layout

* fix: fixed store model id

* fix: corrupted AppSettingsEnum

* chore: refactor to remove RemoteAlbumSortMode

* refactor: use t instead of tr
2025-12-01 20:51:35 -06:00
carbonemys b452ab463b
fix(web): open onboarding documentation link in new tab (#24289)
* fix(web): open onboarding documentation link in new tab

* Update web/src/lib/components/onboarding-page/onboarding-storage-template.svelte

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-12-01 20:49:31 -06:00
Sergey Katsubo 79bed80226
feat(server): log all thumbnail generation attempts at verbose level (#24324)
Log thumbnail generation at verbose level
2025-12-01 20:26:13 -06:00
Mert 6249996cdb
fix(ml): do not upscale preview (#24322)
do not upscale
2025-12-01 20:26:01 -06:00
Jonathan Jogenfors a3f281caa3
docs(faq): add more info on archiving (#24326)
docs: add more info on archive to faq
2025-12-01 20:25:31 -06:00
Mert 7c19b0591f
fix(server): cjk migration (#24320)
* join string

* use pagination instead
2025-12-01 15:41:19 -06:00
Mert 95c29a8aea
fix(server): use bigrams for cjk (#24285)
* use bigrams for cjk

* update sql

* linting

* actually migrate ocr

* fix backwards test

* use array

* tweaks
2025-12-01 17:24:37 +00:00
idubnori d8ca210641
chore(web): minor UX improvements of "view asset owners" feature (#24319)
* feat: toggle in options modal

* feat(i18n): add labels to display who uploaded each asset and show asset owners

* feat: migrate asset owner settings to TimelineManager and update AlbumOptionsModal

* Revert "feat(i18n): add labels to display who uploaded each asset and show asset owners"

This reverts commit cf8f4eb135.

* fix: simplify AlbumOptionsModal invocation and update aria-label for asset owners

* feat(i18n): add label for viewing asset owners in the interface

* feat: add tests for showAssetOwners functionality in TimelineManager

* chore: move asset owner visibility toggle to kebabu menu
2025-12-01 10:25:12 -06:00
Min Idzelis ab35afd3b1
refactor(web): reimplement operation-support as part of timeline-manager (#24056)
* refactor(web): reimplement operation-support as part of timeline-manager

Improve clarity of methods. 
Add inline method documentation.  
Make return type of AssetOperation optional.

* Review comments - self document code. remove optional return from callback
2025-12-01 09:04:39 -06:00
idubnori 65e4fdf98d
refactor(web): i18n-ize "view asset owners" (#24317) 2025-12-01 15:01:57 +00:00
Matthew Momjian fa43fae2a5
fix(mobile): docs link (#24277)
update docs link
2025-11-30 13:01:33 -06:00
Alex 46afd6a101
fix: only generate memory based on users assets (#24151) 2025-11-30 13:01:12 -06:00
Hai Sullivan 46e1967760
chore: optimisation of several UI components of the mobile app (#24098)
* fix(mobile): normalize scrolling behavior in networking settings

Remove ClampingScrollPhysics from networking settings page to match
the scrolling behavior of other settings pages. This restores the
standard iOS bounce/elastic scrolling effect.

* fix(mobile): use consistent native transitions for Library pages

Change Trash, Shared Links, and Folders routes from CustomRoute to AutoRoute to enable native iOS transitions with swipe-back gesture support.

* fix(mobile): remove SafeArea wrapper and ClampingScrollPhysics from Settings

Remove SafeArea wrapper (Scaffold handles safe areas automatically) and ClampingScrollPhysics to enable native iOS bounce scrolling.

* fix(mobile): remove bottom white space in Sync Status page

Replace Padding wrapper with ListView padding to match other Settings pages and eliminate bottom white space.

* chore: fix Dart formatting

Run dart format to fix formatting issues in settings.page.dart and sync_status_and_actions.dart

* Format Dart files

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: kao-byte <benjaminliu@MacBook-Air.local>
2025-11-30 13:01:01 -06:00
Chris Peckover 922282b2b4
feat(web): Shared album owner labels (#21171)
* - pass available album users along to the thumbnail through the asset-date-group
- show a small user-avatar in bottom right of thumbnail

* - change owner to their name in white text instead of the avatar

* cleanup

* - cleanup albumUsers creation
- use font-light for the user's name

* fix lint

* format

* - add toggle to show/hide asset owner names

* update new Timeline with albumUsers

* add @idubnori suggestion for the name font

* Don't show 'view owners' button if the album doesn't have editors

* add missing import

* format

* fix(web): #21171 (#24298)

fix: Bind timelineManager to Timeline component

---------

Co-authored-by: idubnori <i.dub.nori@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-30 18:56:03 +00:00
Alex e3ab16a5bd
chore: refactor mobile events (#24263)
chore: refactor mobile evets
2025-11-30 12:43:33 -06:00
Niklas von Moers 08f320c801
fix(web): use full tag path when creating nested subtags (#24249) 2025-11-29 12:09:32 +00:00
Mees Frensel e36261b552
fix(web): integrate zoom toggle button into panorama photo viewer (#24189) 2025-11-28 18:50:16 +01:00
Daniel Dietzler c0a3b58bba
fix: rare cases of assets not loading in when scrolling backwards (#24245) 2025-11-28 10:18:49 -06:00
Yaros f12f609038
fix(mobile): enable backup text overflows (#24227) 2025-11-28 10:18:44 -06:00
renovate[bot] 1f6eb662e5
chore(deps): update dependency opentofu to v1.10.7 (#23964)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-28 14:41:23 +00:00
renovate[bot] 0c1fe35f2f
chore(deps): update dependency terragrunt to v0.93.10 (#24149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-28 15:17:25 +01:00
Robert Schäfer e98a33cf9d
fix(docs): build `cli` for e2e tests (#24184) 2025-11-28 15:11:17 +01:00
Dionysius d38305360c
docs: DB_STORAGE_TYPE is only used by the database container (#24215)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-28 12:43:48 +00:00
Tijs 3e3ca4c104
feat(server): exclude syncthing folders from external libraries (#24240)
* Add SyncThing folders to External library exclusion

SyncThing is a popular library for syncing files (like pictures) between systems. It can really mess up your library if an external library, which is also used by SyncThing, is added and these folders are not excluded.

* Plural

* fix formatting

---------

Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>
2025-11-28 11:40:33 +00:00
Jacob Bundgaard 81edf0749f
fix: label 'for' attributes in user-api-key-grid (#24232) 2025-11-27 23:28:38 +00:00
renovate[bot] 01f83ae964
fix(deps): update dependency exiftool-vendored to v33 (#24172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-27 21:45:35 +00:00
renovate[bot] 5eec0dc981
chore(deps): update github-actions (#24038)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-27 21:41:41 +00:00
renovate[bot] ca4fd07656
chore(deps): update dependency eslint-plugin-unicorn to v62 (#24167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-27 21:31:16 +00:00
renovate[bot] 7ce43b3824
chore(deps): update dependency node-gyp to v12 (#24168)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-27 16:13:57 +01:00
Daniel Dietzler ce00119926
chore: update sharp to 0.34.5 (#24170) 2025-11-27 15:13:16 +00:00
Daniel Dietzler fffee80e2f
feat: command palette (#23693) 2025-11-26 22:18:50 +01:00
Jason Rasmussen 64cd4e96e3
fix: theme switcher (#24209) 2025-11-26 21:17:26 +00:00
renovate[bot] 955a3bfaa6
chore(deps): update base-image to v202511261514 (major) (#24165)
chore(deps): update base-image to v202511261514

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-26 15:10:48 -05:00
renovate[bot] e699d8f170
chore(deps): update machine-learning (#23352) 2025-11-26 19:09:39 +00:00
Jason Rasmussen 13104d49cd
feat(web): shared link card tweaks (#24192) 2025-11-25 19:35:21 -06:00
Jason Rasmussen 2d5ec528d5
fix(web): user admin pages (#24185)
fix: user admin pages
2025-11-25 16:35:37 -05:00
Min Idzelis 5226898184
fix: update timeline-manager after archive actions (#24010)
* fix: update timeline-manager after archive actions

* Add locators to thumb icons
2025-11-25 15:06:29 -05:00
Luka Prebil Grintal dd4169876c
fix(ml): Upgrade ONNX Runtime to v1.22.1 to fix ROCm build failures (#24045)
* fix: update ONNX runtime version to 1.21.0 to fix the failing checksum of 1.20.1

* update patch

* update to 1.22.1

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-11-25 13:27:21 -05:00
renovate[bot] 8321c275b8
chore(deps): update dependency body-parser to v2.2.1 [security] (#24179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 18:29:13 +01:00
renovate[bot] 3d6c26350a
fix(deps): update typescript-projects (#24163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-25 17:26:36 +00:00
Jason Rasmussen db15e5e423
fix: duration extraction (#24178) 2025-11-25 10:26:25 -05:00
renovate[bot] 35d18da14a
chore(deps): update node to v24 (major) (#24169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:40:48 +01:00
renovate[bot] cb56a11f0b
chore(deps): update dependency @types/archiver to v7 (#24166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:40:12 +01:00
Jason Rasmussen 104fa09f69
feat: queues (#24142) 2025-11-25 08:19:40 -05:00
Alex 66ae07ee39
fix: don't get OCR data in shared link (#24152) 2025-11-25 07:58:27 -05:00
Daniel Dietzler 939d2c8b27
chore: minor admin pages refactorings (#24160) 2025-11-25 07:57:30 -05:00
renovate[bot] 2801a6e672
chore(deps): update actions/download-artifact action to v6 (#24164)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 13:46:34 +01:00
renovate[bot] 4742360469
chore(deps): update grafana/grafana docker tag to v12.3.0 (#24162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 13:45:50 +01:00
Daniel Dietzler b56fa62b32
fix: revert "chore(deps): update dependency sharp to v0.34.5" (#24173) 2025-11-25 12:37:08 +00:00
renovate[bot] ddbe485074
chore(deps): update dependency sharp to v0.34.5 (#24146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 11:56:11 +01:00
renovate[bot] 01310c6d86
chore(deps): update node.js to v24.11.1 (#24147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 11:55:36 +01:00
Brandon Wees 512327ef69
feat: java in mise (#24154) 2025-11-24 23:18:44 -06:00
Daniel Dietzler 8755cd59fd
chore: refactor svelte reactivity (#24072) 2025-11-24 18:57:46 -05:00
Min Idzelis 7694b342ed
refactor(web): Extract asset grid layout component from TimelineDateGroup and split into AssetLayout and Month components (#23338)
* refactor(web): Extract asset grid layout component from TimelineDateGroup and split into AssetLayout and Month components

* chore: cleanup

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-11-24 23:09:46 +00:00
fabianbees 78553a0258
feat: separate camera and lens info in detail panel (#23670)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-24 16:30:15 +00:00
renovate[bot] c1198b99b7
chore(deps): update dependency js-yaml to v4.1.1 [security] (#23901)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 17:28:18 +01:00
renovate[bot] 8b7b9ee394
chore(deps): update dependency esbuild to ^0.25.0 [security] (#23903)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 17:27:46 +01:00
Min Idzelis d6b39a464d
feat: improve performance: don't sort timeline buckets from server (#24032) 2025-11-24 17:26:52 +01:00
Snowknight26 75d23fe135
fix(web): fix support & feedback modal wrapping (#24018)
* fix(web): fix support & feedback modal wrapping

* Fix reference
2025-11-24 10:24:02 -06:00
shenlong c860809aa1
fix: getAspectRatio fallback to db width and height (#24131)
fix: getExif fallback to db width and height

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-24 10:23:17 -06:00
Daniel Dietzler 0498f6cb9d
fix: albums page reactivity loops (#24046) 2025-11-24 17:14:24 +01:00
shenlong 24e5dabb51
fix: use proper updatedAt value in local assets (#24137)
* fix: incorrect updatedAt value in local assets

* add test

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-24 09:49:27 -06:00
Greg Lutostanski aecf064ec9
fix(server): sanitize DB_URL for pg_dumpall to remove unknown query params (#23333)
Co-authored-by: Greg Lutostanski <greg.lutostanski@mobilityhouse.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-24 16:34:21 +01:00
Daniel Dietzler 57be3ff8c7
fix: add users to album (#24133) 2025-11-24 07:52:36 -05:00
Ujjwal Goel 99505f987e
fix: use npm instead of pnpm and fix `check:all` (#24101)
* fix: use npm instead of pnpm and fix `check:all`

* fix: remove `--` from pnpm commands

* Remove `check:all` from the documentation section
2025-11-23 21:04:43 -06:00
Lukas 1e1c4ac9d2
feat(web): allow navigating the map with arrow keys (#24080) 2025-11-21 23:46:30 +01:00
Mees Frensel d952b62053
feat(web): show detected faces in spherical photos (#23974) 2025-11-21 09:11:47 -06:00
Yaros 9f3eeed091
fix(mobile): first video memory on page doesn't play (#23906)
* fix(mobile): first video memory doesn't play

* refactor: moved logic to static method

* refactor: fix haptic feedback & empty check

* refactor: use DriftMemory on setMemory

* refactor: move video reset into if block
2025-11-21 09:11:30 -06:00
Brandon Wees 1dbc20fd77
fix: show archived assets in favorite page (#24052) 2025-11-21 09:09:16 -06:00
Joren Guillaume ba8df712c4
fix: Use correct app store link (#24062) 2025-11-21 13:54:09 +01:00
renovate[bot] 741d838f56
chore(deps): update base-image to v202511181104 (major) (#24050)
chore(deps): update base-image to v202511181104

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-20 15:40:40 -06:00
Brandon Wees ec2fa6e308
fix: disable animation "add to" action menu (#24040) 2025-11-20 11:54:15 -06:00
shenlong b974ed5735
fix: do not clear hash on updated_at change (#24039)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-20 11:31:17 -06:00
Daniel Dietzler 78457d9b89
chore: add new language requests (#23991) 2025-11-20 08:58:18 -05:00
bo0tzz 5d043b435e
fix: hardcoded build-cache pull for e2e tests (#24002) 2025-11-20 14:22:45 +01:00
Jason Rasmussen 9a403d5886
refactor(web): user delete websocket event (#24015) 2025-11-20 07:54:29 -05:00
Jason Rasmussen 1a31faf1a2
fix: effect loop (#24014) 2025-11-20 07:52:37 -05:00
github-actions edbdc14178 chore: version v2.3.1 2025-11-20 02:20:16 +00:00
Alex e7261a04e1
fix: new update notification cause rendering loop (#24013) 2025-11-19 20:14:30 -06:00
Jason Rasmussen acded69adf
fix: supporter badge (#24012) 2025-11-19 20:14:15 -06:00
github-actions 45a0315606 chore: version v2.3.0 2025-11-19 17:46:53 +00:00
Weblate (bot) 3856d4053c
chore(web): update translations (#23449)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ar/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/be/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bi/
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/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/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/hr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/id/
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/mr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/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/te/
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/uk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/
Translation: Immich/immich

Co-authored-by: 100daysummer <bobbydochev@gmail.com>
Co-authored-by: Abhijeet Bonde <abhijeetbonde19@gmail.com>
Co-authored-by: AbuKareem Tuffaha <abukareem.tuffaha@gmail.com>
Co-authored-by: Adam Uchmanowicz <auchmanowicz@gmail.com>
Co-authored-by: Adrian Jost <github@adrianjost.dev>
Co-authored-by: Aitor-RM <Aitor.Rufian@alu.uclm.es>
Co-authored-by: Alexander Lohnes <alex.lohnes@googlemail.com>
Co-authored-by: Alexis-Loskoutoff <alexis@pctraining.fr>
Co-authored-by: Alma Hassan <almahassan9988@gmail.com>
Co-authored-by: AndreiP28 <andreiprica28@gmail.com>
Co-authored-by: Artur Koziara <arturkoziara@gmail.com>
Co-authored-by: Bryan Saputra <bryananta@icloud.com>
Co-authored-by: Carlo_Mava <carlomavaracchio@gmail.com>
Co-authored-by: Cristian Florin Tănase <crissssty@gmail.com>
Co-authored-by: Cristiano Fagundes <fagundescristianof@gmail.com>
Co-authored-by: Daniel Rieiro <daniel@danielrieiro.com>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fred <freddyfunk@users.noreply.hosted.weblate.org>
Co-authored-by: Hossein Fani <linr@users.noreply.hosted.weblate.org>
Co-authored-by: Hurricane-32 <rodrigorimo@hotmail.com>
Co-authored-by: Indrek Haav <indrek.haav@hotmail.com>
Co-authored-by: Ivan Dimitrov <idimitrov08@gmail.com>
Co-authored-by: Jeppe Nellemann <jepnel@proton.me>
Co-authored-by: Jesús Jiménez <jesjimenez@gmail.com>
Co-authored-by: Johannes Dorn <johannes@dorn.email>
Co-authored-by: Jordy H <jordy@hoebergen.net>
Co-authored-by: Jorge Tristan <trisjor1998@gmail.com>
Co-authored-by: Jozef Gaal <preklady@mayday.sk>
Co-authored-by: Juanma Sanchez <juxmix@gmail.com>
Co-authored-by: Junghyuk Kwon <kwon@junghy.uk>
Co-authored-by: Kai Heine <kai-heine@users.noreply.hosted.weblate.org>
Co-authored-by: Knight Hat <knightchanelgaming@gmail.com>
Co-authored-by: Krissada Singhakachain <46844213+OmsinKrissada@users.noreply.github.com>
Co-authored-by: Leigh van der merwe <palitu822@gmail.com>
Co-authored-by: Leo Bottaro <github@leobottaro.com>
Co-authored-by: Luca Segato <luspy89@hotmail.it>
Co-authored-by: Lucas Jaksys <lucas3033@gmail.com>
Co-authored-by: Luís Nunes <lmcnunes@gmail.com>
Co-authored-by: Macgyver <macgyver@users.noreply.hosted.weblate.org>
Co-authored-by: Marc Casillas <mcasillassu@gmail.com>
Co-authored-by: Marco Perrotta <leondaval18@gmail.com>
Co-authored-by: MatijaThe245th <matija245matakovic@gmail.com>
Co-authored-by: Matjaž T. <matjaz@moj-svet.si>
Co-authored-by: Matteo D. <alex3025game@gmail.com>
Co-authored-by: Matteo De Carli <matteo.de.carli01@gmail.com>
Co-authored-by: Mees Frensel <meesfrensel@gmail.com>
Co-authored-by: Melvin Snijders <mail@melvinsnijders.nl>
Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Co-authored-by: Parms <shoppingpar+weblate@simplelogin.com>
Co-authored-by: Paul <paul.kunad@kabelmail.de>
Co-authored-by: Petri Hämäläinen <petri.hamalainen@mailbox.org>
Co-authored-by: Philip Goto <philip.goto@gmail.com>
Co-authored-by: Pitoune <p.dhebrail@proton.me>
Co-authored-by: Ponas <le.slab124@aleeas.com>
Co-authored-by: PontusÖsterlindh <pontus@osterlindh.com>
Co-authored-by: Rasmus Sehlin <rasmus@sehl.in>
Co-authored-by: Richard Gráčik <r.gracik@gmail.com>
Co-authored-by: Roi Gabay <roigby@gmail.com>
Co-authored-by: Runskrift <anders@rimfrost.nu>
Co-authored-by: Ryan Gleeson <gleeson.ryanj@gmail.com>
Co-authored-by: S M, Aravinth (A.) <asm1@ford.com>
Co-authored-by: Sai Athulith Neela <saiathulithn@gmail.com>
Co-authored-by: Sebastiano <sebastiano.romi@gmail.com>
Co-authored-by: Sergey Katsubo <skatsubo@gmail.com>
Co-authored-by: Shawn <xiaxinx@gmail.com>
Co-authored-by: Sylvain Pichon <service@spichon.fr>
Co-authored-by: TV Box <realceday.tvbox@gmail.com>
Co-authored-by: Tanishq <weblate.impure434@passinbox.com>
Co-authored-by: Tatsuhiko Kono <kono@takenoko.io>
Co-authored-by: Tedy25879 <tedy25879@gmail.com>
Co-authored-by: Thanh Tùng Nguyễn <tung.nguyent03@gmail.com>
Co-authored-by: Toine Rademacher <hi@toine.zip>
Co-authored-by: Tomi Pöyskö <tomi.poysko@gmail.com>
Co-authored-by: User 123456789 <user123456789@users.noreply.hosted.weblate.org>
Co-authored-by: Vegard Fladby <vegard@fladby.org>
Co-authored-by: anton garcias <isaga.percompartir@gmail.com>
Co-authored-by: eav5jhl0 <eav5jhl0@users.noreply.hosted.weblate.org>
Co-authored-by: gablilli <gabriele.lilli0511@gmail.com>
Co-authored-by: kiwinho <kiwicaja@gmail.com>
Co-authored-by: pyccl <changcongliang@163.com>
Co-authored-by: r64 <me@ruka64.dev>
Co-authored-by: ruka-64 <202770393+ruka-64@users.noreply.github.com>
Co-authored-by: sam ng <andy.sam@gmail.com>
Co-authored-by: sh4tteredd <llor22658@gmail.com>
Co-authored-by: shiuh67 <shiuh.cheng@gmail.com>
Co-authored-by: swever <swever@users.noreply.hosted.weblate.org>
Co-authored-by: thehijacker <thehijacker@gmail.com>
Co-authored-by: ti-guru <anders.egeland@outlook.com>
Co-authored-by: ume <bungoume@gmail.com>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
2025-11-19 17:44:39 +00:00
bo0tzz 8175b3b75b
fix: allow adding new translations files (#23998) 2025-11-19 16:00:01 +00:00
Alex 56e431226f
feat: show OCR bounding box (#23717)
* feat: ocr bounding box

* bounding boxes

* pr feedback

* pr feedback

* allow copy across text boxes

* pr feedback
2025-11-19 15:52:40 +00:00
Daniel Dietzler f59417cc77
chore(web): refactor replace asset (#23972) 2025-11-19 08:49:15 -06:00
Min Idzelis 11cec56e80
refactor(web): consolidate timeline API - merge addAssets/updateAssets into upsertAssets (#23985) 2025-11-19 08:48:16 -06:00
bo0tzz 810f22057c
fix: create release as draft (#23996) 2025-11-19 14:46:14 +00:00
Min Idzelis 2152f20b6c
fix: unarchive action doesn't update archive page (#23987) 2025-11-19 12:29:02 +01:00
Matthew Momjian a6c76e78d6
fix(docs): update Readme links (#23959)
* fix(docs): Update star history links and image sources

* Update star history link in README.md
2025-11-18 21:32:11 -06:00
renovate[bot] 644a3bf090
chore(deps): update github-actions (#23962)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 21:32:00 -06:00
Min Idzelis 42dd3315f8
refactor(web): fix TimelineManager import - use value import instead of type-only (#23983) 2025-11-18 21:26:15 -06:00
Kevin Puertas 3a694219bf
feat: add originalPath for external library assets in dedupe (#23710)
* Add original path info row to duplicate asset component

View path of images, useful when using external Library

* Make if for not show path in internal images

* Update web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

* Refactor original path display logic in duplicate-asset

* Update duplicate-asset.svelte

* Add full path localization string

* Change translated data

* format: fix

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-11-19 03:24:17 +00:00
Min Idzelis d9fd52ea18
feat: timeline e2e tests (#23895)
* feat: timeline e2e tests

* Fix flakiness, mock all apis, allow parallel tests

* Upload playwright reports

* wrong report path

* Add CI=true, disable flaky/failing tests

* Re-enable tests, fix worker thread config

* fix maintance e2e test

* increase retries
2025-11-18 21:08:55 -06:00
Brandon Wees 2a281e7906
feat(mobile): location edit from asset viewer (#23925)
* chore: break sheet tile into own file

* feat: set location from bottom sheet

* refactor: location picker

There was a lot of confusing controls here, simplified to 1 mode

* fix: local asset check

* chore: refactoring of location details widget

* fix: update currentAssetExifProvider when changing location

* chore: use SheetTile for location header

* chore: remove coordinate change check

* chore: remove comment
2025-11-18 21:06:51 -06:00
Daniel Dietzler 5f987a95f5
fix: feature flags manager race condition (#23973)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-19 03:05:53 +00:00
bo0tzz edf577d7f7
feat: publish on release pr merge (#23867) 2025-11-18 21:03:49 -06:00
Sergey Katsubo 5e482dabc6
feat(server): support running medium tests in devcontainer (#23882)
* Support running medium tests in devcontainer

* Add "pnpm run test:medium" to the devcontainer doc

* Fix indentation for inline comments in the doc

* Fix a couple of words in the doc
2025-11-18 21:03:21 -06:00
Mees Frensel 76c73549ae
feat(web): always view original of animated images (#23842) 2025-11-18 21:02:52 -06:00
Mees Frensel 271a42ac7f
fix(server): copy relevant panorama tags to preview image (#23953) 2025-11-19 03:02:12 +00:00
bo0tzz 4462952564
fix: proper docker caching for plugin mise deps (#23967)
* fix: proper docker caching for plugin mise deps

* fix: mount mise cache for build too
2025-11-19 03:00:03 +00:00
shenlong 38d4d1a573
chore: reset remote sync on app update (#23969)
reset remote sync on update

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-19 02:55:01 +00:00
Daniel Dietzler d310c6f3cd
feat: library details page (#23908)
* feat: library details page

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-11-18 15:27:41 -05:00
Alex c086a65fa8
chore: update drift (#23877)
* chore: update drift

* update drift dep

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-18 16:07:33 +00:00
renovate[bot] 7134dd29ca
chore(deps): pin ghcr.io/jdx/mise docker tag to ac26f59 (#23961)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 12:21:28 +01:00
renovate[bot] 3e08953a43
chore(deps): update dependency @types/node to ^22.19.1 (#23963)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 12:20:52 +01:00
Min Idzelis 58c3c7e26b
feat: run e2e server in dev mode (#23921)
* feat: run e2e server in dev mode

* Use bash syntax: [[ and ==
2025-11-17 14:16:39 -06:00
Min Idzelis 237ddcb648
fix: incorrect header height calculation in estimated month height (#23923) 2025-11-17 14:14:06 -06:00
Min Idzelis fbaeffd65c
fix: flaky user-admin.e2e-spec.ts (#23929)
* fix: flaky user-admin.e2e-spec.ts

* lint
2025-11-17 14:12:44 -06:00
Min Idzelis d64c339b4f
fix: null dereference when canceling bucket in album (#23924) 2025-11-17 14:12:28 -06:00
Min Idzelis 69880ee165
fix: deep link to last asset (#23920) 2025-11-17 14:12:07 -06:00
Paul Makles 15e00f82f0
feat: maintenance mode (#23431)
* feat: add a `maintenance.enabled` config flag

* feat: implement graceful restart
feat: restart when maintenance config is toggled

* feat: boot a stripped down maintenance api if enabled

* feat: cli command to toggle maintenance mode

* chore: fallback IMMICH_SERVER_URL environment variable in process

* chore: add additional routes to maintenance controller

* fix: don't wait for nest application to close to finish request response

* chore: add a failsafe on restart to prevent other exit codes from preventing restart

* feat: redirect into/from maintenance page

* refactor: use system metadata for maintenance status

* refactor: wait on WebSocket connection to refresh

* feat: broadcast websocket event on server restart
refactor: listen to WS instead of polling

* refactor: bubble up maintenance information instead of hijacking in fetch function
feat: show modal when server is restarting

* chore: increase timeout for ungraceful restart

* refactor: deduplicate code between api/maintenance workers

* fix: skip config check if database is not initialised

* fix: add `maintenanceMode` field to system config test

* refactor: move maintenance resolution code to static method in service

* chore: clean up linter issues

* chore: generate dart openapi

* refactor: use try{} block for maintenance mode check

* fix: logic error in server redirect

* chore: include `maintenanceMode` key in e2e test

* chore: add i18n entries for maintenance screens

* chore: remove negated condition from hook

* fix: should set default value not override in service

* fix: minor error in page

* feat: initial draft of maintenance module, repo., worker controller, worker service

* refactor: move broadcast code into notification service

* chore: connect websocket on client if in maintenance

* chore: set maintenance module app name

* refactor: rename repository to include worker
chore: configure websocket adapter

* feat: reimplement maintenance mode exit with new module

* refactor: add a constant enum for ExitCode

* refactor: remove redundant route for maintenance

* refactor: only spin up kysely on boot (rather than a Nest app)

* refactor(web): move redirect logic into +layout file where modal is setup

* feat: add Maintenance permission

* refactor: merge common code between api/maintenance

* fix: propagate changes from the CLI to servers

* feat: maintenance authentication guard

* refactor: unify maintenance code into repository
feat: add a step to generate maintenance mode token

* feat: jwt auth for maintenance

* refactor: switch from nest jwt to just jsonwebtokens

* feat: log into maintenance mode from CLI command

* refactor: use `secret` instead of `token` in jwt terminology
chore: log maintenance mode login URL on boot
chore: don't make CLI actions reload if already in target state

* docs: initial draft for maintenance mode page

* refactor: always validate the maintenance auth on the server

* feat: add a link to maintenance mode documentation

* feat: redirect users back to the last page they were on when exiting maintenance

* refactor: provide closeFn in both maintenance repos.

* refactor: ensure the user is also redirected by the server

* chore: swap jsonwebtoken for jose

* refactor: introduce AppRestartEvent w/o secret passing

* refactor: use navigation goto

* refactor: use `continue` instead of `next`

* chore: lint fixes for server

* chore: lint fixes for web

* test: add mock for maintenance repository

* test: add base service dependency to maintenance

* chore: remove @types/jsonwebtoken

* refactor: close database connection after startup check

* refactor: use `request#auth` key

* refactor: use service instead of repository
chore: read token from cookie if possible
chore: rename client event to AppRestartV1

* refactor: more concise redirect logic on web

* refactor: move redirect check into utils
refactor: update translation strings to be more sensible

* refactor: always validate login (i.e. check cookie)

* refactor: lint, open-api, remove old dto

* refactor: encode at point of usage

* refactor: remove business logic from repositories

* chore: fix server/web lints

* refactor: remove repository mock

* chore: fix formatting

* test: write service mocks for maintenance mode

* test: write cli service tests

* fix: catch errors when closing app

* fix: always report no maintenance when usual API is available

* test: api e2e maintenance spec

* chore: add response builder

* chore: add helper to set maint. auth cookie

* feat: add SSR to maintenance API

* test(e2e): write web spec for maintenance

* chore: clean up lint issues

* chore: format files

* feat: perform 302 redirect at server level during maintenance

* fix: keep trying to stop immich until it succeeds (CLI issue)

* chore: lint/format

* refactor: annotate references to other services in worker service

* chore: lint

* refactor: remove unnecessary await

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

* refactor: move static methods into util

* refactor: assert secret exists in maintenance worker

* refactor: remove assertion which isn't necessary anymore

* refactor: remove assertion

* refactor: remove outer try {} catch block from loadMaintenanceAuth

* refactor: undo earlier change to vite.config.ts

* chore: update tests due to refactors

* revert: vite.config.ts

* test: expect string jwt

* chore: move blanket exceptions into controllers

* test: update tests according with last change

* refactor: use respondWithCookie
refactor: merge start/end into one route
refactor: rename MaintenanceRepository to AppRepository
chore: use new ApiTag/Endpoint
refactor: apply other requested changes

* chore: regenerate openapi

* chore: lint/format

* chore: remove secureOnly for maint. cookie

* refactor: move maintenance worker code into src/maintenance\nfix: various test fixes

* refactor: use `action` property for setting maint. mode

* refactor: remove Websocket#restartApp in favour of individual methods

* chore: incomplete commit

* chore: remove stray log

* fix: call exitApp from maintenance worker on exit

* fix: add app repository mock

* fix: ensure maintenance cookies are secure

* fix: run playwright tests over secure context (localhost)

* test: update other references to 127.0.0.1

* refactor: use serverSideEmitWithAck

* chore: correct the logic in tryTerminate

* test: juggle cookies ourselves

* chore: fix lint error for e2e spec

* chore: format e2e test

* fix: set cookie secure/non-secure depending on context

* chore: format files

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-11-17 17:15:44 +00:00
Jason Rasmussen ce82e27f4b
fix: workflow medium tests (#23952) 2025-11-17 16:26:30 +00:00
Yaros eeee5147cc
fix(mobile): delete from device warning shows incorrectly (#23935)
fix(mobile): delete warning on multiple assets
2025-11-17 10:17:04 -06:00
100daysummer af22f9b014
fix: word wrap on custom link preview (#23942)
Word break fix in create link

Adds the "break-all" tailwind style to the slug text under the custom link text box
2025-11-17 08:49:32 -05:00
Paul Makles 1086f22166
fix: devcontainer server not starting due to missing plugins mount (#23948) 2025-11-17 12:24:59 +01:00
Christian e94eb5012f
feat(mobile): add to album from asset viewer (#23608)
* feat: add action button in photo viewer for adding assets to albums, archiving, and moving to locked folders

* fix: use const constructors for icons in action button menu

* Update mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart

Co-authored-by: Brandon Wees <brandonwees@gmail.com>

* Update mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart

Co-authored-by: Brandon Wees <brandonwees@gmail.com>

* remove de translation

* fixed PR comments: https://github.com/immich-app/immich/pull/23608

* menu styling

* menu styling

* i18n

---------

Co-authored-by: Brandon Wees <brandonwees@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-14 15:11:47 -06:00
Alex 4dcc049465
feat: workflow foundation (#23621)
* feat: plugins

* feat: table definition

* feat: type and migration

* feat: add repositories

* feat: validate manifest with class-validator and load manifest info to database

* feat: workflow/plugin controller/service layer

* feat: implement workflow logic

* feat: make trigger static

* feat: dynamical instantiate plugin instances

* fix: access control and helper script

* feat: it works

* chore: simplify

* refactor: refactor and use queue for workflow execution

* refactor: remove unsused property in plugin-schema

* build wasm in prod

* feat: plugin loader in transaction

* fix: docker build arm64

* generated files

* shell check

* fix tests

* fix: waiting for migration to finish before loading plugin

* remove context reassignment

* feat: use mise to manage extism tools (#23760)

* pr feedback

* refactor: create workflow now including create filters and actions

* feat: workflow medium tests

* fix: broken medium test

* feat: medium tests

* chore: unify workflow job

* sign user id with jwt

* chore: query plugin with filters and action

* chore: read manifest in repository

* chore: load manifest from server configs

* merge main

* feat: endpoint documentation

* pr feedback

* load plugin from absolute path

* refactor:handle trigger

* throw error and return early

* pr feedback

* unify plugin services

* fix: plugins code

* clean up

* remove triggerConfig

* clean up

* displayName and methodName

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: bo0tzz <git@bo0tzz.me>
2025-11-14 20:05:05 +00:00
Jason Rasmussen d784d431d0
refactor: job vs queue naming (#23902) 2025-11-14 14:42:00 -05:00
Daniel Dietzler 1200bfad13
refactor: server config and feature flags managers (#23894) 2025-11-14 14:10:44 -05:00
Jason Rasmussen f11bfb9581
fix(server): broken memories (#23896) 2025-11-14 11:46:32 -05:00
Daniel Dietzler 074fdb2b96
fix: out of sync pnpm lockfile (#23891) 2025-11-14 12:13:09 +01:00
Daniel Dietzler f1f203719d
refactor: admin settings (#23843) 2025-11-13 13:17:44 -05:00
zebrapurring f73ca9d9c0
chore: build bcrypt dependency from source (#22145)
This may provide better performance on some cases and guarantee cross-platform compatibility

Co-authored-by: zebrapurring <>
2025-11-13 12:12:01 -05:00
renovate[bot] ad3f4fb434
chore(deps): update dependency validator to v13.15.20 [security] (#23284)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-13 17:37:51 +01:00
Juan Roa 8001dedcbf
fix(web): keep album timeline when selecting cover (#23819) 2025-11-13 16:30:24 +00:00
Hritik V 07a39226c5
chore: include link to discord server when referencing contribution channel (#23728)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-13 16:27:26 +00:00
shenlong 88e7e21683
fix: prefer filename from body over path in mime validation (#23810)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-13 17:26:34 +01:00
idubnori 2cefbf8ca3
chore: add "pnpm run migrations:revert" command (#23869) 2025-11-13 10:12:59 -06:00
Jason Rasmussen 4a6c50cd81
feat: endpoint versioning (#23858) 2025-11-13 08:18:43 -05:00
Daniel Dietzler e0535e20e6
chore: cleanup web (#23866) 2025-11-13 12:51:17 +00:00
bo0tzz 62580455af
fix: use changelog file instead of PR body (#23864) 2025-11-13 10:35:30 +00:00
Daniel Dietzler 0e7e67efe1
fix: timeline scroll after navigate (#23664) 2025-11-13 11:28:42 +01:00
Sergey Katsubo 2c54b506b3
fix(server): include the previous year in memories for January 1, 2, 3 (#23832)
* Test memory creation in advance

Use year 2035 to make sure it's in the future of current time of a test run

* Use target year instead of current year when fetching assets during memory creation

This fixes an edge case of creating memories in advance when target year is
different from current year.
Example: job runs on 2025-12-31 (current year is 2025) and creates memories
to be shown on 2026-01-01 (target year is 2026). If using _current_ year in
calculation then range of years is capped at (2025 - 1 = 2024) thus excluding
2025-01-01 from created memories. With _target_ year it is (2026 - 1 = 2025),
so 2025-01-01 will be included in memories.

* Update sql queries
2025-11-12 15:38:03 -06:00
Alex 8969b8bdb2
fix: GHA build issue on iOS (#23849)
* fix: GHA build issue on iOS

* fix: resolve Swift Package dependencies in GitHub Actions

* fix: use Release configuration for iOS build

* fix: simplify code signing for build-only lane

* fix: explicitly resolve Swift packages before building

* fix: use specified XCode version
2025-11-12 15:32:08 -06:00
Alexander Sulfrian 5186092faa
fix: Update module name for rapidocr DownloadFile (#23838) 2025-11-12 18:43:00 +00:00
bo0tzz 4c9142308f
fix: use app token for github-script run (#23852) 2025-11-12 19:16:09 +01:00
bo0tzz bea5d4fd37
fix: release-pr workflow fixes (#23850) 2025-11-12 18:25:32 +01:00
bo0tzz 74c24bfa88
fix: pump-version.sh flags (#23848) 2025-11-12 17:47:52 +01:00
bo0tzz 95834c68d9
fix: bump args order (#23846) 2025-11-12 17:31:25 +01:00
bo0tzz 09024c3558
fix: release-pr script name (#23844) 2025-11-12 16:24:39 +00:00
bo0tzz 137cb043ef
feat: track next release in pull request (#23806) 2025-11-12 17:19:18 +01:00
Mees Frensel edf21bae41
feat(web): disable searching by disabled features (#23798)
fix(web): disable searching by disabled features
2025-11-12 09:19:18 -06:00
shenlong c958f9856d
chore: bump background_downloader (#23839)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-12 09:17:44 -06:00
Daniel Dietzler 70ab8bc657
refactor: shared links modals (#23803) 2025-11-12 12:57:53 +01:00
Jason Rasmussen edde0f93ae
feat: endpoint descriptions (#23813) 2025-11-11 17:01:14 -05:00
Alex 896665bca9
fix: iOS release build dependency verification (#23814) 2025-11-11 15:35:44 -06:00
renovate[bot] e8e9e7830e
chore(deps): update github-actions (major) (#23812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: bo0tzz <git@bo0tzz.me>
2025-11-11 20:04:22 +00:00
Mees Frensel 4fd9e42ce5
feat(web): animate gifs on hover (#23198) 2025-11-11 10:22:53 -06:00
idubnori 337e3a8dac
feat(mobile): album activity deep link (#23737)
* feat: add activity deep link support in DeepLinkService

* test: add unit tests for DeepLinkService handling of activity deep links

* Revert "test: add unit tests for DeepLinkService handling of activity deep links"

This reverts commit 0b1914be9a.
2025-11-11 10:04:54 -06:00
renovate[bot] 2dc81e28fc
chore(deps): update github-actions (#23582)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 16:25:36 +01:00
bo0tzz f915d4cc90
fix: disable ruby updates (#23794)
Until https://github.com/fastlane/fastlane/issues/29183 is fixed
2025-11-11 14:51:21 +00:00
Mees Frensel 905f4375b0
fix(web): make sliding window cover all visible space to show small number of assets (#23796) 2025-11-11 08:50:31 -06:00
David Wolff 0b3633db4f
fix(server): properly handle HEAD requests to SSR paths (#23788) 2025-11-11 07:47:11 -05:00
Jason Rasmussen 2f40f5aad8
refactor: user admin service (#23785) 2025-11-11 07:42:33 -05:00
renovate[bot] 2611e2ec20
chore(deps): update dependency exiftool-vendored to v31.3.0 (#23787)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 13:35:36 +01:00
renovate[bot] 433a3cd339
chore(deps): update dependency @types/node to ^22.19.0 (#23786)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 23:50:50 -05:00
Jason Rasmussen 0b487897a4
refactor: shared link service (#23775) 2025-11-10 16:17:18 -05:00
Jason Rasmussen d5c5bdffcb
refactor: album delete (#23773) 2025-11-10 16:10:29 -05:00
Jason Rasmussen dea95ac2e6
refactor: shared-link service (#23770) 2025-11-10 20:49:02 +00:00
Mert 9e2208b8dd
chore(mobile): add table schemas to swift (#23749)
* add schemas

* handle json, improve type safety

* formatting

* sync variants

* formatting
2025-11-10 20:21:08 +00:00
Alex 6922a92b69
feat: show update version info (#23698)
* feat: show update version info

* Apply suggestions from code review

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-11-10 14:19:27 -06:00
exelix 7a2c8e0662
feat(mobile): Quick date picker in the search page (#22653)
* Quick date picker

* Include current year in quick date picker

* Quick date picker: localization, fix datetime overflows

* Properly localized 'last_months'

* Move quick_date_picker.dart to lib/presentation/widgets/search

* Wrap the quick date picker state into its own class, improve the interaction patterns

* Fix last9Months value

* Improve method naming

* Subtitle for "custom range" in quick date picker

* Fix style warnings

* Fix lint warning

* fix:  mobile unawaited_futures lint (#21661)

* chore: add unawaited_futures lint as warning

* remove unused dcm lints

They will be added back later on a case by case basis

* fix warning

* auto gen file

* review changes

* conflict resolution

---------

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

* Quick date picker

* Wrap the quick date picker state into its own class, improve the interaction patterns

* chore: delete file from rebase

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: bwees <brandonwees@gmail.com>
2025-11-10 13:55:09 -06:00
renovate[bot] 787158247f
fix(deps): update typescript-projects (#23588)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-10 19:50:19 +00:00
idubnori b0a0b7c2e1
feat(mobile): chat-style for asset activity view (#23347)
* feat(mobile): open assetviewer via album activities page

* adjust ui behavior: keep current asset & disable initial forcus

* init of v2...

* refactoring...

* refactor: remove _DismissibleWrapper

* feat: initial scrolling to bottom

* refactor: use feature toggle

* refactor: new route page

* fix: file name, dcm analyze

* fix: test failure

* fix: remove toggle and the exisitng style based on review feedback

* refactorr: rename methods for clarity in comment bubble widget

* feat: (mobile) chat-style asset activity timeline

* chore: extract as a new file

* chore: styling (based on 2c12bc56)

* chore: clean up

* fix: albumActivityProvider parameter

* fix: review point

* fix
2025-11-10 13:26:27 -06:00
idubnori cb6d81771d
fix(mobile): sync album and asset activity state when add/remove asset level activity (#23484)
* fix; sync album-asset state when remove activity

* make build

* fix: support adding case

* make build

* Update mobile/lib/providers/activity.provider.dart

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

* fix: add missing import for collection package

* make build

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2025-11-10 13:25:43 -06:00
Jason Rasmussen 8de6ec1a1b
refactor: album service (#23768) 2025-11-10 13:40:58 -05:00
Daniel Dietzler d27c01ef70
chore: migrate remaining usages of the logo to use the UI lib (#23430) 2025-11-10 19:16:49 +01:00
Noel S d6307b262f
fix(mobile): Hide download button in asset viewer "immersive mode" (#23720)
* Hide download FAB in asset viewer immersive mode

* Remove commented out code

* Remove more comments
2025-11-10 12:13:04 -06:00
Viktor Mykhailiv b2cbefe41e
fix(mobile): Set dynamic height of actions row in BottomSheet (#23755)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-10 12:03:12 -06:00
shenlong da5a72f6de
chore: patch MemoriesResponse (#23764)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-10 11:37:45 -06:00
Jason Rasmussen 45304f1211
refactor: view shared link (#23766) 2025-11-10 12:21:26 -05:00
Jason Rasmussen a4e65a7ea8
refactor: albums-list (#23765) 2025-11-10 11:49:59 -05:00
Daniel Dietzler dd393c8346
feat(web): event handler component (#23763) 2025-11-10 11:49:46 -05:00
Peter Ombodi 493cde9d55
feat: opt-in sync of deletes and restores from web to Android (beta timeline) (#20473)
* feature(mobile, beta, Android): handle remote asset trash/restore events and rescan media
- Handle move to trash and restore from trash for remote assets on Android
- Trigger MediaScannerConnection to rescan affected media files

* feature(mobile, beta, Android): fix rescan

* fix imports

* fix checking conditions

* refactor naming

* fix line breaks

* refactor code
rollback changes in BackgroundServicePlugin

* refactor code (use separate TrashService)

* refactor code

* parallelize restoreFromTrash calls with Future.wait
format trash.provider.dart

* try to re-format trash.provider.dart

* re-format trash.provider.dart

* rename TrashService to TrashSyncService to avoid duplicated names
revert changes in original trash.provider.dart

* refactor code (minor nitpicks)

* process restoreFromTrash sequentially instead of Future.wait

* group local assets by checksum before moving to trash
delete LocalAssetEntity records when moved to trash
refactor code

* fix format

* use checksum for asset restoration
refactro code

* fix format

* sync trash only for backup-selected assets

* feat(db): add local_trashed_asset table and integrate with restoration flow
- Add new `local_trashed_asset` table to store metadata of trashed assets
- Save trashed asset info into `local_trashed_asset` before deletion
- Use `local_trashed_asset` as source for asset restoration
- Implement file restoration by `mediaId`

* resolve merge conflicts

* fix index creating on migration

* rework trashed assets handling
- add new table trashed_local_asset
- mirror trashed assets data in trashed_local_asset.
- compute checksums for assets trashed out-of-app.
- restore assets present in trashed_local_asset and non-trashed in remote_asset.
- simplify moving-to-trash logic based on remote_asset events.

* resolve merge conflicts
use updated approach for calculating checksums

* use CurrentPlatform instead _platform
fix mocks

* revert redundant changes

* Include trashed items in getMediaChanges
Process trashed items delta during incremental sync

* fix merge conflicts

* fix format

* trashed_local_asset table mirror of local_asset table structure
trashed_local_asset<->local_asset transfer data on move to trash or restore
refactor code

* refactor and format code

* refactor TrashedAsset model
fix missed data transfering

* refactor code
remove unused model

* fix label

* fix merge conflicts

* optimize, refactor code
remove redundant code and checking
getTrashedAssetsForAlbum for iOS
tests for hash trashed assets

* format code

* fix migration
fix tests

* fix generated file

* reuse exist checksums on trash data update
handle restoration errors
fix import

* format code

* sync_stream.service depend on repos
refactor assets restoration
update dependencies in tests

* remove trashed asset model
remove trash_sync.service
refactor DriftTrashedLocalAssetRepository, LocalSyncService

* rework fetching trashed assets data on native side
optimize handling trashed assets in local sync service
refactor code

* update NativeSyncApi on iOS side
remove unused code

* optimize sync trashed assets call in full sync mode
refactor code

* fix format

* remove albumIds from getTrashedAssets params
fix upsert in trashed local asset repo
refactor code

* fix getTrashedAssets params

* fix(trash-sync): clean up NativeSyncApiImplBase and correct applyDelta

* refactor(trash-sync): optimize performance and fix minor issues

* refactor(trash-sync): add missed index

* feat(trash-sync): remove sinceLastCheckpoint param from getTrashedAssets

* fix(trash-sync): fix target table

* fix(trash-sync): remove unused extension

* fix(trash-sync): remove unused code

* fix(trash-sync): refactor code

* fix(trash-sync): reformat file

* fix(trash_sync): refactor code

* fix(trash_sync): improve moving to trash

* refactor(trash_sync): integrate MANAGE_MEDIA permission request into login flow and advanced settings

* refactor(trash_sync): add additional checking for experimental trash sync flag and MANAGE_MEDIA permission.

* refactor(trash_sync): resolve merge conflicts

* refactor(trash_sync): fix format

* resolve merge conflicts
add await for alert dialog
add missed request

* refactor(trash_sync): rework MANAGE_MEDIA info widget
show rationale text in permission request alert dialog
refactor setting getter

* fix(trash_sync): restore missing text values

* fix(trash_sync): format file

* fix(trash_sync): check backup enabled and remove remote asset existence check

* fix(trash_sync): remove checking backup enabled
test(trash_sync): cover sync-stream trash/restore paths and dedupe mocks

* test(trash_sync): cover trash/restore flows for local_sync_service

* chore(e2e): restore test-assets submodule pointer

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-10 16:20:51 +00:00
Jason Rasmussen 7705c84b04
refactor(web): album service (#23762) 2025-11-10 11:06:40 -05:00
Matthew Momjian ce0172b8c1
fix(docs): bump docs for PG versions (#23714) 2025-11-10 10:56:18 -05:00
bo0tzz 718b3a7b52
fix: mise tf task scope (#23761) 2025-11-10 15:49:44 +00:00
bo0tzz 8a73de018c
feat: mise monorepo tasks (#23691) 2025-11-10 15:55:15 +01:00
Jonathan Gilbert d92df63f84
feat: random memories sort order (#20025) 2025-11-10 09:38:50 -05:00
Mees Frensel 6c6b00067b
fix(web): i18n for admin>users>sessions (#23756) 2025-11-10 12:48:17 +00:00
Mees Frensel 9cc88ed2a6
feat: make memories slideshow duration configurable (#22783) 2025-11-08 17:46:43 -05:00
renovate[bot] 4905bba694
chore(deps): update base-image to v202511041104 (major) (#23718)
chore(deps): update base-image to v202511041104

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:48:35 -06:00
Noel S 853d19dc2d
fix(mobile): Add fade-in to asset viewer transition (#23692)
Add fade-in animation
2025-11-07 17:13:43 -06:00
Lukas c935ae47d0
feat: lazy load thumbnails on people and place list (#23682)
perf(web): lazy load thumbnails on people and place list
2025-11-07 14:22:02 -06:00
fabianbees 93ab42fa24
feat(mobile): Show lens model information in the asset viewer detail panel (#23601)
* feat(mobile): add lens info to details bottom sheet

* fix unrelated typo

* order same like in web app: first exposure time, than iso
2025-11-07 17:10:59 +00:00
Mert 6913697ad1
feat(ml): multilingual ocr (#23527)
* handle other languages in ml server

* add variants to model selector

* no need to override path

* unused import
2025-11-06 12:58:41 -05:00
Mert a4ae86ce29
feat(ml): add preload and fp16 settings for ocr (#23576) 2025-11-06 17:55:11 +00:00
Snowknight26 2c50f2e244
fix(web): add URLs to results in large files utility (#23617)
fix(web): add URLs to results in large files
2025-11-06 09:24:47 -05:00
shenlong 365abd8906
fix: check if unmetered instead of wifi (#23380)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-05 13:27:38 -06:00
Alex 25fb43bbe3
fix: fully sync local library on app restart (#23323) 2025-11-05 14:09:50 +00:00
bo0tzz 125e8cee01
chore: update config.json example (#23471)
* chore: update config.json example

closes #23465

* fix: format, for real this time
2025-11-05 08:05:53 -06:00
Arnaud Wery c15e9bfa72
fix(web): "select all" button in trash and permanently deleted count (#23594) 2025-11-05 14:05:48 +00:00
Dmitry 35e188e6e7
docs: sync ru docs with main README.md (#23627) 2025-11-05 08:05:03 -06:00
Sergey Katsubo 3cc9dd126c
fix(web): fix timezone dropdown for timestamps lacking milliseconds (#23615)
Fix timezone selector for timestamps without milliseconds
2025-11-05 08:03:55 -06:00
Jason Rasmussen aa69d89b9f
fix: bad merge (#23610) 2025-11-04 16:22:45 -05:00
Jason Rasmussen 29c14a3f58
refactor: database column names (#23356) 2025-11-04 16:03:21 -05:00
Jason Rasmussen 0df70365d7
feat: exif medium tests (#23561) 2025-11-04 16:03:02 -05:00
Mees Frensel c34be73d81
fix(web): consistently use mdiMotionPauseOutline icon (#23595) 2025-11-04 12:12:47 +01:00
renovate[bot] f396e9e374
chore(deps): update prom/prometheus docker digest to 4921475 (#23578)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 11:49:12 +01:00
renovate[bot] 821a9d4691
chore(deps): update redis:6.2-alpine docker digest to 37e0024 (#23579)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 11:48:21 +01:00
renovate[bot] cad654586f
chore(deps): update dependency @types/node to ^22.18.13 (#23581)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 11:47:54 +01:00
930 changed files with 73206 additions and 18479 deletions

View File

@ -29,6 +29,12 @@
]
}
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
// https://github.com/devcontainers/features/issues/1466
"moby": false
}
},
"forwardPorts": [3000, 9231, 9230, 2283],
"portsAttributes": {
"3000": {

View File

@ -21,6 +21,7 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin
immich-web:
env_file: !reset []
immich-machine-learning:

2
.github/.nvmrc vendored
View File

@ -1 +1 @@
24.11.0
24.11.1

10
.github/mise.toml vendored Normal file
View File

@ -0,0 +1,10 @@
[tasks.install]
run = "pnpm install --filter github --frozen-lockfile"
[tasks.format]
env._.path = "./node_modules/.bin"
run = "prettier --check ."
[tasks."format-fix"]
env._.path = "./node_modules/.bin"
run = "prettier --write ."

View File

@ -4,6 +4,6 @@
"format:fix": "prettier --write ."
},
"devDependencies": {
"prettier": "^3.5.3"
"prettier": "^3.7.4"
}
}

View File

@ -96,7 +96,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
@ -108,7 +108,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
- uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
with:
distribution: 'zulu'
java-version: '17'
@ -165,7 +165,7 @@ jobs:
fi
- name: Publish Android Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk
@ -188,13 +188,13 @@ jobs:
needs: pre-job
permissions:
contents: read
# Run on main branch or workflow_dispatch
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true && github.ref == 'refs/heads/main' }}
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
@ -222,6 +222,7 @@ jobs:
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: ./mobile/ios
- name: Install CocoaPods dependencies
@ -229,13 +230,6 @@ jobs:
run: |
pod install
- name: Install Fastlane
working-directory: ./mobile/ios
run: |
gem install bundler
bundle config set --local path 'vendor/bundle'
bundle install
- name: Create API Key
env:
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
@ -303,12 +297,20 @@ jobs:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
GITHUB_REF: ${{ github.ref }}
working-directory: ./mobile/ios
run: |
if [[ "$ENVIRONMENT" == "development" ]]; then
bundle exec fastlane gha_testflight_dev
# Only upload to TestFlight on main branch
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
if [[ "$ENVIRONMENT" == "development" ]]; then
bundle exec fastlane gha_testflight_dev
else
bundle exec fastlane gha_release_prod
fi
else
bundle exec fastlane gha_release_prod
# Build only, no TestFlight upload for non-main branches
bundle exec fastlane gha_build_only
fi
- name: Clean up keychain
@ -317,7 +319,7 @@ jobs:
security delete-keychain build.keychain || true
- name: Upload IPA artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ios-release-ipa
path: mobile/ios/Runner.ipa

View File

@ -25,7 +25,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check out code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@ -35,7 +35,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@ -44,7 +44,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
@ -78,13 +78,13 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
@ -105,7 +105,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
flavor: |
latest=false

View File

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

View File

@ -50,14 +50,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with:
category: '/language:${{matrix.language}}'

View File

@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "mich"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions:
contents: read
actions: read
@ -155,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions:
contents: read
actions: read

View File

@ -60,7 +60,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@ -69,7 +69,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'
@ -85,7 +85,7 @@ jobs:
run: pnpm build
- name: Upload build output
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: docs-build-output
path: docs/build/

View File

@ -125,7 +125,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@ -174,7 +174,7 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs'
run: 'mise run tf apply'
run: 'mise run //deployment:tf apply'
- name: Deploy Docs Subdomain Output
id: docs-output
@ -186,7 +186,7 @@ jobs:
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs'
run: |
mise run tf output -- -json | jq -r '
mise run //deployment:tf output -- -json | jq -r '
"projectName=\(.pages_project_name.value)",
"subdomain=\(.immich_app_branch_subdomain.value)"
' >> $GITHUB_OUTPUT
@ -211,7 +211,7 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs-release'
run: 'mise run tf apply'
run: 'mise run //deployment:tf apply'
- name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0

View File

@ -23,7 +23,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@ -39,7 +39,7 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs'
run: 'mise run tf destroy -- -refresh=false'
run: 'mise run //deployment:tf destroy -- -refresh=false'
- name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0

View File

@ -16,13 +16,13 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: 'Checkout'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }}
@ -32,7 +32,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

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

View File

@ -45,30 +45,31 @@ jobs:
needs: [merge_translations]
outputs:
ref: ${{ steps.push-tag.outputs.commit_long_sha }}
version: ${{ steps.output.outputs.version }}
permissions: {} # No job-level permissions are needed because it uses the app-token
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@ -80,13 +81,16 @@ jobs:
MOBILE_BUMP: ${{ inputs.mobileBump }}
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
- id: output
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
- name: Commit and tag
id: push-tag
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
with:
default_author: github_actions
message: 'chore: version ${{ env.IMMICH_VERSION }}'
tag: ${{ env.IMMICH_VERSION }}
message: 'chore: version ${{ steps.output.outputs.version }}'
tag: ${{ steps.output.outputs.version }}
push: true
build_mobile:
@ -119,35 +123,35 @@ jobs:
prepare_release:
runs-on: ubuntu-latest
needs: build_mobile
needs: [build_mobile, bump_version]
permissions:
actions: read # To download the app artifact
# No content permissions are needed because it uses the app-token
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
- name: Download APK
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}
tag_name: ${{ needs.bump_version.outputs.version }}
token: ${{ steps.generate-token.outputs.token }}
generate_release_notes: true
body_path: misc/release/notes.tmpl

170
.github/workflows/release-pr.yml vendored Normal file
View File

@ -0,0 +1,170 @@
name: Manage release PR
on:
workflow_dispatch:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions: {}
jobs:
bump:
runs-on: ubuntu-latest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Determine release type
id: bump-type
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
with:
token: ${{ steps.generate-token.outputs.token }}
- name: Bump versions
env:
TYPE: ${{ steps.bump-type.outputs.bump }}
run: |
if [ "$TYPE" == "none" ]; then
exit 1 # TODO: Is there a cleaner way to abort the workflow?
fi
misc/release/pump-version.sh -s $TYPE -m true
- name: Manage Outline release document
id: outline
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const fs = require('fs');
const outlineKey = process.env.OUTLINE_API_KEY;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
let documentId;
let documentUrl;
let documentText;
if (!document) {
// Create new document
console.log('No existing document found. Creating new one...');
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'next',
text: notesTmpl,
collectionId: collectionId,
parentDocumentId: parentDocumentId,
publish: true
})
});
if (!createResponse.ok) {
throw new Error(`Failed to create document: ${createResponse.statusText}`);
}
const createData = await createResponse.json();
documentId = createData.data.id;
const urlId = createData.data.urlId;
documentUrl = `${baseUrl}/doc/next-${urlId}`;
documentText = createData.data.text || '';
console.log(`Created new document: ${documentUrl}`);
} else {
documentId = document.id;
const docPath = document.url;
documentUrl = `${baseUrl}${docPath}`;
documentText = document.text || '';
console.log(`Found existing document: ${documentUrl}`);
}
// Generate GitHub release notes
console.log('Generating GitHub release notes...');
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `${process.env.NEXT_VERSION}`,
});
// Combine the content
const changelog = `
# ${process.env.NEXT_VERSION}
${documentText}
${releaseNotesResponse.data.body}
---
`
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
core.setOutput('document_url', documentUrl);
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
labels: 'changelog:skip'
branch: 'release/next'
draft: true

148
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,148 @@
name: release.yml
on:
pull_request:
types: [closed]
paths:
- CHANGELOG.md
jobs:
# Maybe double check PR source branch?
merge_translations:
uses: ./.github/workflows/merge-translations.yml
permissions:
pull-requests: write
secrets:
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
build_mobile:
uses: ./.github/workflows/build-mobile.yml
needs: merge_translations
permissions:
contents: read
secrets:
KEY_JKS: ${{ secrets.KEY_JKS }}
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
# iOS secrets
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
with:
ref: main
environment: production
prepare_release:
runs-on: ubuntu-latest
needs: build_mobile
permissions:
actions: read # To download the app artifact
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
ref: main
- name: Extract changelog
id: changelog
run: |
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }}
body_path: ${{ steps.changelog.outputs.path }}
draft: true
files: |
docker/docker-compose.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
docker/prometheus.yml
*.apk
- name: Rename Outline document
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
continue-on-error: true
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
VERSION: ${{ steps.changelog.outputs.version }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const outlineKey = process.env.OUTLINE_API_KEY;
const version = process.env.VERSION;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
if (document) {
console.log(`Found document 'next', renaming to '${version}'...`);
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: document.id,
title: version
})
});
if (!updateResponse.ok) {
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
}
} else {
console.log('No document titled "next" found to rename');
}

View File

@ -22,7 +22,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@ -55,7 +55,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@ -69,7 +69,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@ -77,7 +77,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@ -114,14 +114,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@ -161,14 +161,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@ -203,14 +203,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@ -247,14 +247,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@ -285,14 +285,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@ -333,14 +333,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@ -379,14 +379,15 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@ -417,7 +418,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
submodules: 'recursive'
@ -425,7 +426,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@ -472,7 +473,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
submodules: 'recursive'
@ -480,7 +481,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@ -499,8 +500,16 @@ jobs:
run: docker compose build
if: ${{ !cancelled() }}
- name: Run e2e tests (web)
env:
CI: true
run: npx playwright test
if: ${{ !cancelled() }}
- name: Archive test results
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
success-check-e2e:
name: End-to-End Tests Success
needs: [e2e-tests-server-cli, e2e-tests-web]
@ -525,7 +534,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@ -557,13 +566,13 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with:
# python-version: 3.11
@ -601,14 +610,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
@ -630,7 +639,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@ -652,14 +661,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@ -714,14 +723,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@ -36,8 +36,7 @@ jobs:
github-token: ${{ steps.token.outputs.token }}
filters: |
i18n:
- 'i18n/!(en)**\.json'
exclude-branches: 'chore/translations'
- modified: 'i18n/!(en)**\.json'
skip-force-logic: 'true'
enforce-lock:

View File

@ -52,7 +52,7 @@
},
"cSpell.words": ["immich"],
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "svelte"],
"eslint.validate": ["javascript", "typescript", "svelte"],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",

View File

@ -17,6 +17,9 @@ dev-docs:
e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
e2e-dev:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans

View File

@ -118,16 +118,16 @@ Read more about translations [here](https://docs.immich.app/developer/translatio
## Star history
<a href="https://star-history.com/#immich-app/immich&Date">
<a href="https://star-history.com/#immich-app/immich&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=date" width="100%" />
</picture>
</a>
## Contributors
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<a href="https://github.com/immich-app/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>

View File

@ -1 +1 @@
24.11.0
24.11.1

View File

@ -1,4 +1,4 @@
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS core
WORKDIR /usr/src/app
COPY package* pnpm* .pnpmfile.cjs ./

29
cli/mise.toml Normal file
View File

@ -0,0 +1,29 @@
[tasks.install]
run = "pnpm install --filter @immich/cli --frozen-lockfile"
[tasks.build]
env._.path = "./node_modules/.bin"
run = "vite build"
[tasks.test]
env._.path = "./node_modules/.bin"
run = "vite"
[tasks.lint]
env._.path = "./node_modules/.bin"
run = "eslint \"src/**/*.ts\" --max-warnings 0"
[tasks."lint-fix"]
run = { task = "lint --fix" }
[tasks.format]
env._.path = "./node_modules/.bin"
run = "prettier --check ."
[tasks."format-fix"]
env._.path = "./node_modules/.bin"
run = "prettier --write ."
[tasks.check]
env._.path = "./node_modules/.bin"
run = "tsc --noEmit"

View File

@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.101",
"version": "2.2.104",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.18.12",
"@types/node": "^24.10.3",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@ -28,10 +28,10 @@
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
@ -69,6 +69,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.11.0"
"node": "24.11.1"
}
}

View File

@ -299,7 +299,7 @@ describe('crawl', () => {
.map(([file]) => file);
// Compare file's content instead of path since a file can be represent in multiple ways.
expect(actual.map((path) => readContent(path)).sort()).toEqual(expected.sort());
expect(actual.map((path) => readContent(path)).toSorted()).toEqual(expected.toSorted());
});
}
});

View File

@ -160,7 +160,7 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
ignore: [`**/${exclusionPattern}`],
});
globbedFiles.push(...crawledFiles);
return globbedFiles.sort();
return globbedFiles.toSorted();
};
export const sha1 = (filepath: string) => {

View File

@ -9,7 +9,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"target": "es2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,

20
deployment/mise.toml Normal file
View File

@ -0,0 +1,20 @@
[tools]
terragrunt = "0.93.10"
opentofu = "1.10.7"
[tasks."tg:fmt"]
run = "terragrunt hclfmt"
description = "Format terragrunt files"
[tasks.tf]
run = "terragrunt run --all"
description = "Wrapper for terragrunt run-all"
dir = "{{cwd}}"
[tasks."tf:fmt"]
run = "tofu fmt -recursive tf/"
description = "Format terraform files"
[tasks."tf:init"]
run = { task = "tf init -- -reconfigure" }
dir = "{{cwd}}"

View File

@ -41,6 +41,7 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin
env_file:
- .env
environment:
@ -57,10 +58,6 @@ services:
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
ulimits:
nofile:
soft: 1048576
hard: 1048576
ports:
- 9230:9230
- 9231:9231
@ -99,10 +96,6 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
ulimits:
nofile:
soft: 1048576
hard: 1048576
restart: unless-stopped
depends_on:
immich-server:
@ -134,7 +127,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck:
test: redis-cli ping || exit 1

View File

@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck:
test: redis-cli ping || exit 1
restart: always
@ -83,7 +83,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:23031bfe0e74a13004252caaa74eccd0d62b6c6e7a04711d5b8bf5b7e113adc7
image: prom/prometheus@sha256:d936808bdea528155c0154a922cd42fd75716b8bb7ba302641350f9f3eaeba09
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
@ -95,7 +95,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.2.1-ubuntu@sha256:797530c642f7b41ba7848c44cfda5e361ef1f3391a98bed1e5d448c472b6826a
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
volumes:
- grafana-data:/var/lib/grafana

View File

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

View File

@ -1 +1 @@
24.11.0
24.11.1

View File

@ -133,9 +133,9 @@ There are a few different scenarios that can lead to this situation. The solutio
The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc.,
the job may not have run automatically the first time.
### How can I hide photos from the timeline?
### How can I hide a photo or video from the timeline?
You can _archive_ them.
You can _archive_ them. This will hide the asset from the main timeline and folder view, but it will still show up in searches. All archived assets can be found in the _Archive_ view
### How can I backup data from Immich?

View File

@ -41,7 +41,7 @@ By default, Immich will keep the last 14 database dumps and create a new dump ev
#### Trigger Dump
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/jobs-status).
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/queues).
Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm".
A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder.
This dumps will count towards the last `X` dumps that will be kept based on your settings.

View File

@ -0,0 +1,18 @@
# Maintenance Mode
Maintenance mode is used to perform administrative tasks such as restoring backups to Immich.
You can enter maintenance mode by either:
- Selecting "enable maintenance mode" in system settings in administration.
- Running the enable maintenance mode [administration command](./server-commands.md).
## Logging in during maintenance
Maintenance mode uses a separate login system which is handled automatically behind the scenes in most cases. Enabling maintenance mode in settings will automatically log you into maintenance mode when the server comes back up.
If you find that you've been logged out, you can:
- Open the logs for the Immich server and look for _"🚧 Immich is in maintenance mode, you can log in using the following URL:"_
- Run the enable maintenance mode [administration command](./server-commands.md) again, this will give you a new URL to login with.
- Run the disable maintenance mode [administration command](./server-commands.md) then re-enter through system settings.

View File

@ -10,16 +10,19 @@ Running with a pre-existing Postgres server can unlock powerful administrative f
## Prerequisites
You must install `pgvector` (`>= 0.7.0, < 1.0.0`), as it is a prerequisite for `vchord`.
You must install pgvector as it is a prerequisite for VectorChord.
The easiest way to do this on Debian/Ubuntu is by adding the [PostgreSQL Apt repository][pg-apt] and then
running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`).
You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`.
:::note
Immich is known to work with Postgres versions `>= 14, < 18`.
:::note Supported versions
Immich is known to work with Postgres versions `>= 14, < 19`.
Make sure the installed version of VectorChord is compatible with your version of Immich. The current accepted range for VectorChord is `>= 0.3.0, < 0.5.0`.
VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`.
The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found.
The current accepted range for VectorChord is `>= 0.3, < 0.6`.
:::
## Specifying the connection URL

View File

@ -21,6 +21,12 @@ server {
# allow large file uploads
client_max_body_size 50000M;
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
proxy_request_buffering off;
# increase body buffer to avoid limiting upload speed
client_body_buffer_size 1024k;
# Set headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@ -29,8 +35,6 @@ server {
# enable websockets: http://nginx.org/en/docs/http/websocket.html
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
# set timeout
@ -40,6 +44,8 @@ server {
location / {
proxy_pass http://<backend_url>:2283;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# useful when using Let's Encrypt http-01 challenge

View File

@ -2,17 +2,19 @@
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
| Command | Description |
| ------------------------ | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
| Command | Description |
| -------------------------- | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `disable-maintenance-mode` | Disable maintenance mode |
| `enable-maintenance-mode` | Enable maintenance mode |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
## How to run a command
@ -47,6 +49,23 @@ immich-admin enable-password-login
Password login has been enabled.
```
Disable Maintenance Mode
```
immich-admin disable-maintenance-mode
Maintenance mode has been disabled.
```
Enable Maintenance Mode
```
immich-admin enable-maintenance-mode
Maintenance mode has been enabled.
Log in using the following URL:
https://my.immich.app/maintenance?token=<token>
```
Enable OAuth login
```

View File

@ -12,3 +12,13 @@ pnpm run migrations:generate <migration-name>
3. Move the migration file to folder `./server/src/schema/migrations` in your code editor.
The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately.
## Reverting a Migration
If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run:
```bash
pnpm run migrations:revert
```
This command rolls back the latest migration and brings the database schema back to its previous state.

View File

@ -256,7 +256,7 @@ The Dev Container supports multiple ways to run tests:
```bash
# Run tests for specific components
make test-server # Server unit tests
make test-server # Server unit tests
make test-web # Web unit tests
make test-e2e # End-to-end tests
make test-cli # CLI tests
@ -268,12 +268,13 @@ make test-all # Runs tests for all components
make test-medium-dev # End-to-end tests
```
#### Using NPM Directly
#### Using PNPM Directly
```bash
# Server tests
cd /workspaces/immich/server
pnpm test # Run all tests
pnpm test # Run all tests
pnpm run test:medium # Medium tests (integration tests)
pnpm run test:watch # Watch mode
pnpm run test:cov # Coverage report
@ -293,21 +294,21 @@ pnpm run test:web # Run web UI tests
```bash
# Linting
make lint-server # Lint server code
make lint-web # Lint web code
make lint-all # Lint all components
make lint-web # Lint web code
make lint-all # Lint all components
# Formatting
make format-server # Format server code
make format-web # Format web code
make format-all # Format all code
make format-web # Format web code
make format-all # Format all code
# Type checking
make check-server # Type check server
make check-web # Type check web
make check-all # Check all components
make check-web # Type check web
make check-all # Check all components
# Complete hygiene check
make hygiene-all # Runs lint, format, check, SQL sync, and audit
make hygiene-all # Run lint, format, check, SQL sync, and audit
```
### Additional Make Commands
@ -315,21 +316,21 @@ make hygiene-all # Runs lint, format, check, SQL sync, and audit
```bash
# Build commands
make build-server # Build server
make build-web # Build web app
make build-all # Build everything
make build-web # Build web app
make build-all # Build everything
# API generation
make open-api # Generate OpenAPI specs
make open-api # Generate OpenAPI specs
make open-api-typescript # Generate TypeScript SDK
make open-api-dart # Generate Dart SDK
make open-api-dart # Generate Dart SDK
# Database
make sql # Sync database schema
make sql # Sync database schema
# Dependencies
make install-server # Install server dependencies
make install-web # Install web dependencies
make install-all # Install all dependencies
make install-server # Install server dependencies
make install-web # Install web dependencies
make install-all # Install all dependencies
```
### Debugging

View File

@ -14,15 +14,15 @@ When contributing code through a pull request, please check the following:
- [ ] `pnpm run check:typescript` (check typescript)
- [ ] `pnpm test` (unit tests)
:::tip AIO
Run all web checks with `pnpm run check:all`
:::
## Documentation
- [ ] `pnpm run format` (formatting via Prettier)
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
:::tip AIO
Run all web checks with `pnpm run check:all`
:::
## Server Checks
- [ ] `pnpm run lint` (linting via ESLint)

View File

@ -5,7 +5,7 @@ sidebar_position: 2
# Setup
:::note
If there's a feature you're planning to work on, just give us a heads up in [Discord](https://discord.com/channels/979116623879368755/1071165397228855327) so we can:
If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can:
1. Let you know if it's something we would accept into Immich
2. Provide any guidance on how something like that would ideally be implemented
@ -48,7 +48,6 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
**Notes:**
- The "web" development container runs with uid 1000. If that uid does not have read/write permissions on the mounted volumes, you may encounter errors
- In case of rootless docker setup, you need to use root within the container, otherwise you will encounter read/write permission related errors, see comments in `docker/docker-compose.dev.yml`.
#### Connect web to a remote backend

View File

@ -18,6 +18,7 @@ make e2e
Before you can run the tests, you need to run the following commands _once_:
- `pnpm install` (in `e2e/`)
- `pnpm run build` (in `cli/`)
- `make open-api` (in the project root `/`)
Once the test environment is running, the e2e tests can be run via:

View File

@ -1222,4 +1222,4 @@ Feel free to make a feature request if there's a model you want to use that we d
[huggingface-clip]: https://huggingface.co/collections/immich-app/clip-654eaefb077425890874cd07
[huggingface-multilingual-clip]: https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7
[smart-search-settings]: https://my.immich.app/admin/system-settings?isOpen=machine-learning+smart-search
[job-status-page]: https://my.immich.app/admin/jobs-status
[job-status-page]: https://my.immich.app/admin/queues

View File

@ -106,14 +106,14 @@ SELECT "user"."email", "asset"."type", COUNT(*) FROM "asset"
```sql title="Count by tag"
SELECT "t"."value" AS "tag_name", COUNT(*) AS "number_assets" FROM "tag" "t"
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id"
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id"
WHERE "a"."visibility" != 'hidden'
GROUP BY "t"."value" ORDER BY "number_assets" DESC;
```
```sql title="Count by tag (per user)"
SELECT "t"."value" AS "tag_name", "u"."email" as "user_email", COUNT(*) AS "number_assets" FROM "tag" "t"
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
WHERE "a"."visibility" != 'hidden'
GROUP BY "t"."value", "u"."email" ORDER BY "number_assets" DESC;
```

View File

@ -53,7 +53,7 @@ Version mismatches between both hosts may cause bugs and instability, so remembe
Adding a new URL to the settings is recommended over replacing the existing URL. This is because it will allow machine learning tasks to be processed successfully when the remote server is down by falling back to the local machine learning container. If you do not want machine learning tasks to be processed locally when the remote server is not available, you can instead replace the existing URL and only provide the remote container's URL. If doing this, you can remove the `immich-machine-learning` section of the local `docker-compose.yml` file to save resources, as this service will never be used.
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/jobs-status) page for the jobs to be retried.
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/queues) page for the jobs to be retried.
## Load balancing

View File

@ -16,48 +16,76 @@ The default configuration looks like this:
```json
{
"ffmpeg": {
"crf": 23,
"threads": 0,
"preset": "ultrafast",
"targetVideoCodec": "h264",
"acceptedVideoCodecs": ["h264"],
"targetAudioCodec": "aac",
"acceptedAudioCodecs": ["aac", "mp3", "libopus", "pcm_s16le"],
"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": false
},
"backup": {
"database": {
"enabled": true,
"cronExpression": "0 02 * * *",
"enabled": true,
"keepLastAmount": 14
}
},
"ffmpeg": {
"accel": "disabled",
"accelDecode": false,
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
"acceptedContainers": ["mov", "ogg", "webm"],
"acceptedVideoCodecs": ["h264"],
"bframes": -1,
"cqMode": "auto",
"crf": 23,
"gopSize": 0,
"maxBitrate": "0",
"preferredHwDevice": "auto",
"preset": "ultrafast",
"refs": 0,
"targetAudioCodec": "aac",
"targetResolution": "720",
"targetVideoCodec": "h264",
"temporalAQ": false,
"threads": 0,
"tonemap": "hable",
"transcode": "required",
"twoPass": false
},
"image": {
"colorspace": "p3",
"extractEmbedded": false,
"fullsize": {
"enabled": false,
"format": "jpeg",
"quality": 80
},
"preview": {
"format": "jpeg",
"quality": 80,
"size": 1440
},
"thumbnail": {
"format": "webp",
"quality": 80,
"size": 250
}
},
"job": {
"backgroundTask": {
"concurrency": 5
},
"smartSearch": {
"faceDetection": {
"concurrency": 2
},
"library": {
"concurrency": 5
},
"metadataExtraction": {
"concurrency": 5
},
"faceDetection": {
"concurrency": 2
"migration": {
"concurrency": 5
},
"notifications": {
"concurrency": 5
},
"ocr": {
"concurrency": 1
},
"search": {
"concurrency": 5
@ -65,20 +93,23 @@ The default configuration looks like this:
"sidecar": {
"concurrency": 5
},
"library": {
"concurrency": 5
},
"migration": {
"concurrency": 5
"smartSearch": {
"concurrency": 2
},
"thumbnailGeneration": {
"concurrency": 3
},
"videoConversion": {
"concurrency": 1
}
},
"library": {
"scan": {
"cronExpression": "0 0 * * *",
"enabled": true
},
"notifications": {
"concurrency": 5
"watch": {
"enabled": false
}
},
"logging": {
@ -86,8 +117,11 @@ The default configuration looks like this:
"level": "log"
},
"machineLearning": {
"enabled": true,
"urls": ["http://immich-machine-learning:3003"],
"availabilityChecks": {
"enabled": true,
"interval": 30000,
"timeout": 2000
},
"clip": {
"enabled": true,
"modelName": "ViT-B-32__openai"
@ -96,27 +130,59 @@ The default configuration looks like this:
"enabled": true,
"maxDistance": 0.01
},
"enabled": true,
"facialRecognition": {
"enabled": true,
"modelName": "buffalo_l",
"minScore": 0.7,
"maxDistance": 0.5,
"minFaces": 3
}
"minFaces": 3,
"minScore": 0.7,
"modelName": "buffalo_l"
},
"ocr": {
"enabled": true,
"maxResolution": 736,
"minDetectionScore": 0.5,
"minRecognitionScore": 0.8,
"modelName": "PP-OCRv5_mobile"
},
"urls": ["http://immich-machine-learning:3003"]
},
"map": {
"darkStyle": "https://tiles.immich.cloud/v1/style/dark.json",
"enabled": true,
"lightStyle": "https://tiles.immich.cloud/v1/style/light.json",
"darkStyle": "https://tiles.immich.cloud/v1/style/dark.json"
},
"reverseGeocoding": {
"enabled": true
"lightStyle": "https://tiles.immich.cloud/v1/style/light.json"
},
"metadata": {
"faces": {
"import": false
}
},
"newVersionCheck": {
"enabled": true
},
"nightlyTasks": {
"clusterNewFaces": true,
"databaseCleanup": true,
"generateMemories": true,
"missingThumbnails": true,
"startTime": "00:00",
"syncQuotaUsage": true
},
"notifications": {
"smtp": {
"enabled": false,
"from": "",
"replyTo": "",
"transport": {
"host": "",
"ignoreCert": false,
"password": "",
"port": 587,
"secure": false,
"username": ""
}
}
},
"oauth": {
"autoLaunch": false,
"autoRegister": true,
@ -128,70 +194,44 @@ The default configuration looks like this:
"issuerUrl": "",
"mobileOverrideEnabled": false,
"mobileRedirectUri": "",
"profileSigningAlgorithm": "none",
"roleClaim": "immich_role",
"scope": "openid email profile",
"signingAlgorithm": "RS256",
"profileSigningAlgorithm": "none",
"storageLabelClaim": "preferred_username",
"storageQuotaClaim": "immich_quota"
"storageQuotaClaim": "immich_quota",
"timeout": 30000,
"tokenEndpointAuthMethod": "client_secret_post"
},
"passwordLogin": {
"enabled": true
},
"reverseGeocoding": {
"enabled": true
},
"server": {
"externalDomain": "",
"loginPageMessage": "",
"publicUsers": true
},
"storageTemplate": {
"enabled": false,
"hashVerificationEnabled": true,
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
},
"image": {
"thumbnail": {
"format": "webp",
"size": 250,
"quality": 80
},
"preview": {
"format": "jpeg",
"size": 1440,
"quality": 80
},
"colorspace": "p3",
"extractEmbedded": false
},
"newVersionCheck": {
"enabled": true
},
"trash": {
"enabled": true,
"days": 30
"templates": {
"email": {
"albumInviteTemplate": "",
"albumUpdateTemplate": "",
"welcomeTemplate": ""
}
},
"theme": {
"customCss": ""
},
"library": {
"scan": {
"enabled": true,
"cronExpression": "0 0 * * *"
},
"watch": {
"enabled": false
}
},
"server": {
"externalDomain": "",
"loginPageMessage": ""
},
"notifications": {
"smtp": {
"enabled": false,
"from": "",
"replyTo": "",
"transport": {
"ignoreCert": false,
"host": "",
"port": 587,
"username": "",
"password": ""
}
}
"trash": {
"days": 30,
"enabled": true
},
"user": {
"deleteDelay": 7

View File

@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work
## Ports
| Variable | Description | Default |
| :------------ | :------------- | :----------------------------------------: |
| `IMMICH_HOST` | Listening host | `0.0.0.0` |
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) |
| Variable | Description | Default | Containers |
| :------------ | :------------- | :----------------------------------------: | :----------------------- |
| `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning |
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning |
## Database
@ -80,7 +80,7 @@ Information on the current workers can be found [here](/administration/jobs-work
| `DB_SSL_MODE` | Database SSL mode | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
@ -93,7 +93,7 @@ Information on the current workers can be found [here](/administration/jobs-work
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
`DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`.
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&sslmode=no-verify`.
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&uselibpqcompat=true`. This allows both immich and `pg_dumpall` (the utility used for database backups) to [properly connect](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections) to your database.
When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored.
@ -149,29 +149,31 @@ Redis (Sentinel) URL example JSON before encoding:
## Machine Learning
| Variable | Description | Default | Containers |
| :---------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
| Variable | Description | Default | Containers |
| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning |
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.

25
docs/mise.toml Normal file
View File

@ -0,0 +1,25 @@
[tasks.install]
run = "pnpm install --filter documentation --frozen-lockfile"
[tasks.start]
env._.path = "./node_modules/.bin"
run = "docusaurus --port 3005"
[tasks.build]
env._.path = "./node_modules/.bin"
run = [
"jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
"docusaurus build",
]
[tasks.preview]
env._.path = "./node_modules/.bin"
run = "docusaurus serve"
[tasks.format]
env._.path = "./node_modules/.bin"
run = "prettier --check ."
[tasks."format-fix"]
env._.path = "./node_modules/.bin"
run = "prettier --write ."

View File

@ -38,7 +38,7 @@
"@docusaurus/module-type-aliases": "~3.9.0",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.7.0",
"prettier": "^3.2.4",
"prettier": "^3.7.4",
"typescript": "^5.1.6"
},
"browserslist": {
@ -57,6 +57,6 @@
"node": ">=20"
},
"volta": {
"node": "24.11.0"
"node": "24.11.1"
}
}

View File

@ -1,4 +1,16 @@
[
{
"label": "v2.4.0",
"url": "https://docs.v2.4.0.archive.immich.app"
},
{
"label": "v2.3.1",
"url": "https://docs.v2.3.1.archive.immich.app"
},
{
"label": "v2.3.0",
"url": "https://docs.v2.3.0.archive.immich.app"
},
{
"label": "v2.2.3",
"url": "https://docs.v2.2.3.archive.immich.app"

1
e2e/.gitignore vendored
View File

@ -4,3 +4,4 @@ node_modules/
/blob-report/
/playwright/.cache/
/dist
.env

View File

@ -1 +1 @@
24.11.0
24.11.1

105
e2e/docker-compose.dev.yml Normal file
View File

@ -0,0 +1,105 @@
name: immich-e2e
services:
immich-server:
container_name: immich-e2e-server
command: ['immich-dev']
image: immich-server-dev:latest
build:
context: ../
dockerfile: server/Dockerfile.dev
target: dev
environment:
- DB_HOSTNAME=database
- DB_USERNAME=postgres
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=immich
- IMMICH_MACHINE_LEARNING_ENABLED=false
- IMMICH_TELEMETRY_INCLUDE=all
- IMMICH_ENV=testing
- IMMICH_PORT=2285
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
volumes:
- ./test-assets:/test-assets
- ..:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin
depends_on:
redis:
condition: service_started
database:
condition: service_healthy
immich-web:
container_name: immich-e2e-web
image: immich-web-dev:latest
build:
context: ../
dockerfile: server/Dockerfile.dev
target: dev
command: ['immich-web']
ports:
- 2285:3000
environment:
- IMMICH_SERVER_URL=http://immich-server:2285/
volumes:
- ..:/usr/src/app
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
restart: unless-stopped
redis:
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: immich
ports:
- 5435:5432
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
interval: 1s
timeout: 5s
retries: 30
start_period: 10s
volumes:
model-cache:
prometheus-data:
grafana-data:
pnpm-store:
server-node_modules:
web-node_modules:
github-node_modules:
cli-node_modules:
docs-node_modules:
e2e-node_modules:
sdk-node_modules:
app-node_modules:
sveltekit:
coverage:

View File

@ -7,6 +7,9 @@ services:
build:
context: ../
dockerfile: server/Dockerfile
cache_from:
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-arm64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
args:
- BUILD_ID=1234567890
- BUILD_IMAGE=e2e
@ -35,7 +38,7 @@ services:
- 2285:2285
redis:
image: redis:6.2-alpine@sha256:77697a75da9f94e9357b61fcaf8345f69e3d9d32e9d15032c8415c21263977dc
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338

29
e2e/mise.toml Normal file
View File

@ -0,0 +1,29 @@
[tasks.install]
run = "pnpm install --filter immich-e2e --frozen-lockfile"
[tasks.test]
env._.path = "./node_modules/.bin"
run = "vitest --run"
[tasks."test-web"]
env._.path = "./node_modules/.bin"
run = "playwright test"
[tasks.format]
env._.path = "./node_modules/.bin"
run = "prettier --check ."
[tasks."format-fix"]
env._.path = "./node_modules/.bin"
run = "prettier --write ."
[tasks.lint]
env._.path = "./node_modules/.bin"
run = "eslint \"src/**/*.ts\" --max-warnings 0"
[tasks."lint-fix"]
run = { task = "lint --fix" }
[tasks.check]
env._.path = "./node_modules/.bin"
run = "tsc --noEmit"

View File

@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.2.3",
"version": "2.4.0",
"description": "",
"main": "index.js",
"type": "module",
@ -20,30 +20,32 @@
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/js": "^9.8.0",
"@faker-js/faker": "^10.1.0",
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.18.12",
"@types/node": "^24.10.3",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
"dotenv": "^17.2.3",
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0",
"exiftool-vendored": "^31.1.0",
"eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^34.0.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",
"oidc-provider": "^9.0.0",
"pg": "^8.11.3",
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.4",
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^5.3.3",
@ -52,6 +54,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.11.0"
"node": "24.11.1"
}
}

View File

@ -1,23 +1,50 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices, PlaywrightTestConfig } from '@playwright/test';
import dotenv from 'dotenv';
import { cpus } from 'node:os';
import { resolve } from 'node:path';
export default defineConfig({
dotenv.config({ path: resolve(import.meta.dirname, '.env') });
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
export const playwriteSlowMo = 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/web/specs',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
retries: process.env.CI ? 4 : 0,
reporter: 'html',
use: {
baseURL: 'http://127.0.0.1:2285',
baseURL: playwriteBaseUrl,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
launchOptions: {
slowMo: playwriteSlowMo,
},
},
testMatch: /.*\.e2e-spec\.ts/,
workers: process.env.CI ? 4 : Math.round(cpus().length * 0.75),
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.e2e-spec\.ts/,
workers: 1,
},
{
name: 'parallel tests',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.parallel-e2e-spec\.ts/,
fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
},
// {
@ -59,4 +86,8 @@ export default defineConfig({
stderr: 'pipe',
reuseExistingServer: true,
},
});
};
if (playwrightDisableWebserver) {
delete config.webServer;
}
export default defineConfig(config);

View File

@ -15,7 +15,6 @@ import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import sharp from 'sharp';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
@ -41,40 +40,6 @@ const today = DateTime.fromObject({
}) as DateTime<true>;
const yesterday = today.minus({ days: 1 });
const createTestImageWithExif = async (filename: string, exifData: Record<string, any>) => {
// Generate unique color to ensure different checksums for each image
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
// Create a 100x100 solid color JPEG using Sharp
const imageBytes = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r, g, b },
},
})
.jpeg({ quality: 90 })
.toBuffer();
// Add random suffix to filename to avoid collisions
const uniqueFilename = filename.replace('.jpg', `-${randomBytes(4).toString('hex')}.jpg`);
const filepath = join(tempDir, uniqueFilename);
await writeFile(filepath, imageBytes);
// Filter out undefined values before writing EXIF
const cleanExifData = Object.fromEntries(Object.entries(exifData).filter(([, value]) => value !== undefined));
await exiftool.write(filepath, cleanExifData);
// Re-read the image bytes after EXIF has been written
const finalImageBytes = await readFile(filepath);
return { filepath, imageBytes: finalImageBytes, filename: uniqueFilename };
};
describe('/asset', () => {
let admin: LoginResponseDto;
let websocket: Socket;
@ -1140,16 +1105,6 @@ describe('/asset', () => {
},
},
},
{
input: 'metadata/gps-position/empty_gps.jpg',
expected: {
type: AssetTypeEnum.Image,
exifInfo: {
latitude: null,
longitude: null,
},
},
},
];
it.each(tests)(`should upload and generate a thumbnail for different file types`, async ({ input, expected }) => {
@ -1259,411 +1214,6 @@ describe('/asset', () => {
});
});
describe('EXIF metadata extraction', () => {
describe('Additional date tag extraction', () => {
describe('Date-time vs time-only tag handling', () => {
it('should fall back to file timestamps when only time-only tags are available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('time-only-fallback.jpg', {
TimeCreated: '2023:11:15 14:30:00', // Time-only tag, should not be used for dateTimeOriginal
// Exclude all date-time tags to force fallback to file timestamps
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
GPSDateTime: undefined,
DateTimeUTC: undefined,
SonyDateTime2: undefined,
GPSDateStamp: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
it('should prefer DateTimeOriginal over time-only tags', async () => {
const { imageBytes, filename } = await createTestImageWithExif('datetime-over-time.jpg', {
DateTimeOriginal: '2023:10:10 10:00:00', // Should be preferred
TimeCreated: '2023:11:15 14:30:00', // Should be ignored (time-only)
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use DateTimeOriginal, not TimeCreated
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-10-10T10:00:00.000Z').getTime(),
);
});
});
describe('GPSDateTime tag extraction', () => {
it('should extract GPSDateTime with GPS coordinates', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datetime.jpg', {
GPSDateTime: '2023:11:15 12:30:00Z',
GPSLatitude: 37.7749,
GPSLongitude: -122.4194,
// Exclude other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(37.7749, 4);
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-122.4194, 4);
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-11-15T12:30:00.000Z').getTime(),
);
});
});
describe('CreateDate tag extraction', () => {
it('should extract CreateDate when available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('create-date.jpg', {
CreateDate: '2023:11:15 10:30:00',
// Exclude other higher priority date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
GPSDateTime: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-11-15T10:30:00.000Z').getTime(),
);
});
});
describe('GPSDateStamp tag extraction', () => {
it('should fall back to file timestamps when only date-only tags are available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp.jpg', {
GPSDateStamp: '2023:11:15', // Date-only tag, should not be used for dateTimeOriginal
// Note: NOT including GPSTimeStamp to avoid automatic GPSDateTime creation
GPSLatitude: 51.5074,
GPSLongitude: -0.1278,
// Explicitly exclude all testable date-time tags to force fallback to file timestamps
DateTimeOriginal: undefined,
CreateDate: undefined,
CreationDate: undefined,
GPSDateTime: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(51.5074, 4);
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-0.1278, 4);
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
});
/*
* NOTE: The following EXIF date tags are NOT effectively usable with JPEG test files:
*
* NOT WRITABLE to JPEG:
* - MediaCreateDate: Can be read from video files but not written to JPEG
* - DateTimeCreated: Read-only tag in JPEG format
* - DateTimeUTC: Cannot be written to JPEG files
* - SonyDateTime2: Proprietary Sony tag, not writable to JPEG
* - SubSecMediaCreateDate: Tag not defined for JPEG format
* - SourceImageCreateTime: Non-standard insta360 tag, not writable to JPEG
*
* WRITABLE but NOT READABLE from JPEG:
* - SubSecDateTimeOriginal: Can be written but not read back from JPEG
* - SubSecCreateDate: Can be written but not read back from JPEG
*
* EFFECTIVELY TESTABLE TAGS (writable and readable):
* - DateTimeOriginal
* - CreateDate
* - CreationDate
* - GPSDateTime
*
* The metadata service correctly handles non-readable tags and will fall back to
* file timestamps when only non-readable tags are present.
*/
describe('Date tag priority order', () => {
it('should respect the complete date tag priority order', async () => {
// Test cases using only EFFECTIVELY TESTABLE tags (writable AND readable from JPEG)
const testCases = [
{
name: 'DateTimeOriginal has highest priority among testable tags',
exifData: {
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-04-04T04:00:00.000Z',
},
{
name: 'CreationDate when DateTimeOriginal missing',
exifData: {
CreationDate: '2023:05:05 05:00:00', // TESTABLE
CreateDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-05-05T05:00:00.000Z',
},
{
name: 'CreationDate when standard EXIF tags missing',
exifData: {
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-07-07T07:00:00.000Z',
},
{
name: 'GPSDateTime when no other testable date tags present',
exifData: {
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
Make: 'SONY',
},
expectedDate: '2023-10-10T10:00:00.000Z',
},
];
for (const testCase of testCases) {
const { imageBytes, filename } = await createTestImageWithExif(
`${testCase.name.replaceAll(/\s+/g, '-').toLowerCase()}.jpg`,
testCase.exifData,
);
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal, `Failed for: ${testCase.name}`).toBeDefined();
expect(
new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime(),
`Date mismatch for: ${testCase.name}`,
).toBe(new Date(testCase.expectedDate).getTime());
}
});
});
describe('Edge cases for date tag handling', () => {
it('should fall back to file timestamps with GPSDateStamp alone', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp-only.jpg', {
GPSDateStamp: '2023:08:08', // Date-only tag, should not be used for dateTimeOriginal
// Intentionally no GPSTimeStamp
// Exclude all other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
GPSDateTime: undefined,
DateTimeUTC: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
it('should handle all testable date tags present to verify complete priority order', async () => {
const { imageBytes, filename } = await createTestImageWithExif('all-testable-date-tags.jpg', {
// All TESTABLE date tags to JPEG format (writable AND readable)
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
// Note: Excluded non-testable tags:
// SubSec tags: writable but not readable from JPEG
// Non-writable tags: MediaCreateDate, DateTimeCreated, DateTimeUTC, SonyDateTime2, etc.
// Time-only/date-only tags: already excluded from EXIF_DATE_TAGS
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use DateTimeOriginal as it has the highest priority among testable tags
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-04-04T04:00:00.000Z').getTime(),
);
});
it('should use CreationDate when SubSec tags are missing', async () => {
const { imageBytes, filename } = await createTestImageWithExif('creation-date-priority.jpg', {
CreationDate: '2023:07:07 07:00:00', // WRITABLE
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE
// Note: DateTimeCreated, DateTimeUTC, SonyDateTime2 are NOT writable to JPEG
// Note: TimeCreated and GPSDateStamp are excluded from EXIF_DATE_TAGS (time-only/date-only)
// Exclude SubSec and standard EXIF tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
CreateDate: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use CreationDate when available
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-07-07T07:00:00.000Z').getTime(),
);
});
it('should skip invalid date formats and use next valid tag', async () => {
const { imageBytes, filename } = await createTestImageWithExif('invalid-date-handling.jpg', {
// Note: Testing invalid date handling with only WRITABLE tags
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - Valid date
CreationDate: '2023:13:13 13:00:00', // WRITABLE - Valid date
// Note: TimeCreated excluded (time-only), DateTimeCreated not writable to JPEG
// Exclude other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
CreateDate: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should skip invalid dates and use the first valid one (GPSDateTime)
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-10-10T10:00:00.000Z').getTime(),
);
});
});
});
});
describe('POST /assets/exist', () => {
it('ignores invalid deviceAssetIds', async () => {
const response = await utils.checkExistingAssets(user1.accessToken, {

View File

@ -1,4 +1,4 @@
import { JobCommand, JobName, LoginResponseDto, updateConfig } from '@immich/sdk';
import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich/sdk';
import { cpSync, rmSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
@ -17,28 +17,28 @@ describe('/jobs', () => {
describe('PUT /jobs', () => {
afterEach(async () => {
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
command: QueueCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.FaceDetection, {
command: QueueCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.SmartSearch, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.SmartSearch, {
command: QueueCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.DuplicateDetection, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.DuplicateDetection, {
command: QueueCommand.Resume,
force: false,
});
@ -59,8 +59,8 @@ describe('/jobs', () => {
it('should queue metadata extraction for missing assets', async () => {
const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Pause,
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
command: QueueCommand.Pause,
force: false,
});
@ -77,20 +77,20 @@ describe('/jobs', () => {
expect(asset.exifInfo?.make).toBeNull();
}
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Empty,
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
command: QueueCommand.Empty,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
command: QueueCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Start,
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
command: QueueCommand.Start,
force: false,
});
@ -124,8 +124,8 @@ describe('/jobs', () => {
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path);
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Start,
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
command: QueueCommand.Start,
force: false,
});
@ -144,8 +144,8 @@ describe('/jobs', () => {
it('should queue thumbnail extraction for assets missing thumbs', async () => {
const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`;
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Pause,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Pause,
force: false,
});
@ -153,32 +153,32 @@ describe('/jobs', () => {
assetData: { bytes: await readFile(path), filename: basename(path) },
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
expect(assetBefore.thumbhash).toBeNull();
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Empty,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Empty,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Start,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Start,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
expect(assetAfter.thumbhash).not.toBeNull();
@ -193,26 +193,26 @@ describe('/jobs', () => {
assetData: { bytes: await readFile(path), filename: basename(path) },
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path);
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Resume,
force: false,
});
// This runs the missing thumbnail job
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Start,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Start,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);

View File

@ -1006,7 +1006,7 @@ describe('/libraries', () => {
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],

View File

@ -0,0 +1,172 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/admin/maintenance', () => {
let cookie: string | undefined;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
});
// => outside of maintenance mode
describe('GET ~/server/config', async () => {
it('should indicate we are out of maintenance mode', async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
expect(body.maintenanceMode).toBeFalsy();
});
});
describe('POST /login', async () => {
it('should not work out of maintenance mode', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not in maintenance mode'));
});
});
// => enter maintenance mode
describe.sequential('POST /', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/admin/maintenance').send({
action: 'end',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send({ action: 'end' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should be a no-op if try to exit maintenance mode', async () => {
const { status } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ action: 'end' });
expect(status).toBe(201);
});
it('should enter maintenance mode', async () => {
const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'start',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
expect(cookie).toEqual(
expect.stringMatching(/^immich_maintenance_token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/),
);
await expect
.poll(
async () => {
const { body } = await request(app).get('/server/config');
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
},
)
.toBeTruthy();
});
});
// => in maintenance mode
describe.sequential('in maintenance mode', () => {
describe('GET ~/server/config', async () => {
it('should indicate we are in maintenance mode', async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
expect(body.maintenanceMode).toBeTruthy();
});
});
describe('POST /login', async () => {
it('should fail without cookie or token in body', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token'));
});
it('should succeed with cookie', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').set('cookie', cookie!).send({});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
username: 'Immich Admin',
}),
);
});
it('should succeed with token', async () => {
const { status, body } = await request(app)
.post('/admin/maintenance/login')
.send({
token: cookie!.split('=')[1].trim(),
});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
username: 'Immich Admin',
}),
);
});
});
describe('POST /', async () => {
it('should be a no-op if try to enter maintenance mode', async () => {
const { status } = await request(app)
.post('/admin/maintenance')
.set('cookie', cookie!)
.send({ action: 'start' });
expect(status).toBe(201);
});
});
});
// => exit maintenance mode
describe.sequential('POST /', () => {
it('should exit maintenance mode', async () => {
const { status } = await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});
expect(status).toBe(201);
await expect
.poll(
async () => {
const { body } = await request(app).get('/server/config');
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
},
)
.toBeFalsy();
});
});
});

View File

@ -136,6 +136,7 @@ describe('/server', () => {
externalDomain: '',
publicUsers: true,
isOnboarded: false,
maintenanceMode: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});

View File

@ -1,6 +1,6 @@
import {
JobName,
LoginResponseDto,
QueueName,
createStack,
deleteUserAdmin,
getMyUser,
@ -328,7 +328,7 @@ describe('/admin/users', () => {
{ headers: asBearerAuth(user.accessToken) },
);
await utils.waitForQueueFinish(admin.accessToken, JobName.BackgroundTask);
await utils.waitForQueueFinish(admin.accessToken, QueueName.BackgroundTask);
const { status, body } = await request(app)
.delete(`/admin/users/${user.userId}`)

View File

@ -1,178 +0,0 @@
#!/usr/bin/env node
/**
* Script to generate test images with additional EXIF date tags
* This creates actual JPEG images with embedded metadata for testing
* Images are generated into e2e/test-assets/metadata/dates/
*/
import { execSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
interface TestImage {
filename: string;
description: string;
exifTags: Record<string, string>;
}
const testImages: TestImage[] = [
{
filename: 'time-created.jpg',
description: 'Image with TimeCreated tag',
exifTags: {
TimeCreated: '2023:11:15 14:30:00',
Make: 'Canon',
Model: 'EOS R5',
},
},
{
filename: 'gps-datetime.jpg',
description: 'Image with GPSDateTime and coordinates',
exifTags: {
GPSDateTime: '2023:11:15 12:30:00Z',
GPSLatitude: '37.7749',
GPSLongitude: '-122.4194',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
{
filename: 'datetime-utc.jpg',
description: 'Image with DateTimeUTC tag',
exifTags: {
DateTimeUTC: '2023:11:15 10:30:00',
Make: 'Nikon',
Model: 'D850',
},
},
{
filename: 'gps-datestamp.jpg',
description: 'Image with GPSDateStamp and GPSTimeStamp',
exifTags: {
GPSDateStamp: '2023:11:15',
GPSTimeStamp: '08:30:00',
GPSLatitude: '51.5074',
GPSLongitude: '-0.1278',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
{
filename: 'sony-datetime2.jpg',
description: 'Sony camera image with SonyDateTime2 tag',
exifTags: {
SonyDateTime2: '2023:11:15 06:30:00',
Make: 'SONY',
Model: 'ILCE-7RM5',
},
},
{
filename: 'date-priority-test.jpg',
description: 'Image with multiple date tags to test priority',
exifTags: {
SubSecDateTimeOriginal: '2023:01:01 01:00:00',
DateTimeOriginal: '2023:02:02 02:00:00',
SubSecCreateDate: '2023:03:03 03:00:00',
CreateDate: '2023:04:04 04:00:00',
CreationDate: '2023:05:05 05:00:00',
DateTimeCreated: '2023:06:06 06:00:00',
TimeCreated: '2023:07:07 07:00:00',
GPSDateTime: '2023:08:08 08:00:00',
DateTimeUTC: '2023:09:09 09:00:00',
GPSDateStamp: '2023:10:10',
SonyDateTime2: '2023:11:11 11:00:00',
},
},
{
filename: 'new-tags-only.jpg',
description: 'Image with only additional date tags (no standard tags)',
exifTags: {
TimeCreated: '2023:12:01 15:45:30',
GPSDateTime: '2023:12:01 13:45:30Z',
DateTimeUTC: '2023:12:01 13:45:30',
GPSDateStamp: '2023:12:01',
SonyDateTime2: '2023:12:01 08:45:30',
GPSLatitude: '40.7128',
GPSLongitude: '-74.0060',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
];
const generateTestImages = async (): Promise<void> => {
// Target directory: e2e/test-assets/metadata/dates/
// Current file is in: e2e/src/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const targetDir = join(__dirname, '..', 'test-assets', 'metadata', 'dates');
console.log('Generating test images with additional EXIF date tags...');
console.log(`Target directory: ${targetDir}`);
for (const image of testImages) {
try {
const imagePath = join(targetDir, image.filename);
// Create unique JPEG file using Sharp
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
const jpegData = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r, g, b },
},
})
.jpeg({ quality: 90 })
.toBuffer();
writeFileSync(imagePath, jpegData);
// Build exiftool command to add EXIF data
const exifArgs = Object.entries(image.exifTags)
.map(([tag, value]) => `-${tag}="${value}"`)
.join(' ');
const command = `exiftool ${exifArgs} -overwrite_original "${imagePath}"`;
console.log(`Creating ${image.filename}: ${image.description}`);
execSync(command, { stdio: 'pipe' });
// Verify the tags were written
const verifyCommand = `exiftool -json "${imagePath}"`;
const result = execSync(verifyCommand, { encoding: 'utf8' });
const metadata = JSON.parse(result)[0];
console.log(` ✓ Created with ${Object.keys(image.exifTags).length} EXIF tags`);
// Log first date tag found for verification
const firstDateTag = Object.keys(image.exifTags).find(
(tag) => tag.includes('Date') || tag.includes('Time') || tag.includes('Created'),
);
if (firstDateTag && metadata[firstDateTag]) {
console.log(` ✓ Verified ${firstDateTag}: ${metadata[firstDateTag]}`);
}
} catch (error) {
console.error(`Failed to create ${image.filename}:`, (error as Error).message);
}
}
console.log('\nTest image generation complete!');
console.log('Files created in:', targetDir);
console.log('\nTo test these images:');
console.log(`cd ${targetDir} && exiftool -time:all -gps:all *.jpg`);
};
export { generateTestImages };
// Run the generator if this file is executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
generateTestImages().catch(console.error);
}

View File

@ -0,0 +1,37 @@
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';

View File

@ -0,0 +1,183 @@
import { generateConsecutiveDays, generateDayAssets } from 'src/generators/timeline/model-objects';
import { SeededRandom, selectRandomDays } from 'src/generators/timeline/utils';
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

View File

@ -0,0 +1,111 @@
import sharp from 'sharp';
import { SeededRandom } from 'src/generators/timeline/utils';
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 & 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;
};

View File

@ -0,0 +1,265 @@
/**
* 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/generators/timeline/utils';
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): string {
return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
}
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.parseFloat(ratio.split(':')[0]) / Number.parseFloat(ratio.split(':')[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()].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 };
}

View File

@ -0,0 +1,436 @@
/**
* REST API output functions for converting timeline data to API response formats
*/
import {
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';
import { parseTimeBucketKey } from 'src/generators/timeline/utils';
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: [],
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;
}
if (isFavorite !== undefined && actuallyFavorited !== isFavorite) {
return false;
}
return true;
}
/**
* 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: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(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,
deviceAssetId: `device-${asset.id}`,
ownerId: asset.ownerId,
owner: owner || defaultOwner,
libraryId: `library-${asset.ownerId}`,
deviceId: `device-${asset.ownerId}`,
type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
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 || '0:00:00.00000',
exifInfo,
livePhotoVideoId: asset.livePhotoVideoId,
tags: [],
people: [],
unassignedFaces: [],
stack: asset.stack,
isOffline: false,
hasMetadata: true,
duplicateId: null,
resized: true,
checksum: asset.checksum,
};
}
/**
* 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()];
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()].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,
ownerId: albumOwner.id,
owner: albumOwner,
albumUsers: [], // Empty array for non-shared album
shared: false,
hasSharedLink: false,
isActivityEnabled: true,
assetCount: albumAssets.length,
assets: albumAssets,
startDate: albumAssets.length > 0 ? albumAssets.at(-1)?.fileCreatedAt : undefined,
endDate: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
lastModifiedAssetTimestamp: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
};
return response;
}

View File

@ -0,0 +1,200 @@
import type { AssetVisibility } from '@immich/sdk';
import { DayPattern, MonthDistribution } from 'src/generators/timeline/distribution-patterns';
// 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;
export type AspectRatio = {
width: number;
height: number;
ratio: number;
name: string;
};
// 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: string | 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.isInteger(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,
};
}

View File

@ -0,0 +1,186 @@
import { DateTime } from 'luxon';
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/generators/timeline/timeline-config';
/**
* 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;
}
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
if (unit === 'day') {
return !date1.startOf('day').equals(date2.startOf('day'));
} else if (unit === 'month') {
return date1.year !== date2.year || date1.month !== date2.month;
} else {
return 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;
}

View File

@ -0,0 +1,285 @@
import { BrowserContext } from '@playwright/test';
import { playwrightHost } from 'playwright.config';
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,
},
},
});
});
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/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',
'.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',
'.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) => {
if (request.url().endsWith('albums?shared=true') || request.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',
},
],
});
});
};

View File

@ -0,0 +1,149 @@
import { BrowserContext, Page, Request, Route } from '@playwright/test';
import { basename } from 'node:path';
import {
Changes,
getAlbum,
getAsset,
getTimeBucket,
getTimeBuckets,
randomPreview,
randomThumbnail,
TimelineData,
} from 'src/generators/timeline';
import { sleep } from 'src/web/specs/timeline/utils';
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),
});
} else 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) => {
const url = new URL(request.url());
const pathname = url.pathname;
const assetId = basename(pathname);
const asset = getAsset(timelineRestData, assetId);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: asset,
});
});
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/albums/**', async (route, request) => {
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
const match = request.url().match(pattern);
if (!match) {
return route.continue();
}
const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: album,
});
});
};
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;
};

View File

@ -7,6 +7,12 @@ export const errorDto = {
message: 'Authentication required',
correlationId: expect.any(String),
},
unauthorizedWithMessage: (message: string) => ({
error: 'Unauthorized',
statusCode: 401,
message,
correlationId: expect.any(String),
}),
forbidden: {
error: 'Forbidden',
statusCode: 403,

View File

@ -1,5 +1,4 @@
import {
AllJobStatusResponseDto,
AssetMediaCreateDto,
AssetMediaResponseDto,
AssetResponseDto,
@ -7,11 +6,13 @@ import {
CheckExistingAssetsDto,
CreateAlbumDto,
CreateLibraryDto,
JobCommandDto,
JobName,
MaintenanceAction,
MetadataSearchDto,
Permission,
PersonCreateDto,
QueueCommandDto,
QueueName,
QueuesResponseLegacyDto,
SharedLinkCreateDto,
UpdateLibraryDto,
UserAdminCreateDto,
@ -27,15 +28,16 @@ import {
createStack,
createUserAdmin,
deleteAssets,
getAllJobsStatus,
getAssetInfo,
getConfig,
getConfigDefaults,
getQueuesLegacy,
login,
runQueueCommandLegacy,
scanLibrary,
searchAssets,
sendJobCommand,
setBaseUrl,
setMaintenanceMode,
signUpAdmin,
tagAssets,
updateAdminOnboarding,
@ -52,7 +54,7 @@ import { exec, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path, { dirname } from 'node:path';
import { dirname, resolve } from 'node:path';
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
import { promisify } from 'node:util';
import pg from 'pg';
@ -60,6 +62,8 @@ import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import request from 'supertest';
import { playwrightDbHost, playwrightHost, playwriteBaseUrl } from '../playwright.config';
export type { Emitter } from '@socket.io/component-emitter';
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
@ -68,12 +72,12 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu
type AdminSetupOptions = { onboarding?: boolean };
type FileData = { bytes?: Buffer; filename: string };
const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5435/immich';
export const baseUrl = 'http://127.0.0.1:2285';
const dbUrl = `postgres://postgres:postgres@${playwrightDbHost}:5435/immich`;
export const baseUrl = playwriteBaseUrl;
export const shareUrl = `${baseUrl}/share`;
export const app = `${baseUrl}/api`;
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve('./test-assets');
export const testAssetDir = resolve(import.meta.dirname, '../test-assets');
export const testAssetDirInternal = '/test-assets';
export const tempDir = tmpdir();
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
@ -477,10 +481,10 @@ export const utils = {
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
jobCommand: async (accessToken: string, jobName: JobName, jobCommandDto: JobCommandDto) =>
sendJobCommand({ id: jobName, jobCommandDto }, { headers: asBearerAuth(accessToken) }),
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost) =>
await context.addCookies([
{
name: 'immich_access_token',
@ -514,6 +518,42 @@ export const utils = {
},
]),
setMaintenanceAuthCookie: async (context: BrowserContext, token: string, domain = '127.0.0.1') =>
await context.addCookies([
{
name: 'immich_maintenance_token',
value: token,
domain,
path: '/',
expires: 2_058_028_213,
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
]),
enterMaintenance: async (accessToken: string) => {
let setCookie: string[] | undefined;
await setMaintenanceMode(
{
setMaintenanceModeDto: {
action: MaintenanceAction.Start,
},
},
{
headers: asBearerAuth(accessToken),
fetch: (...args: Parameters<typeof fetch>) =>
fetch(...args).then((response) => {
setCookie = response.headers.getSetCookie();
return response;
}),
},
);
return setCookie;
},
resetTempFolder: () => {
rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
@ -524,13 +564,13 @@ export const utils = {
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
},
isQueueEmpty: async (accessToken: string, queue: keyof AllJobStatusResponseDto) => {
const queues = await getAllJobsStatus({ headers: asBearerAuth(accessToken) });
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
const jobCounts = queues[queue].jobCounts;
return !jobCounts.active && !jobCounts.waiting;
},
waitForQueueFinish: (accessToken: string, queue: keyof AllJobStatusResponseDto, ms?: number) => {
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
// eslint-disable-next-line no-async-promise-executor
return new Promise<void>(async (resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);

View File

@ -0,0 +1,51 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe.configure({ mode: 'serial' });
test.describe('Maintenance', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test('enter and exit maintenance mode', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/system-settings?isOpen=maintenance');
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
});
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
const setCookie = await utils.enterMaintenance(admin.accessToken);
const cookie = setCookie
?.map((cookie) => cookie.split(';')[0].split('='))
?.find(([name]) => name === 'immich_maintenance_token');
expect(cookie).toBeTruthy();
await expect(async () => {
await page.goto('/');
await page.waitForURL('**/maintenance?**', {
timeout: 1000,
});
}).toPass({ timeout: 10_000 });
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
await page.goto(`/maintenance?${new URLSearchParams({ token: cookie![1] })}`);
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toBeVisible();
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('**/auth/login');
});
});

View File

@ -0,0 +1,864 @@
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/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { utils } from 'src/utils';
import {
assetViewerUtils,
cancelAllPollers,
padYearMonth,
pageUtils,
poll,
thumbnailUtils,
timelineUtils,
} from 'src/web/specs/timeline/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('-');
yearMonths.push(`${year}-${Number(month)}`);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
});
test.afterEach(() => {
cancelAllPollers();
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.h-full.w-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('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('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.expectSelectedReadonly(page, asset.id);
});
test('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.expectSelectedReadonly(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('Done').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.getByText('Trash', { exact: true }).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', { exact: true }).click();
await expect(restoreRequest).resolves.toEqual({
ids: [assetToTrash.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
await page.getByText('Photos', { exact: true }).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('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByText('Trash', { exact: true }).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', { exact: true }).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.getByText('Photos', { exact: true }).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('#asset-selection-app-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.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
});
test('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('#asset-selection-app-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;
};

View File

@ -0,0 +1,238 @@
import { BrowserContext, expect, Page } from '@playwright/test';
import { DateTime } from 'luxon';
import { TimelineAssetConfig } from 'src/generators/timeline';
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const padYearMonth = (yearMonth: string) => {
const [year, month] = yearMonth.split('-');
return `${year}-${month.padStart(2, '0')}`;
};
export async function throttlePage(context: BrowserContext, page: Page) {
const session = await context.newCDPSession(page);
await session.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (1.5 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
latency: 40,
connectionType: 'cellular3g',
});
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
}
let activePollsAbortController = new AbortController();
export const cancelAllPollers = () => {
activePollsAbortController.abort();
activePollsAbortController = new AbortController();
};
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 signal = activePollsAbortController.signal;
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
while (!terminate(result) && Date.now() < timeout) {
if (signal.aborted) {
return;
}
try {
result = await query();
} catch {
// ignore
}
if (signal.aborted) {
return;
}
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`);
},
selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
},
async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click();
},
async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
const assetIds: string[] = [];
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 expectSelectedReadonly(page: Page, assetId: string) {
// todo - need a data attribute for selected
await expect(
page.locator(
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
),
).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)).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}"]`)
.or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`))
.waitFor();
},
async expectActiveAssetToBe(page: Page, assetId: string) {
const activeElement = () =>
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
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) {
await page.getByTitle(day).hover();
await page.locator('[data-group] .w-8').click();
},
async pauseTestDebug() {
console.log('NOTE: pausing test indefinately for debug');
await new Promise(() => void 0);
},
};

View File

@ -52,14 +52,18 @@ test.describe('User Administration', () => {
await page.goto(`/admin/users/${user.userId}`);
await page.getByRole('button', { name: 'Edit user' }).click();
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByText('Admin User').click();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(updated.isAdmin).toBe(true);
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 }) => {
@ -77,13 +81,17 @@ test.describe('User Administration', () => {
await page.goto(`/admin/users/${user.userId}`);
await page.getByRole('button', { name: 'Edit user' }).click();
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByText('Admin User').click();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(updated.isAdmin).toBe(false);
await expect
.poll(async () => {
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
return userAdmin.isAdmin;
})
.toBe(false);
});
});

@ -1 +1 @@
Subproject commit 68e8b5853cdc2d76c5e6f18a6d1773793728c491
Subproject commit 163c251744e0a35d7ecfd02682452043f149fc2b

View File

@ -9,7 +9,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"target": "es2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,

View File

@ -17,7 +17,6 @@
"add_birthday": "Voeg 'n verjaarsdag by",
"add_endpoint": "Voeg Koppelvlakpunt by",
"add_exclusion_pattern": "Voeg uitsgluitingspatrone by",
"add_import_path": "Voeg invoerpad by",
"add_location": "Voeg ligging by",
"add_more_users": "Voeg meer gebruikers by",
"add_partner": "Voeg vennoot by",
@ -69,7 +68,6 @@
"disable_login": "Deaktiveer aanmelding",
"duplicate_detection_job_description": "Begin masjienleer op bates om soortgelyke beelde op te spoor. Maak staat op Smart Search",
"exclusion_pattern_description": "Met uitsluitingspatrone kan jy lêers en vouers ignoreer wanneer jy jou biblioteek skandeer. Dit is nuttig as jy vouers het wat lêers bevat wat jy nie wil invoer nie, soos RAW-lêers.",
"external_library_management": "Eksterne Biblioteekbestuur",
"face_detection": "Gesig herkenning",
"face_detection_description": "Identifiseer die gesigte in media deur middel van masjienleer. Vir videos word slegs die duimnaelskets oorweeg. “Herlaai” (ver)werk al die media weer. “Stel terug” verwyder alle huidige gesigdata. “Onverwerk” plaas bates in die tou wat nog nie verwerk is nie. Geidentifiseerde gesigte sal ná voltooiing van Gesigidentifikasie vir Gesigherkenning in die tou geplaas word, om hulle in bestaande of nuwe persone te groepeer.",
"facial_recognition_job_description": "Groepeer gesigte in mense in. Die stap is vinniger nadat Gesig Deteksie klaar is. \"Herstel\" (her-)groepeer alle gesigte. \"Vermiste\" plaas gesigte in ry wat nie 'n persoon gekoppel het nie.",
@ -98,10 +96,8 @@
"job_not_concurrency_safe": "Hierdie taak kan nie gelyktydig uitgevoer word nie.",
"job_settings": "Agtergrondtaakinstellings",
"job_settings_description": "Bestuur werkgelyktydigheid",
"job_status": "Werkstatus",
"library_created": "Biblioteek geskep: {library}",
"library_deleted": "Biblioteek verwyder",
"library_import_path_description": "Spesifiseer 'n leer om in te neem. Hierdie leer, en al die sub leers, gaan deursoek word vir prente en videos.",
"library_scanning": "Periodieke Soek",
"library_scanning_description": "Stel periodieke deursoek van biblioteek in",
"library_scanning_enable_description": "Aktiveer periodieke biblioteekskandering",

View File

@ -1,5 +1,5 @@
{
"about": َوْل",
"about": ول",
"account": "حساب",
"account_settings": "إعدادات الحساب",
"acknowledge": "أُدرك ذلك",
@ -17,7 +17,6 @@
"add_birthday": "أضف تاريخ الميلاد",
"add_endpoint": "اضف نقطة نهاية",
"add_exclusion_pattern": "إضافة نمط إستثناء",
"add_import_path": "إضافة مسار الإستيراد",
"add_location": "إضافة موقع",
"add_more_users": "إضافة مستخدمين آخرين",
"add_partner": "أضف شريكًا",
@ -32,6 +31,7 @@
"add_to_album_toggle": "تبديل التحديد لـ{album}",
"add_to_albums": "إضافة الى البومات",
"add_to_albums_count": "إضافه إلى البومات ({count})",
"add_to_bottom_bar": "اضف الى",
"add_to_shared_album": "إضافة إلى ألبوم مشارك",
"add_upload_to_stack": "اضف رفع الى حزمة",
"add_url": "إضافة رابط",
@ -67,6 +67,7 @@
"confirm_reprocess_all_faces": "هل أنت متأكد أنك تريد إعادة معالجة جميع الوجوه؟ سيخلي هذا كل الأشخاص الذين سَميتَهم.",
"confirm_user_password_reset": "هل أنت متأكد أنك تريد إعادة تعيين كلمة مرور {user}؟",
"confirm_user_pin_code_reset": "هل انت متاكد من اعادة ضبط رمز PIN الخاص ب {user}؟",
"copy_config_to_clipboard_description": "انسخ اعدادات النظام الحالية بتنسيق JSON الى الحافظة",
"create_job": "إنشاء وظيفة",
"cron_expression": "تعبير Cron",
"cron_expression_description": "اضبط الفاصل الزمني للفحص باستخدام تنسيق cron. لمزيد من المعلومات يُرجى الرجوع إلى <link>Crontab Guru</link> على سبيل المثال",
@ -74,7 +75,8 @@
"disable_login": "تعطيل تسجيل الدخول",
"duplicate_detection_job_description": "بدء التعلم الآلي على المحتوى للعثور على الصور المتشابهة. يعتمد على البحث الذكي",
"exclusion_pattern_description": "تتيح لك أنماط الاستبعاد تجاهل الملفات والمجلدات عند فحص مكتبتك. يعد هذا مفيدًا إذا كان لديك مجلدات تحتوي على ملفات لا تريد استيرادها، مثل ملفات RAW.",
"external_library_management": "إدارة المكتبة الخارجية",
"export_config_as_json_description": "تحميل اعدادات النظام الحالية كملف بصيغة JSON",
"external_libraries_page_description": "مشرف صفحة مكتبة خارجية",
"face_detection": "إ‏كتشاف الوجوه",
"face_detection_description": "اكتشف الوجوه في الأصول باستخدام التعلم الآلي. بالنسبة لمقاطع الفيديو، يتم اعتبار الصورة المصغرة فقط. \"تحديث\" (إعادة) معالجة جميع الأصول. \"إعادة تعيين\" تمسح أيضًا جميع بيانات الوجوه الحالية. \"مفقود\" يضع الأصول التي لم تتم معالجتها بعد في قائمة الانتظار. سيتم وضع الوجوه المكتشفة في قائمة الانتظار للتعرف على الوجه بعد اكتمال اكتشاف الوجه، وتجميعها في أشخاص موجودين أو جدد.",
"facial_recognition_job_description": "تجميع الوجوه المكتشفة كأشخاص. يتم تنفيذ هذه الخطوة بعد اكتمال اكتشاف الوجه. خيار \"إعادة التعيين\" يعيد تجميع جميع الوجوه. خيار \"المفقود\" يضع في قائمة الانتظار الوجوه التي لم يتم تعيين شخص لها.",
@ -102,23 +104,27 @@
"image_thumbnail_description": "صورة مصغرة صغيرة مع بيانات وصفية مجردة، تُستخدم عند عرض مجموعات من الصور مثل الجدول الزمني الرئيسي",
"image_thumbnail_quality_description": "تتراوح جودة الصورة المصغرة من 1 إلى 100. كلما كانت الجودة أعلى كان ذلك أفضل، ولكنها تنتج ملفات أكبر وقد تقلل من استجابة التطبيق.",
"image_thumbnail_title": "إعدادات الصورة المصغرة",
"import_config_from_json_description": "استيراد اعدادات النظام بتحميل ملف اعدادات بصيغة JSON",
"job_concurrency": "تزامن {job}",
"job_created": "تم إنشاء الوظيفة",
"job_not_concurrency_safe": "هذه الوظيفة غير آمنة للتشغيل المتزامن.",
"job_settings": "إعدادات الوظائف",
"job_settings_description": "إدارة تزامن الوظائف",
"job_status": "حالة الوظيفة",
"jobs_delayed": "{jobCount, plural, other {# مؤجلة}}",
"jobs_failed": "{jobCount, plural, other {# فشلت}}",
"library_created": "تم إنشاء المكتبة: {library}",
"library_deleted": "تم حذف المكتبة",
"library_import_path_description": "حدد مجلدًا للاستيراد. سيتم فحص هذا المجلد، بما في ذلك المجلدات الفرعية، بحثًا عن الصور ومقاطع الفيديو.",
"library_details": "تفاصيل المكتبة",
"library_folder_description": "حدد مجلد للاستيراد. هذا المجلد مع المجلدات الفرعية، سيتم تفحصهم للصور والفديوات.",
"library_remove_exclusion_pattern_prompt": "هل انت متاكد انك تريد ازالة نمط الاستبعاد هذا؟",
"library_remove_folder_prompt": "هل انت متاكد انك تريد ازالة مجلد الاستيراد هذا؟",
"library_scanning": "المسح الدوري",
"library_scanning_description": "إعداد مسح المكتبة الدوري",
"library_scanning_enable_description": "تفعيل مسح المكتبة الدوري",
"library_settings": "المكتبة الخارجية",
"library_settings_description": "إدارة إعدادات المكتبة الخارجية",
"library_tasks_description": "مسح المكتبات الخارجية للعثور على الأصول الجديدة و/أو المتغيرة",
"library_updated": "مكتبة محدثة",
"library_watching_enable_description": "مراقبة المكتبات الخارجية لاكتشاف تغييرات الملفات",
"library_watching_settings": "مراقبة المكتبات [تجريبي]",
"library_watching_settings_description": "راقب تلقائيًا التغييرات في الملفات",
@ -154,6 +160,18 @@
"machine_learning_min_detection_score_description": "الحد الأدنى لنقطة الثقة لاكتشاف الوجه، تتراوح من 0 إلى 1. القيم الأقل ستكشف عن المزيد من الوجوه ولكن قد تؤدي إلى نتائج إيجابية خاطئة.",
"machine_learning_min_recognized_faces": "الحد الأدنى لعدد الوجوه المتعرف عليها",
"machine_learning_min_recognized_faces_description": "الحد الأدنى لعدد الوجوه المتعرف عليها لإنشاء شخص. زيادة هذا الرقم يجعل التعرف على الوجوه أكثر دقة على حساب زيادة احتمال عدم تعيين الوجه لشخص ما.",
"machine_learning_ocr": "التعرف البصري على الحروف",
"machine_learning_ocr_description": "استخدم التعلم الآلي للتعرف على النصوص في الصور",
"machine_learning_ocr_enabled": "تفعيل التعرف البصري على الحروف",
"machine_learning_ocr_enabled_description": "في حال تعطيل هذه الميزة، لن تخضع الصور لعملية التعرف على النصوص.",
"machine_learning_ocr_max_resolution": "أقصى دقة",
"machine_learning_ocr_max_resolution_description": "سيتم تغيير حجم المعاينات التي تتجاوز هذه الدقة مع الحفاظ على نسبة العرض إلى الارتفاع. القيم الأعلى توفر دقة أكبر، ولكنها تستغرق وقتًا أطول للمعالجة وتستهلك المزيد من الذاكرة.",
"machine_learning_ocr_min_detection_score": "الحد الأدنى لدرجة الكشف",
"machine_learning_ocr_min_detection_score_description": "لحد الأدنى لدرجة الثقة المطلوبة لاكتشاف النص، وتتراوح قيمتها من 0 إلى 1. ستؤدي القيم الأقل إلى اكتشاف المزيد من النصوص ولكنها قد تؤدي إلى نتائج إيجابية خاطئة.",
"machine_learning_ocr_min_recognition_score": "الحد الأدنى لدرجة التعرّف",
"machine_learning_ocr_min_score_recognition_description": "الحد الأدنى لدرجة الثقة المطلوبة للنصوص المكتشفة ليتم التعرف عليها، وتتراوح من 0 إلى 1. ستؤدي القيم الأقل إلى التعرف على المزيد من النصوص ولكنها قد تؤدي إلى نتائج إيجابية خاطئة.",
"machine_learning_ocr_model": "نموذج التعرف البصري على الحروف",
"machine_learning_ocr_model_description": "تتميز نماذج الخوادم بدقة أكبر من نماذج الأجهزة المحمولة، ولكنها تستغرق وقتًا أطول في المعالجة وتستهلك ذاكرة أكبر.",
"machine_learning_settings": "إعدادات التعلم الآلي",
"machine_learning_settings_description": "إدارة ميزات وإعدادات التعلم الآلي",
"machine_learning_smart_search": "البحث الذكي",
@ -161,7 +179,12 @@
"machine_learning_smart_search_enabled": "تفعيل البحث الذكي",
"machine_learning_smart_search_enabled_description": "إذا تم تعطيله، فلن يتم ترميز الصور للبحث الذكي.",
"machine_learning_url_description": "عنوان URL لخادم التعلم الآلي. إذا تم توفير أكثر من عنوان URL واحد، سيتم محاولة الاتصال بكل خادم على حدة حتى يستجيب أحدهم بنجاح، بدءًا من الأول إلى الأخير. سيتم تجاهل الخوادم التي لا تستجيب مؤقتًا حتى تعود للعمل.",
"maintenance_settings": "صيانة",
"maintenance_settings_description": "ضع Immich في وضع الصيانة.",
"maintenance_start": "ابدأ وضع الصيانة",
"maintenance_start_error": "فشل البدء في وضع الصيانة.",
"manage_concurrency": "إدارة التزامن",
"manage_concurrency_description": "انتقل الى صفحة الاعمال لادارة تزامن المهام",
"manage_log_settings": "إدارة إعدادات السجلات",
"map_dark_style": "النمط الداكن",
"map_enable_description": "تفعيل ميزات الخرائط",
@ -211,6 +234,8 @@
"notification_email_ignore_certificate_errors_description": "تجاهل أخطاء التحقق من صحة شهادة TLS (غير مستحسن)",
"notification_email_password_description": "كلمة المرور المستخدمة للمصادقة مع خادم البريد الإلكتروني",
"notification_email_port_description": "منفذ خادم البريد الإلكتروني (مثلاً 25، 465، أو 587)",
"notification_email_secure": "بروتوكول نقل البريد البسيط الآمن SMTPS",
"notification_email_secure_description": "استخدم بروتوكول SMTPS (بروتوكول SMTP عبر TLS)",
"notification_email_sent_test_email_button": "إرسال بريد إلكتروني تجريبي وحفظ التعديلات",
"notification_email_setting_description": "إعدادات إرسال إشعارات البريد الإلكتروني",
"notification_email_test_email": "إرسال بريد تجريبي",
@ -243,6 +268,7 @@
"oauth_storage_quota_default_description": "الحصة بالجيجابايت التي سيتم استخدامها عندما لا يتم توفير مطالبة.",
"oauth_timeout": "نفاذ وقت الطلب",
"oauth_timeout_description": "نفاذ وقت الطلب بالميلي ثانية",
"ocr_job_description": "استخدم التعلم الآلي للتعرف على النصوص في الصور",
"password_enable_description": "تسجيل الدخول باستخدام البريد الكتروني وكلمة المرور",
"password_settings": "تسجيل الدخول بكلمة المرور",
"password_settings_description": "إدارة تسجيل الدخول بكلمة المرور",
@ -264,8 +290,10 @@
"server_public_users_description": "يتم إدراج جميع المستخدمين (الاسم والبريد الإلكتروني) عند إضافة مستخدم إلى الألبومات المشتركة. عند تعطيل هذه الميزة، ستكون قائمة المستخدمين متاحة فقط لمستخدمي الإدارة.",
"server_settings": "إعدادات الخادم",
"server_settings_description": "إدارة إعدادات الخادم",
"server_stats_page_description": "صفحة إحصائيات مسؤول الخادم",
"server_welcome_message": "الرسالة الترحيبية",
"server_welcome_message_description": "رسالة تُعرض على صفحة تسجيل الدخول.",
"settings_page_description": "صفخة اعدادات المسؤول",
"sidecar_job": "البيانات الوصفية الجانبية",
"sidecar_job_description": "اكتشاف أو مزامنة البيانات التعريفية الجانبية من نظام الملفات",
"slideshow_duration_description": "عدد الثواني لعرض كل صورة",
@ -384,7 +412,8 @@
"user_restore_scheduled_removal": "استعادة المستخدم - تمت جدولة الإزالة في {date, date, long}",
"user_settings": "إعدادات المستخدم",
"user_settings_description": "إدارة إعدادات المستخدم",
"user_successfully_removed": "تمت إزالة المستخدم {email} بنجاح.",
"user_successfully_removed": "المستخدم {email} تمت ازالته بنجاح.",
"users_page_description": "صفحة ادارة المستخدمين",
"version_check_enabled_description": "تفعيل التحقق من الإصدارات الجديدة",
"version_check_implications": "تعتمد ميزة التحقق من الإصدار على التواصل الدوري مع github.com",
"version_check_settings": "التحقق من الإصدار",
@ -402,11 +431,11 @@
"advanced_settings_prefer_remote_subtitle": "تكون بعض الأجهزة بطيئة للغاية في تحميل الصور المصغرة من الأصول المحلية. قم بتفعيل هذا الخيار لتحميل الصور البعيدة بدلاً من ذلك.",
"advanced_settings_prefer_remote_title": "تفضل الصور البعيدة",
"advanced_settings_proxy_headers_subtitle": "عرف عناوين الوكيل التي يستخدمها Immich لارسال كل طلب شبكي",
"advanced_settings_proxy_headers_title": "عناوين الوكيل",
"advanced_settings_proxy_headers_title": "عناوين الوكيل المخصصة [تجريبية]",
"advanced_settings_readonly_mode_subtitle": "تتيح هذه الميزة وضع العرض فقط، حيث يمكن للمستخدم معاينة الصور فقط، بينما يتم تعطيل جميع الخيارات الأخرى مثل تحديد عدة صور، أو مشاركتها، أو بثها، أو حذفها. يمكن تفعيل/تعطيل وضع العرض فقط من خلال صورة المستخدم في الشاشة الرئيسية",
"advanced_settings_readonly_mode_title": "وضع القراءة فقط",
"advanced_settings_self_signed_ssl_subtitle": "تخطي التحقق من شهادة SSL لخادم النقطة النهائي. مكلوب للشهادات الموقعة ذاتيا.",
"advanced_settings_self_signed_ssl_title": "السماح بشهادات SSL الموقعة ذاتيًا",
"advanced_settings_self_signed_ssl_title": "السماح بشهادات SSL الموقعة ذاتيًا [تجريبية]",
"advanced_settings_sync_remote_deletions_subtitle": "حذف او استعادة تلقائي للاصول على هذا الجهاز عند تنفيذ العملية على الويب",
"advanced_settings_sync_remote_deletions_title": "مزامنة عمليات الحذف عن بعد [تجريبي]",
"advanced_settings_tile_subtitle": "إعدادات المستخدم المتقدمة",
@ -415,6 +444,7 @@
"age_months": "عمر {months, plural, one {# شهر} other {# أشهر}}",
"age_year_months": "عمر سنة واحدة، {months, plural, one {# شهر} other {# أشهر}}",
"age_years": "{years, plural, other {العمر #}}",
"album": "البوم",
"album_added": "تمت إضافة الألبوم",
"album_added_notification_setting_description": "تلقي إشعارًا بالبريد الإلكتروني عند إضافتك إلى ألبوم مشترك",
"album_cover_updated": "تم تحديث غلاف الألبوم",
@ -460,16 +490,21 @@
"allow_edits": "إسمح بالتعديل",
"allow_public_user_to_download": "السماح لأي مستخدم عام بالتنزيل",
"allow_public_user_to_upload": "السماح للمستخدم العام بالرفع",
"allowed": "مسموح",
"alt_text_qr_code": "صورة رمز الاستجابة السريعة (QR)",
"anti_clockwise": "عكس اتجاه عقارب الساعة",
"api_key": "مفتاح API",
"api_key_description": "سيتم عرض هذه القيمة مرة واحدة فقط. يرجى التأكد من نسخها قبل إغلاق النافذة.",
"api_key_empty": "يجب ألا يكون اسم مفتاح API فارغًا",
"api_keys": "مفاتيح API",
"app_architecture_variant": "متغير (الهندسة المعمارية)",
"app_bar_signout_dialog_content": "هل أنت متأكد أنك تريد تسجيل الخروج؟",
"app_bar_signout_dialog_ok": "نعم",
"app_bar_signout_dialog_title": "خروج",
"app_download_links": "روابط تحميل التطبيق",
"app_settings": "إعدادات التطبيق",
"app_stores": "متاجر التطبيقات",
"app_update_available": "تحديث التطبيق متاح",
"appears_in": "يظهر في",
"apply_count": "تطبيق ({count, number})",
"archive": "الأرشيف",
@ -553,6 +588,7 @@
"backup_albums_sync": "مزامنة ألبومات النسخ الاحتياطي",
"backup_all": "الجميع",
"backup_background_service_backup_failed_message": "فشل في النسخ الاحتياطي للأصول. جارٍ إعادة المحاولة…",
"backup_background_service_complete_notification": "تم الانتهاء من النسخ الاحتياطي للأصول",
"backup_background_service_connection_failed_message": "فشل في الاتصال بالخادم. جارٍ إعادة المحاولة…",
"backup_background_service_current_upload_notification": "تحميل {filename}",
"backup_background_service_default_notification": "التحقق من الأصول الجديدة…",
@ -662,6 +698,8 @@
"change_password_description": "هذه إما هي المرة الأولى التي تقوم فيها بتسجيل الدخول إلى النظام أو أنه تم تقديم طلب لتغيير كلمة المرور الخاصة بك. الرجاء إدخال كلمة المرور الجديدة أدناه.",
"change_password_form_confirm_password": "تأكيد كلمة المرور",
"change_password_form_description": "مرحبًا {name}،\n\nاما ان تكون هذه هي المرة الأولى التي تقوم فيها بالتسجيل في النظام أو تم تقديم طلب لتغيير كلمة المرور الخاصة بك. الرجاء إدخال كلمة المرور الجديدة أدناه.",
"change_password_form_log_out": "تسجيل الخروج من جميع الأجهزة الأخرى",
"change_password_form_log_out_description": "يُنصح بتسجيل الخروج من جميع الأجهزة الأخرى",
"change_password_form_new_password": "كلمة المرور الجديدة",
"change_password_form_password_mismatch": "كلمة المرور غير مطابقة",
"change_password_form_reenter_new_password": "أعد إدخال كلمة مرور جديدة",
@ -689,13 +727,14 @@
"client_cert_invalid_msg": "ملف شهادة عميل غير صالحة او كلمة سر غير صحيحة",
"client_cert_remove_msg": "تم ازالة شهادة العميل",
"client_cert_subtitle": "يدعم صيغ PKCS12 (.p12, .pfx)فقط. استيراد/ازالة الشهادات متاح فقط قبل تسجيل الدخول",
"client_cert_title": "شهادة مستخدم SSL",
"client_cert_title": "شهادة مستخدم SSL [تجريبية]",
"clockwise": "باتجاه عقارب الساعة",
"close": "إغلاق",
"collapse": "طي",
"collapse_all": "طيّ الكل",
"color": "اللون",
"color_theme": "نمط الألوان",
"command": "امر",
"comment_deleted": "تم حذف التعليق",
"comment_options": "خيارات التعليق",
"comments_and_likes": "التعليقات والإعجابات",
@ -739,6 +778,7 @@
"create": "انشاء",
"create_album": "إنشاء ألبوم",
"create_album_page_untitled": "بدون اسم",
"create_api_key": "إنشاء مفتاح API",
"create_library": "إنشاء مكتبة",
"create_link": "إنشاء رابط",
"create_link_to_share": "إنشاء رابط للمشاركة",
@ -768,6 +808,7 @@
"daily_title_text_date_year": "E ، MMM DD ، yyyy",
"dark": "معتم",
"dark_theme": "تبديل المظهر الداكن",
"date": "تاريخ",
"date_after": "التارخ بعد",
"date_and_time": "التاريخ و الوقت",
"date_before": "التاريخ قبل",
@ -870,8 +911,6 @@
"edit_description_prompt": "الرجاء اختيار وصف جديد:",
"edit_exclusion_pattern": "تعديل نمط الاستبعاد",
"edit_faces": "تعديل الوجوه",
"edit_import_path": "تعديل مسار الاستيراد",
"edit_import_paths": "تعديل مسارات الاستيراد",
"edit_key": "تعديل المفتاح",
"edit_link": "تغيير الرابط",
"edit_location": "تعديل الموقع",
@ -943,8 +982,8 @@
"failed_to_stack_assets": "فشل في تكديس المحتويات",
"failed_to_unstack_assets": "فشل في فصل المحتويات",
"failed_to_update_notification_status": "فشل في تحديث حالة الإشعار",
"import_path_already_exists": "مسار الاستيراد هذا موجود مسبقًا.",
"incorrect_email_or_password": "بريد أو كلمة مرور غير صحيحة",
"library_folder_already_exists": "مسار الاستيراد موجود بالفعل.",
"paths_validation_failed": "فشل في التحقق من {paths, plural, one {# مسار} other {# مسارات}}",
"profile_picture_transparent_pixels": "لا يمكن أن تحتوي صور الملف الشخصي على أجزاء/بكسلات شفافة. يرجى التكبير و/أو تحريك الصورة.",
"quota_higher_than_disk_size": "لقد قمت بتعيين حصة نسبية أعلى من حجم القرص",
@ -953,7 +992,6 @@
"unable_to_add_assets_to_shared_link": "تعذر إضافة المحتويات إلى الرابط المشترك",
"unable_to_add_comment": "تعذر إضافة التعليق",
"unable_to_add_exclusion_pattern": "تعذر إضافة نمط الإستبعاد",
"unable_to_add_import_path": "تعذر إضافة مسار الإستيراد",
"unable_to_add_partners": "تعذر إضافة الشركاء",
"unable_to_add_remove_archive": "تعذر {archived, select, true {إزالة المحتوى من} other {إضافة المحتوى إلى}} الأرشيف",
"unable_to_add_remove_favorites": "تعذر {favorite, select, true {إضافة المحتوى إلى} other {إزالة المحتوى من}} المفضلة",
@ -976,12 +1014,10 @@
"unable_to_delete_asset": "غير قادر على حذف المحتوى",
"unable_to_delete_assets": "حدث خطأ أثناء حذف المحتويات",
"unable_to_delete_exclusion_pattern": "غير قادر على حذف نمط الاستبعاد",
"unable_to_delete_import_path": "غير قادر على حذف مسار الاستيراد",
"unable_to_delete_shared_link": "غير قادر على حذف الرابط المشترك",
"unable_to_delete_user": "غير قادر على حذف المستخدم",
"unable_to_download_files": "غير قادر على تنزيل الملفات",
"unable_to_edit_exclusion_pattern": "غير قادر على تعديل نمط الاستبعاد",
"unable_to_edit_import_path": "غير قادر على تحرير مسار الاستيراد",
"unable_to_empty_trash": "غير قادر على إفراغ سلة المهملات",
"unable_to_enter_fullscreen": "غير قادر على الدخول إلى وضع ملء الشاشة",
"unable_to_exit_fullscreen": "غير قادر على الخروج من وضع ملء الشاشة",
@ -1032,11 +1068,13 @@
"unable_to_update_user": "غير قادر على تحديث المستخدم",
"unable_to_upload_file": "تعذر رفع الملف"
},
"exclusion_pattern": "نمط استبعاد",
"exif": "Exif (صيغة ملف صوري قابل للتبادل)",
"exif_bottom_sheet_description": "اضف وصفا...",
"exif_bottom_sheet_description_error": "خطأ في تحديث الوصف",
"exif_bottom_sheet_details": "تفاصيل",
"exif_bottom_sheet_location": "موقع",
"exif_bottom_sheet_no_description": "لا يوجد وصف",
"exif_bottom_sheet_people": "الناس",
"exif_bottom_sheet_person_add_person": "اضف اسما",
"exit_slideshow": "خروج من العرض التقديمي",
@ -1075,6 +1113,7 @@
"features_setting_description": "إدارة ميزات التطبيق",
"file_name": "إسم الملف",
"file_name_or_extension": "اسم الملف أو امتداده",
"file_size": "حجم الملف",
"filename": "اسم الملف",
"filetype": "نوع الملف",
"filter": "تصفية",
@ -1089,6 +1128,7 @@
"folders_feature_description": "تصفح عرض المجلد للصور ومقاطع الفيديو الموجودة على نظام الملفات",
"forgot_pin_code_question": "هل نسيت رمز الPIN الخاص بك؟",
"forward": "إلى الأمام",
"full_path": "مسار كامل:{path}",
"gcast_enabled": "كوكل كاست",
"gcast_enabled_description": "تقوم هذه الميزة بتحميل الموارد الخارجية من Google حتى تعمل.",
"general": "عام",
@ -1114,7 +1154,7 @@
"hash_asset": "عمل Hash للأصل (للملف)",
"hashed_assets": "أصول (ملفات) تم عمل Hash لها",
"hashing": "يتم عمل Hash",
"header_settings_add_header_tip": "اضاف راس",
"header_settings_add_header_tip": "إضافة رأس الصفحة",
"header_settings_field_validator_msg": "القيمة لا يمكن ان تكون فارغة",
"header_settings_header_name_input": "اسم الرأس",
"header_settings_header_value_input": "قيمة الرأس",
@ -1125,6 +1165,7 @@
"hide_named_person": "إخفاء الشخص {name}",
"hide_password": "اخفاء كلمة المرور",
"hide_person": "اخفاء الشخص",
"hide_text_recognition": "اخفاء التعرف على النص",
"hide_unnamed_people": "إخفاء الأشخاص بدون إسم",
"home_page_add_to_album_conflicts": "تمت إضافة {added} أصول إلى الألبوم {album}. {failed} أصول موجودة بالفعل في الألبوم.",
"home_page_add_to_album_err_local": "لا يمكن إضافة الأصول المحلية إلى الألبومات حتى الآن ، سوف يتخطى",
@ -1170,6 +1211,8 @@
"import_path": "مسار الاستيراد",
"in_albums": "في {count, plural, one {# ألبوم } other {# ألبومات}}",
"in_archive": "في الأرشيف",
"in_year": "في {year}",
"in_year_selector": "في",
"include_archived": "تشمل الأرشفة",
"include_shared_albums": "تضمين الألبومات المشتركة",
"include_shared_partner_assets": "تضمين محتويات الشريك المشتركة",
@ -1206,6 +1249,7 @@
"language_setting_description": "اختر لغتك المفضلة",
"large_files": "ملفات كبيرة",
"last": "الاخير",
"last_months": "{count, plural, one {شهر فائت} other {اشهر # فائتة}}",
"last_seen": "اخر ظهور",
"latest_version": "احدث اصدار",
"latitude": "خط العرض",
@ -1215,6 +1259,8 @@
"let_others_respond": "دع الآخرين يستجيبون",
"level": "المستوى",
"library": "مكتبة",
"library_add_folder": "اضافة مجلد",
"library_edit_folder": "تعديل مجلد",
"library_options": "خيارات المكتبة",
"library_page_device_albums": "ألبومات على الجهاز",
"library_page_new_album": "البوم جديد",
@ -1238,6 +1284,7 @@
"local_media_summary": "ملخص الملفات المحلية",
"local_network": "شبكة محلية",
"local_network_sheet_info": "سيتصل التطبيق بالخادم من خلال عنوان URL هذا عند استخدام شبكة Wi-Fi المحددة",
"location": "موقع",
"location_permission": "اذن الموقع",
"location_permission_content": "من أجل استخدام ميزة التبديل التلقائي، يحتاج Immich إلى إذن موقع دقيق حتى يتمكن من قراءة اسم شبكة Wi-Fi الحالية",
"location_picker_choose_on_map": "اختر على الخريطة",
@ -1285,8 +1332,17 @@
"loop_videos_description": "فَعْل لتكرار مقطع فيديو تلقائيًا في عارض التفاصيل.",
"main_branch_warning": "أنت تستخدم إصداراً قيد التطوير؛ ونحن نوصي بشدة باستخدام إصدار النشر!",
"main_menu": "القائمة الرئيسية",
"maintenance_description": "يجب وضع Immich في وضع الصيانة <link>وضع الصيانة</link>.",
"maintenance_end": "انهاء وضع الصيانة",
"maintenance_end_error": "فشل في انهاء وضع الصيانة.",
"maintenance_logged_in_as": "حاليا مسجل باسم {user}",
"maintenance_title": "غير متوفر مؤقتا",
"make": "صنع",
"manage_geolocation": "إدارة الموقع",
"manage_media_access_rationale": "الاذن المطلوب للتعامل السليم لنقل الاصول الى سلة المهملات واعادتها منها.",
"manage_media_access_settings": "فتح الاعدادات",
"manage_media_access_subtitle": "السماح لبرنامج Immich بإدارة ونقل ملفات الوسائط.",
"manage_media_access_title": "وصول ادارة الوسائط",
"manage_shared_links": "إدارة الروابط المشتركة",
"manage_sharing_with_partners": "إدارة المشاركة مع الشركاء",
"manage_the_app_settings": "إدارة إعدادات التطبيق",
@ -1342,12 +1398,15 @@
"minute": "دقيقة",
"minutes": "دقائق",
"missing": "المفقودة",
"mobile_app": "تطبيق الجوال",
"mobile_app_download_onboarding_note": "قم بتنزيل التطبيق المصاحب للهاتف المحمول باستخدام الخيارات التالية",
"model": "نموذج",
"month": "شهر",
"monthly_title_text_date_format": "ط ط ط",
"more": "المزيد",
"move": "تحريك",
"move_off_locked_folder": "تحريك خارج المجلد المقفل",
"move_to": "نقل الى",
"move_to_lock_folder_action_prompt": "{count} اضيف إلى المجلد المقفل",
"move_to_locked_folder": "النقل الى مجلد مغلق",
"move_to_locked_folder_confirmation": "هذه الصور والفديوات ستتم ازالتها من جميع الالبومات، ويمكنان تتم مشاهدتها فقط من خلال المجلد المقفل",
@ -1360,6 +1419,8 @@
"my_albums": "ألبوماتي",
"name": "الاسم",
"name_or_nickname": "الاسم أو اللقب",
"navigate": "التنقل",
"navigate_to_time": "انتقل إلى الوقت",
"network_requirement_photos_upload": "استخدام بيانات الهاتف المحمول لعمل نسخة احتياطية للصور",
"network_requirement_videos_upload": "استخدام بيانات الهاتف المحمول لعمل نسخة احتياطية لمقاطع الفيديو",
"network_requirements": "متطلبات الشبكة",
@ -1369,11 +1430,13 @@
"never": "أبداً",
"new_album": "البوم جديد",
"new_api_key": "مفتاح API جديد",
"new_date_range": "نطاق تاريخ جديد",
"new_password": "كلمة المرور الجديدة",
"new_person": "شخص جديد",
"new_pin_code": "رمز PIN الجديد",
"new_pin_code_subtitle": "هذه أول مرة تدخل فيها إلى المجلد المقفل. أنشئ رمزًا PIN للوصول بامان إلى هذه الصفحة",
"new_timeline": "الخط الزمني الجديد",
"new_update": "تحديث جديد",
"new_user_created": "تم إنشاء مستخدم جديد",
"new_version_available": "إصدار جديد متاح",
"newest_first": "الأحدث أولاً",
@ -1389,12 +1452,14 @@
"no_cast_devices_found": "لم يتم ايجاد جهاز بث",
"no_checksum_local": "لا توجد بيانات تحقق متاحة - يتعذر تحميل الاصول المحلية",
"no_checksum_remote": "لا يوجد رمز تحقق متاح - يتعذر تحميل الاصل من الموقع البعيد",
"no_devices": "لا يوجد اجهزة مرخصة",
"no_duplicates_found": "لم يتم العثور على أي تكرارات.",
"no_exif_info_available": "لا تتوفر معلومات exif",
"no_explore_results_message": "قم برفع المزيد من الصور لاستكشاف مجموعتك.",
"no_favorites_message": "أضف المفضلة للعثور بسرعة على أفضل الصور ومقاطع الفيديو",
"no_libraries_message": "إنشاء مكتبة خارجية لعرض الصور ومقاطع الفيديو الخاصة بك",
"no_local_assets_found": "لم يتم العثور على أي اصول محلية تتطابق مع قيمة التحقق هذه",
"no_location_set": "لم يتم تحديد موقع",
"no_locked_photos_message": "الصور والفديوهات في المجلد المقفل مخفية ولن تصهر في التصفح او البحث في مكتبتك.",
"no_name": "لا اسم",
"no_notifications": "لا توجد تنبيهات",
@ -1405,6 +1470,7 @@
"no_results_description": "جرب كلمة رئيسية مرادفة أو أكثر عمومية",
"no_shared_albums_message": "قم بإنشاء ألبوم لمشاركة الصور ومقاطع الفيديو مع الأشخاص في شبكتك",
"no_uploads_in_progress": "لا يوجد اي ملفات قيد الرفع",
"not_allowed": "غير مسموح",
"not_available": "غير متاح",
"not_in_any_album": "ليست في أي ألبوم",
"not_selected": "لم يختار",
@ -1419,6 +1485,9 @@
"notifications": "إشعارات",
"notifications_setting_description": "إدارة الإشعارات",
"oauth": "OAuth",
"obtainium_configurator": "مُهيئ Obtainium",
"obtainium_configurator_instructions": "استخدم Obtainium لتثبيت تطبيق Android وتحديثه مباشرةً من صفحة إصدارات Immich على GitHub. أنشئ مفتاح API واختر الإصدار المناسب لإنشاء رابط تهيئة Obtainium الخاص بك",
"ocr": "التعرف البصري على الحروف",
"official_immich_resources": "الموارد الرسمية لشركة Immich",
"offline": "غير متصل",
"offset": "ازاحة",
@ -1450,6 +1519,7 @@
"other_variables": "متغيرات أخرى",
"owned": "مملوكة",
"owner": "المالك",
"page": "صفحة",
"partner": "شريك",
"partner_can_access": "يستطيع {partner} الوصول",
"partner_can_access_assets": "جميع الصور ومقاطع الفيديو الخاصة بك باستثناء تلك الموجودة في المؤرشفة والمحذوفة",
@ -1512,6 +1582,8 @@
"photos_count": "{count, plural, one {{count, number} صورة} other {{count, number} صور}}",
"photos_from_previous_years": "صور من السنوات السابقة",
"pick_a_location": "اختر موقعًا",
"pick_custom_range": "نطاق مخصص",
"pick_date_range": "حدد نطاق التاريخ",
"pin_code_changed_successfully": "تم تغير رمز PIN بنجاح",
"pin_code_reset_successfully": "تم اعادة تعيين رمز PIN بنجاح",
"pin_code_setup_successfully": "تم انشاء رمز PIN بنجاح",
@ -1523,6 +1595,9 @@
"play_memories": "تشغيل الذكريات",
"play_motion_photo": "تشغيل الصور المتحركة",
"play_or_pause_video": "تشغيل الفيديو أو إيقافه مؤقتًا",
"play_original_video": "تشغيل الفيديو الأصلي",
"play_original_video_setting_description": "تفضيل تشغيل مقاطع الفيديو الأصلية بدلاً من مقاطع الفيديو المحولة. إذا لم يكن الملف الأصلي متوافقًا، فقد لا يتم تشغيله بشكل صحيح.",
"play_transcoded_video": "تشغيل الفيديو المُعاد ترميزه",
"please_auth_to_access": "الرجاء القيام بالمصادقة للوصول",
"port": "المنفذ",
"preferences_settings_subtitle": "ادارة تفضيلات التطبيق",
@ -1659,6 +1734,7 @@
"reset_sqlite_confirmation": "هل أنت متأكد من رغبتك في إعادة ضبط قاعدة بيانات SQLite؟ ستحتاج إلى تسجيل الخروج ثم تسجيل الدخول مرة أخرى لإعادة مزامنة البيانات",
"reset_sqlite_success": "تم إعادة تعيين قاعدة بيانات SQLite بنجاح",
"reset_to_default": "إعادة التعيين إلى الافتراضي",
"resolution": "دقة",
"resolve_duplicates": "معالجة النسخ المكررة",
"resolved_all_duplicates": "تم حل جميع التكرارات",
"restore": "الاستعاده من سلة المهملات",
@ -1677,6 +1753,7 @@
"running": "قيد التشغيل",
"save": "حفظ",
"save_to_gallery": "حفظ الى المعرض",
"saved": "تم الحفظ",
"saved_api_key": "تم حفظ مفتاح الـ API",
"saved_profile": "تم حفظ الملف",
"saved_settings": "تم حفظ الإعدادات",
@ -1693,6 +1770,9 @@
"search_by_description_example": "يوم المشي لمسافات طويلة في سابا",
"search_by_filename": "البحث بإسم الملف أو نوعه",
"search_by_filename_example": "كـ IMG_1234.JPG أو PNG",
"search_by_ocr": "البحث عن طريق التعرف البصري على الحروف",
"search_by_ocr_example": "لاتيه",
"search_camera_lens_model": "بحث نموذج العدسة...",
"search_camera_make": "البحث حسب الشركة المصنعة للكاميرا...",
"search_camera_model": "البحث حسب موديل الكاميرا...",
"search_city": "البحث حسب المدينة...",
@ -1709,6 +1789,7 @@
"search_filter_location_title": "اختر الموقع",
"search_filter_media_type": "نوع الوسائط",
"search_filter_media_type_title": "اختر نوع الوسائط",
"search_filter_ocr": "البحث عن طريق التعرف البصري على الحروف",
"search_filter_people_title": "اختر الاشخاص",
"search_for": "البحث عن",
"search_for_existing_person": "البحث عن شخص موجود",
@ -1770,7 +1851,10 @@
"server_offline": "الخادم غير متصل",
"server_online": "الخادم متصل",
"server_privacy": "خصوصية الخادم",
"server_restarting_description": "سيتم تحديث هذه الصفحة بعد لحضات.",
"server_restarting_title": "يتم اعادة تشغيل الخادم",
"server_stats": "إحصائيات الخادم",
"server_update_available": "تحديث الخادم متاح",
"server_version": "إصدار الخادم",
"set": "‏تحديد",
"set_as_album_cover": "تحديد كغلاف للألبوم",
@ -1799,6 +1883,8 @@
"setting_notifications_subtitle": "اضبط تفضيلات الإخطار",
"setting_notifications_total_progress_subtitle": "التقدم التحميل العام (تم القيام به/إجمالي الأصول)",
"setting_notifications_total_progress_title": "إظهار النسخ الاحتياطي الخلفية التقدم المحرز",
"setting_video_viewer_auto_play_subtitle": "بدء تشغيل مقاطع الفيديو تلقائيًا عند فتحها",
"setting_video_viewer_auto_play_title": "تشغيل الفيديوهات تلقائيًا",
"setting_video_viewer_looping_title": "تكرار مقطع فيديو تلقائيًا",
"setting_video_viewer_original_video_subtitle": "عند بث فيديو من الخادم، شغّل النسخة الأصلية حتى مع توفر ترميز بديل. قد يؤدي ذلك إلى تقطيع اثناء العرض . تُشغّل الفيديوهات المتوفرة محليًا بجودة أصلية بغض النظر عن هذا الإعداد.",
"setting_video_viewer_original_video_title": "اجبار عرض الفديو الاصلي",
@ -1890,6 +1976,7 @@
"show_slideshow_transition": "إظهار انتقال عرض الشرائح",
"show_supporter_badge": "شارة المؤيد",
"show_supporter_badge_description": "إظهار شارة المؤيد",
"show_text_recognition": "اضهار التعرف على النصوص",
"show_text_search_menu": "عرض قائمة خيارات البحث في النص",
"shuffle": "خلط",
"sidebar": "الشريط الجانبي",
@ -1960,6 +2047,7 @@
"tags": "العلامات",
"tap_to_run_job": "انقر لتشغيل المهمة",
"template": "النموذج",
"text_recognition": "التعرف على النصوص",
"theme": "مظهر",
"theme_selection": "اختيار السمة",
"theme_selection_description": "قم بتعيين السمة تلقائيًا على اللون الفاتح أو الداكن بناءً على تفضيلات نظام المتصفح الخاص بك",
@ -1978,7 +2066,9 @@
"theme_setting_three_stage_loading_title": "تمكين تحميل ثلاث مراحل",
"they_will_be_merged_together": "سيتم دمجهم معًا",
"third_party_resources": "موارد الطرف الثالث",
"time": "وقت",
"time_based_memories": "ذكريات استنادًا للوقت",
"time_based_memories_duration": "عدد الثواني لاظهار كل صورة.",
"timeline": "الخط الزمني",
"timezone": "المنطقة الزمنية",
"to_archive": "أرشفة",
@ -1990,6 +2080,7 @@
"to_select": "للتحديد",
"to_trash": "حذف",
"toggle_settings": "الإعدادات",
"toggle_theme_description": "تبديل السمة",
"total": "الإجمالي",
"total_usage": "الاستخدام الإجمالي",
"trash": "المهملات",
@ -2010,6 +2101,7 @@
"troubleshoot": "استكشاف المشاكل",
"type": "النوع",
"unable_to_change_pin_code": "تفيير رمز PIN غير ممكن",
"unable_to_check_version": "تعذر التحقق من إصدار التطبيق أو الخادم",
"unable_to_setup_pin_code": "انشاء رمز PIN غير ممكن",
"unarchive": "أخرج من الأرشيف",
"unarchive_action_prompt": "{count} ازيل من الارشيف",
@ -2118,12 +2210,13 @@
"welcome": "مرحباً",
"welcome_to_immich": "مرحباً بك في Immich",
"wifi_name": "اسم شبكة Wi-Fi",
"wrong_pin_code": "رمز PIN خاطئ",
"workflow": "سير العمل",
"wrong_pin_code": "رمز التعريف الشخصي خاطئ",
"year": "سنة",
"years_ago": "منذ {years, plural, one {# سنة} other {# سنوات}}",
"years_ago": "{years, plural, one {# سنة} other {# سنوات}} مضت",
"yes": "نعم",
"you_dont_have_any_shared_links": "ليس لديك أي روابط مشتركة",
"your_wifi_name": "اسم شبكة Wi-Fi الخاص بك",
"your_wifi_name": "اسم شبكة الاتصال اللاسلكي الخاص بك",
"zoom_image": "تكبير الصورة",
"zoom_to_bounds": "تكبير حتى حدود المنطقة"
}

View File

@ -7,6 +7,7 @@
"action_common_update": "Yenilə",
"actions": "Əməliyyatlar",
"active": "Aktiv",
"active_count": "Aktiv: {count}",
"activity": "Fəaliyyət",
"activity_changed": "Fəaliyyət {enabled, select, true {aktivdir} other {aktiv deyil}}",
"add": "Əlavə et",
@ -17,7 +18,6 @@
"add_birthday": "Doğum günü əlavə et",
"add_endpoint": "Son nöqtə əlavə et",
"add_exclusion_pattern": ıxarma nümunəsi əlavə et",
"add_import_path": "İdxal yolu əlavə et",
"add_location": "Məkan əlavə et",
"add_more_users": "Daha çox istifadəçi əlavə et",
"add_partner": "Partnyor əlavə et",
@ -32,7 +32,9 @@
"add_to_album_toggle": "{album} üçün seçimi dəyişin",
"add_to_albums": "Albomlara əlavə et",
"add_to_albums_count": "({count}) albomlarına əlavə et",
"add_to_bottom_bar": "Əlavə et",
"add_to_shared_album": "Paylaşılan alboma əlavə et",
"add_upload_to_stack": "Yeni yüklənmə əlavə et",
"add_url": "URL əlavə et",
"added_to_archive": "Arxivə əlavə edildi",
"added_to_favorites": "Sevimlilələrə əlavə edildi",
@ -64,7 +66,6 @@
"confirm_user_password_reset": "{user} adlı istifadəçinin şifrəsini sıfırlamaq istədiyinizdən əminmisiniz?",
"disable_login": "Giriş etməni söndür",
"duplicate_detection_job_description": "Bənzər şəkilləri tapmaq üçün maşın öyrənməsini işə salın. Bu prosses Smart Search funksiyasına əsaslanır",
"external_library_management": "Xarici kitabxana idarəetməsi",
"face_detection": "Üz tanıma",
"force_delete_user_warning": "XƏBƏRDARLIQ: Bu əməliyyat istifadəçi və bütün məlumatları siləcəkdir. Bu prossesi və silinən faylları geri qaytarmaq olmaz.",
"image_format_description": "WebP, JPEG faylına görə daha kiçik həcmə sahibdir, lakin onu kodlaşdırmaq daha çox vaxt alır.",
@ -80,12 +81,10 @@
"job_not_concurrency_safe": "Bu iş eyni vaxtda icra üçün təhlükəsiz deyil.",
"job_settings": "Tapşırıq parametrləri",
"job_settings_description": "Parallel şəkildə fəaliyyət göstərən tapşırıqları idarə et",
"job_status": "Tapşırıq statusu",
"jobs_delayed": "{jobCount, plural, other {# gecikməli}}",
"jobs_failed": "{jobCount, plural, other {# uğursuz}}",
"library_created": "{library} kitabxanası yaradıldı",
"library_deleted": "Kitabxana silindi",
"library_import_path_description": "İdxal olunacaq qovluöu seçin. Bu qovluq, alt qovluqlar daxil olmaqla şəkil və videolar üçün skan ediləcəkdir.",
"library_scanning": "Periodik skan",
"library_scanning_description": "Periodik kitabxana skanını confiqurasiya et",
"library_scanning_enable_description": "Periodik kitabxana skanını aktivləşdir",

View File

@ -17,7 +17,6 @@
"add_birthday": "Дадаць дзень нараджэння",
"add_endpoint": "Дадаць кропку доступу",
"add_exclusion_pattern": "Дадаць шаблон выключэння",
"add_import_path": "Дадаць шлях імпарту",
"add_location": "Дадайце месца",
"add_more_users": "Дадаць больш карыстальнікаў",
"add_partner": "Дадаць партнёра",
@ -73,7 +72,6 @@
"disable_login": "Адключыць уваход",
"duplicate_detection_job_description": "Запусціць машыннае навучанне на актывах для выяўлення падобных выяў. Залежыць ад Smart Search",
"exclusion_pattern_description": "Шаблоны выключэння дазваляюць ігнараваць файлы і папкі пры сканаванні вашай бібліятэкі. Гэта карысна, калі ў вас ёсць папкі, якія змяшчаюць файлы, якія вы не хочаце імпартаваць, напрыклад, файлы RAW.",
"external_library_management": "Кіраванне знешняй бібліятэкай",
"face_detection": "Выяўленне твараў",
"face_detection_description": "Выяўляць твары на фотаздымках і відэа з дапамогай машыннага навучання. Для відэа ўлічваецца толькі мініяцюра. \"Абнавіць\" (пера)апрацоўвае ўсе медыя. \"Скінуць\" дадаткова ачышчае ўсе бягучыя даныя пра твары. \"Адсутнічае\" ставіць у чаргу медыя, якія яшчэ не былі апрацаваныя. Выяўленыя твары будуць пастаўлены ў чаргу для распазнавання асоб пасля завяршэння выяўлення твараў, з групаваннем іх па існуючых або новых людзях.",
"facial_recognition_job_description": "Групаваць выяўленыя твары па асобах. Гэты этап выконваецца пасля завяршэння выяўлення твараў. \"Скінуць\" (паўторна) перагрупоўвае ўсе твары. \"Адсутнічае\" ставіць у чаргу твары, якія яшчэ не прыпісаныя да якой-небудзь асобы.",
@ -104,7 +102,6 @@
"job_not_concurrency_safe": "Гэта заданне небяспечнае для канкурэнтнага(адначасовага, паралельнага) выканання.",
"job_settings": "Налады заданняў",
"job_settings_description": "Кіраваць наладамі адначасовага (паралельнага) выканання задання",
"job_status": "Становішча задання",
"jobs_delayed": "{jobCount, plural, other {# адкладзена}}",
"jobs_failed": "{jobCount, plural, other {# не выканалася}}",
"library_created": "Створана бібліятэка: {library}",
@ -172,7 +169,6 @@
"user_restore_description": "Уліковы запіс карыстальніка <b>{user}</b> будзе адноўлены.",
"user_settings": "Налады карыстальніка",
"user_settings_description": "Кіраванне наладамі карыстальніка",
"user_successfully_removed": "Карыстальнік {email} быў паспяхова выдалены.",
"version_check_enabled_description": "Уключыць праверку версіі",
"version_check_implications": "Функцыя праверкі версіі перыядычна звяртаецца да github.com",
"version_check_settings": "Праверка версіі",
@ -318,8 +314,6 @@
"edit_description": "Рэдагаваць апісанне",
"edit_description_prompt": "Выберыце новае апісанне:",
"edit_faces": "Рэдагаваць твары",
"edit_import_path": "Рэдагаваць шлях імпарту",
"edit_import_paths": "Рэдагаваць шляхі імпарту",
"edit_key": "Рэдагаваць ключ",
"edit_link": "Рэдагаваць спасылку",
"edit_location": "Рэдагаваць месцазнаходжанне",
@ -398,6 +392,8 @@
"partner_list_user_photos": "Фота карыстальніка {user}",
"pause": "Прыпыніць",
"people": "Людзі",
"permanent_deletion_warning": "Папярэджанне аб канчатковым выдаленні",
"permanent_deletion_warning_setting_description": "Паказаць папярэджанне пры канчатковым выдаленні рэсурсаў",
"permission_onboarding_back": "Назад",
"permission_onboarding_continue_anyway": "Усё адно працягнуць",
"photos": "Фота",

View File

@ -7,6 +7,7 @@
"action_common_update": "Обнови",
"actions": "Действия",
"active": "Активни",
"active_count": "Активни: {count}",
"activity": "Дейност",
"activity_changed": "Дейността е {enabled, select, true {включена} other {изключена}}",
"add": "Добави",
@ -17,7 +18,6 @@
"add_birthday": "Добави дата на раждане",
"add_endpoint": "Добави крайна точка",
"add_exclusion_pattern": "Добави модел за изключване",
"add_import_path": "Добави път за импортиране",
"add_location": "Дoбави местоположение",
"add_more_users": "Добави още потребители",
"add_partner": "Добави партньор",
@ -32,6 +32,7 @@
"add_to_album_toggle": "Сменете избора за {album}",
"add_to_albums": "Добавяне в албуми",
"add_to_albums_count": "Добавяне в албуми ({count})",
"add_to_bottom_bar": "Добави към",
"add_to_shared_album": "Добави към споделен албум",
"add_upload_to_stack": "Добави качените в група",
"add_url": "Добави URL",
@ -67,6 +68,7 @@
"confirm_reprocess_all_faces": "Сигурни ли сте, че искате да се обработят лицата отново? Това ще изчисти всички именувани хора.",
"confirm_user_password_reset": "Сигурни ли сте, че искате да нулирате паролата на {user}?",
"confirm_user_pin_code_reset": "Наистина ли искате да смените PIN кода на потребителя {user}?",
"copy_config_to_clipboard_description": "Копирай текущата системна конфигурация като JSON обект в клипборда",
"create_job": "Създайте задача",
"cron_expression": "Cron израз",
"cron_expression_description": "Настрой интервала на сканиране използвайки cron формата. За повече информация <link>Crontab Guru</link>",
@ -74,7 +76,8 @@
"disable_login": "Изключете вписването",
"duplicate_detection_job_description": "Стартиране машинно обучение върху елементи, за откриване на подобни изображения. Разчита на Интелигентно Търсене",
"exclusion_pattern_description": "Модели за изключване позволяват да игнорирате файлове и папки, когато сканирате вашата библиотека. Това е потребно, ако имате папки, които съдържат файлове, които не искате да импортирате. Примерно - RAW файлове.",
"external_library_management": "Управление на външните библиотеки",
"export_config_as_json_description": "Запази текущата системна конфигурация като JSON файл",
"external_libraries_page_description": "Администриране на външната страница на библиотеката",
"face_detection": "Откриване на лица",
"face_detection_description": "Да се разпознават лица в елементи чрез машинно обучение. За видеата се използва само миниатюрата. \"Всички\" обработва отново всички елементи. \"Липсващи\" зарежда за обработка елементи, които на се обработени все още. Откритите лица ще бъдат подредени в опашка за разпознаване на лица след завършване на функцията за откриване на лица, като се групират в съществуващи или нови хора.",
"facial_recognition_job_description": "Групирайте откритите лица в хора. Тази стъпка се изпълнява след завършване на разпознаването на лица. „Нулиране“ прегрупира всички лица. „Липсващи“ поставя в опашка лицата, които нямат назначен човек.",
@ -102,23 +105,28 @@
"image_thumbnail_description": "Малка миниатюра с премахнати метаданни, използвана при преглед на групи снимки, като основния екран",
"image_thumbnail_quality_description": "Качество на миниатюрата от 1 до 100. По-високата стойност е по-добра, но води до по-големи файлове и може да намали бързодействието на приложението.",
"image_thumbnail_title": "Настройки на миниатюрите",
"import_config_from_json_description": "Импорт на системна конфигурация чрез качване на JSON файл",
"job_concurrency": "Паралелност на {job}",
"job_created": "Задачата е създадена",
"job_not_concurrency_safe": "Тази задача не е безопасна за паралелно изпълнение.",
"job_settings": "Настройки за задачите",
"job_settings_description": "Управление на паралелността на задачите",
"job_status": "Статус на задачата",
"jobs_delayed": "{jobCount, plural, other {# забавени}}",
"jobs_failed": "{jobCount, plural, other {# неуспешни}}",
"jobs_over_time": "Работа във времето",
"library_created": "Създадена библиотека: {library}",
"library_deleted": "Библиотека е изтрита",
"library_import_path_description": "Посочете папка за импортиране. Тази папка, включително подпапките, ще бъдат сканирани за изображения и видеоклипове.",
"library_details": "Подробности за библиотеката",
"library_folder_description": "Изберете папка за импортиране. Папката и подпапките в нея ще бъдат сканирани за изображения и видеа.",
"library_remove_exclusion_pattern_prompt": "Сигурни ли сте, че искате да премахнете този шаблон за игнориране?",
"library_remove_folder_prompt": "Сигурни ли сте, че искате да премахнете тази папка за импортиране?",
"library_scanning": "Периодично сканиране",
"library_scanning_description": "Конфигурирай периодично сканиране на библиотеката",
"library_scanning_enable_description": "Включване на периодичното сканиране на библиотеката",
"library_settings": "Външна библиотека",
"library_settings_description": "Управление на настройките за външна библиотека",
"library_tasks_description": "Сканирайте външни библиотеки за нови и/или променени елементи",
"library_updated": "Обновена библиотека",
"library_watching_enable_description": "Наблюдаване за промяна на файловете във външната библиотека",
"library_watching_settings": "Наблюдаване на библиотеката [ЕКСПЕРИМЕНТАЛНО]",
"library_watching_settings_description": "Автоматично наблюдавай за променени файлове",
@ -151,9 +159,21 @@
"machine_learning_max_recognition_distance": "Максимално разстояние за разпознаване",
"machine_learning_max_recognition_distance_description": "Максимално разстояние между две лица, за да се считат за едно и също лице, в диапазона 0-2. Намаляването му може да предотврати определянето на две лица като едно и също лице, а увеличаването му може да предотврати определянето на едно и също лице като две различни лица. Имайте предвид, че е по-лесно да се слеят две лица, отколкото да се раздели едно лице на две, така че по възможност изберете по-ниска стойност.",
"machine_learning_min_detection_score": "Минимална оценка за откриване",
"machine_learning_min_detection_score_description": "Минимална оценка на доверието, за да бъде считано лице като открито - от 0 до 1. По-ниските стойности ще открият повече лица, но може да доведат до фалшиви положителни резултати.",
"machine_learning_min_detection_score_description": "Минимална оценка на доверие, за да бъде считано лице като открито - от 0 до 1. По-ниските стойности ще открият повече лица, но може да доведат до фалшиви положителни резултати.",
"machine_learning_min_recognized_faces": "Минимум разпознати лица",
"machine_learning_min_recognized_faces_description": "Минималният брой разпознати лица, необходими за създаването на лице. Увеличаването му прави разпознаването на лица по-прецизно за сметка на увеличаването на вероятността дадено лице да не бъде причислено към лице.",
"machine_learning_ocr": "Оптично разпознаване на текст",
"machine_learning_ocr_description": "Използвайте машинно обучение за разпознаване на текст в изображенията",
"machine_learning_ocr_enabled": "Включи разпознаване на текст",
"machine_learning_ocr_enabled_description": "Ако е забранено, няма да се прави разпознаване на текст в изображенията.",
"machine_learning_ocr_max_resolution": "Максимална резолюция",
"machine_learning_ocr_max_resolution_description": "Изображения с резолюция над зададената ще бъдат преоразмерени при запазване на пропорцията. Голяма стойност позволява по-прецизно разпознаване, но обработката използва повече време и повече памет.",
"machine_learning_ocr_min_detection_score": "Минимална оценка за откриванe",
"machine_learning_ocr_min_detection_score_description": "Минималната оценка на доверие за откриване на текст може да бъде между 0 и 1. По-ниска стойност ще открива повече текст, но може да доведе до грешни резултати.",
"machine_learning_ocr_min_recognition_score": "Минимална оценкa за откриване",
"machine_learning_ocr_min_score_recognition_description": "Минимална оценка на доверие, за да бъде считан текст като открит - от 0 до 1. По-ниските стойности ще открият повече текст, но може да доведат до фалшиви положителни резултати.",
"machine_learning_ocr_model": "Модел за разпознаване на текст",
"machine_learning_ocr_model_description": "Сървърните модели са по-точни от мобилните модели, но изискват повече време и използват повече памет.",
"machine_learning_settings": "Настройки на машинното обучение",
"machine_learning_settings_description": "Управление на функциите и настройките за машинно обучение",
"machine_learning_smart_search": "Интелигентно Търсене",
@ -161,7 +181,12 @@
"machine_learning_smart_search_enabled": "Включване на Интелигентно Търсене",
"machine_learning_smart_search_enabled_description": "Ако е деактивирано, изображенията няма да бъдат кодирани за Интелигентно Търсене.",
"machine_learning_url_description": "URL на сървъра за машинно обучение. Ако са предоставени повече от един URL, всеки сървър ще бъде опитан един по един, докато един отговори успешно, в реда от първия до последния. Сървъри, които не отговорят, ще бъдат временно игнорирани, докато не се върнат онлайн.",
"maintenance_settings": "Обслужване",
"maintenance_settings_description": "Преквлючване на сървъра Immich в режим на обслужване.",
"maintenance_start": "Започни режим на обслужване",
"maintenance_start_error": "Неуспешно преминаване в режим на обслужване.",
"manage_concurrency": "Управление на паралелност",
"manage_concurrency_description": "Отидете на страницата със задачи, за да управлявате едновременността им",
"manage_log_settings": "Управление на настройките на записване",
"map_dark_style": "Тъмен стил",
"map_enable_description": "Активиране на картата",
@ -245,15 +270,20 @@
"oauth_storage_quota_default_description": "Квота в GiB, която да се използва, когато не е посочено друго.",
"oauth_timeout": "Време на изчакване при заявка",
"oauth_timeout_description": "Време за изчакване на отговор на заявка, в милисекунди",
"ocr_job_description": "Използване на машинно обучение за разпознаване на текст в изображенията",
"password_enable_description": "Влизане с имейл и парола",
"password_settings": "Вписване с парола",
"password_settings_description": "Управление на настройките за влизане с парола",
"paths_validated_successfully": "Всички пътища са валидирани успешно",
"person_cleanup_job": "Почистване на лица",
"queue_details": "Детайли по Опашката",
"queues": "Опашки за задачи",
"queues_page_description": "Страница с опашки за администраторски задачи",
"quota_size_gib": "Размер на квотата (GiB)",
"refreshing_all_libraries": "Опресняване на всички библиотеки",
"registration": "Администраторска регистрация",
"registration_description": "Тъй като сте първият потребител в системата, ще бъдете назначен като администратор и ще отговаряте за административните задачи, а допълнителните потребители ще бъдат създадени от вас.",
"remove_failed_jobs": "Премахване на неуспешни задачи",
"require_password_change_on_login": "Изискване за промяна паролата при първо влизане",
"reset_settings_to_default": "Възстановяване на настройките по подразбиране",
"reset_settings_to_recent_saved": "Възстановяване на настройките до последните запазени настройки",
@ -266,8 +296,10 @@
"server_public_users_description": "Всички потребители (име и имейл) са изброени при добавяне на потребител в споделени албуми. Когато е деактивирано, списъкът с потребители ще бъде достъпен само за администраторите.",
"server_settings": "Настройки на сървъра",
"server_settings_description": "Управление на настройките на сървъра",
"server_stats_page_description": "Администраторска страница със статистика за сървъра",
"server_welcome_message": "Поздравително съобщение",
"server_welcome_message_description": "Съобщение, показващо се на страницата за вход.",
"settings_page_description": "Страница с настройки за администратора",
"sidecar_job": "Метаданни от свързани (sidecar) файлове",
"sidecar_job_description": "Откриване или синхронизиране на странични (sidecar) метаданни от файловата система",
"slideshow_duration_description": "Брой секунди за показване на всяко изображение",
@ -386,7 +418,8 @@
"user_restore_scheduled_removal": "Възстановяване на потребител с насрочено премахване на {date, date, long}",
"user_settings": "Настройки на потребителя",
"user_settings_description": "Управление на потребителските настройки",
"user_successfully_removed": "Потребителят {email} е успешно премахнат.",
"user_successfully_removed": "Потребител {email} е успешно премахнат.",
"users_page_description": "Страница за администриране на потребители",
"version_check_enabled_description": "Активирай проверка на версията",
"version_check_implications": "Функцията за проверка на версията разчита на периодична комуникация с github.com",
"version_check_settings": "Проверка на версията",
@ -417,6 +450,7 @@
"age_months": "Възраст {months, plural, one {# месец} other {# месеци}}",
"age_year_months": "Възраст 1 година, {months, plural, one {# месец} other {# месеци}}",
"age_years": "{years, plural, other {Година #}}",
"album": "Албум",
"album_added": "Албумът е добавен",
"album_added_notification_setting_description": "Получавайте известие по имейл, когато бъдете добавени към споделен албум",
"album_cover_updated": "Обложката на албума е актуализирана",
@ -462,6 +496,7 @@
"allow_edits": "Позволяване на редакции",
"allow_public_user_to_download": "Позволете на публичен потребител да може да изтегля",
"allow_public_user_to_upload": "Позволете на публичния потребител да може да качва",
"allowed": "Разрешено",
"alt_text_qr_code": "Изображение на QR код",
"anti_clockwise": "Обратно на часовниковата стрелка",
"api_key": "API ключ",
@ -617,6 +652,7 @@
"backup_options_page_title": "Настройки за архивиране",
"backup_setting_subtitle": "Управлявай настройките за архивиране в активен и фонов режим",
"backup_settings_subtitle": "Управление на настройките за качване",
"backup_upload_details_page_more_details": "Повече подробности",
"backward": "Назад",
"biometric_auth_enabled": "Включена биометрично удостоверяване",
"biometric_locked_out": "Няма достъп до биометрично удостоверяване",
@ -669,6 +705,8 @@
"change_password_description": "Това е или първият път, когато влизате в системата, или е направена заявка за промяна на паролата ви. Моля, въведете новата парола по-долу.",
"change_password_form_confirm_password": "Потвърди паролата",
"change_password_form_description": "Здравейте {name},\n\nТова или е първото ви вписване в системата или има подадена заявка за смяна на паролата. Моля, въведете нова парола в полето по-долу.",
"change_password_form_log_out": "Излизане от профила на всички други устройства",
"change_password_form_log_out_description": "Препоръчваме да се излезе от профила на всички други устройства",
"change_password_form_new_password": "Нова парола",
"change_password_form_password_mismatch": "Паролите не съвпадат",
"change_password_form_reenter_new_password": "Повтори новата парола",
@ -681,6 +719,7 @@
"check_corrupt_asset_backup_button": "Провери",
"check_corrupt_asset_backup_description": "Изпълни тази проверка само при Wi-Fi и след архивиране на всички обекти. Процедурата може да продължи няколко минути.",
"check_logs": "Провери логовете",
"checksum": "Контролна сума",
"choose_matching_people_to_merge": "Изберете подходящи хора за сливане",
"city": "Град",
"clear": "Изчисти",
@ -703,6 +742,7 @@
"collapse_all": "Свиване на всичко",
"color": "Цвят",
"color_theme": "Цветова тема",
"command": "Команда",
"comment_deleted": "Коментарът е изтрит",
"comment_options": "Опции за коментар",
"comments_and_likes": "Коментари и харесвания",
@ -776,6 +816,7 @@
"daily_title_text_date_year": "E, dd MMM yyyy",
"dark": "Тъмен",
"dark_theme": "Тъмна тема",
"date": "Дата",
"date_after": "Дата след",
"date_and_time": "Дата и час",
"date_before": "Дата преди",
@ -878,8 +919,6 @@
"edit_description_prompt": "Моля, избери ново описание:",
"edit_exclusion_pattern": "Редактиране на шаблон за изключване",
"edit_faces": "Редактиране на лица",
"edit_import_path": "Редактиране на пътя за импортиране",
"edit_import_paths": "Редактиране на пътища за импортиране",
"edit_key": "Редактиране на ключ",
"edit_link": "Редактиране на линк",
"edit_location": "Редактиране на местоположението",
@ -951,8 +990,8 @@
"failed_to_stack_assets": "Неуспешно подреждане на обекти",
"failed_to_unstack_assets": "Неуспешно премахване на подредбата на обекти",
"failed_to_update_notification_status": "Неуспешно обновяване на състоянието на известията",
"import_path_already_exists": "Този път за импортиране вече съществува.",
"incorrect_email_or_password": "Неправилен имейл или парола",
"library_folder_already_exists": "Тази папка вече съществува.",
"paths_validation_failed": "{paths, plural, one {# път} other {# пътища}} не преминаха валидация",
"profile_picture_transparent_pixels": "Профилните снимки не могат да имат прозрачни пиксели. Моля, увеличете и/или преместете изображението.",
"quota_higher_than_disk_size": "Зададена е квота, по-голяма от размера на диска",
@ -961,7 +1000,6 @@
"unable_to_add_assets_to_shared_link": "Неуспешно добавяне на обекти в споделен линк",
"unable_to_add_comment": "Неуспешно добавяне на коментар",
"unable_to_add_exclusion_pattern": "Неуспешно добавяне на шаблон за изключение",
"unable_to_add_import_path": "Неуспешно добавяне на път за импортиране",
"unable_to_add_partners": "Неуспешно добавяне на партньори",
"unable_to_add_remove_archive": "Неуспешно {archived, select, true {премахване на обект от} other {добавяне на обект в}} архива",
"unable_to_add_remove_favorites": "Неуспешно {favorite, select, true {добавяне на обект в} other {премахване на обект от}} любими",
@ -984,12 +1022,10 @@
"unable_to_delete_asset": "Не може да изтрие файла",
"unable_to_delete_assets": "Грешка при изтриване на файлове",
"unable_to_delete_exclusion_pattern": "Не може да изтрие шаблон за изключване",
"unable_to_delete_import_path": "Пътят за импортиране не може да се изтрие",
"unable_to_delete_shared_link": "Споделената връзка не може да се изтрие",
"unable_to_delete_user": "Не може да изтрие потребител",
"unable_to_download_files": "Не могат да се изтеглят файловете",
"unable_to_edit_exclusion_pattern": "Не може да се редактира шаблон за изключване",
"unable_to_edit_import_path": "Пътят за импортиране не може да се редактира",
"unable_to_empty_trash": "Неуспешно изпразване на кошчето",
"unable_to_enter_fullscreen": "Не може да се отвори в цял екран",
"unable_to_exit_fullscreen": "Не може да излезе от цял екран",
@ -1040,6 +1076,7 @@
"unable_to_update_user": "Неуспешно обновяване на потребителя",
"unable_to_upload_file": "Неуспешно качване на файл"
},
"exclusion_pattern": "Шаблон за изключение",
"exif": "Exif",
"exif_bottom_sheet_description": "Добави Описание...",
"exif_bottom_sheet_description_error": "Неуспешно обновяване на описание",
@ -1070,6 +1107,7 @@
"external_network_sheet_info": "Когато няма връзка с предпочитаната Wi-Fi мрежа, приложението ще опитва да се свърже със сървъра чрез първия достъпен URL адрес, започвайки отгоре надолу",
"face_unassigned": "Незададено",
"failed": "Неуспешно",
"failed_count": "Неуспешни: {count}",
"failed_to_authenticate": "Неуспешна автентикация",
"failed_to_load_assets": "Неуспешно зареждане на елементи",
"failed_to_load_folder": "Неуспешно зареждане на папка",
@ -1084,6 +1122,7 @@
"features_setting_description": "Управление на функциите на приложението",
"file_name": "Име на файла",
"file_name_or_extension": "Име на файл или разширение",
"file_size": "Размер на файла",
"filename": "Име на файл",
"filetype": "Тип на файл",
"filter": "Филтър",
@ -1098,6 +1137,7 @@
"folders_feature_description": "Преглеждане на папката за снимките и видеоклиповете в файловата система",
"forgot_pin_code_question": "Забравили сте своя ПИН код?",
"forward": "Напред",
"full_path": "Пълен път: {path}",
"gcast_enabled": "Google Cast",
"gcast_enabled_description": "За да работи тази функция зарежда външни ресурси от Google.",
"general": "Общи",
@ -1128,12 +1168,14 @@
"header_settings_header_name_input": "Име на заглавието",
"header_settings_header_value_input": "Стойност на заглавието",
"headers_settings_tile_title": "Потребителски прокси заглавия",
"height": "Височина",
"hi_user": "Здравей, {name} {email}",
"hide_all_people": "Скрий всички хора",
"hide_gallery": "Скрий галерия",
"hide_named_person": "Скрий човек {name}",
"hide_password": "Скрий парола",
"hide_person": "Скрий човек",
"hide_text_recognition": "Скрий разпознатия текст",
"hide_unnamed_people": "Скрий неназовани хора",
"home_page_add_to_album_conflicts": "Добавени са {added} обекта в албума {album}. Вече има {failed} обекта.",
"home_page_add_to_album_err_local": "Все още не е възможно да се добавят локални обекти в албумите, пропускане",
@ -1179,6 +1221,8 @@
"import_path": "Път за импортиране",
"in_albums": "В {count, plural, one {# албум} other {# албума}}",
"in_archive": "В архив",
"in_year": "{year} г.",
"in_year_selector": "През",
"include_archived": "Включване на архивирани",
"include_shared_albums": "Включване на споделени албуми",
"include_shared_partner_assets": "Включване на споделените с партньор елементи",
@ -1215,6 +1259,7 @@
"language_setting_description": "Изберете предпочитан език",
"large_files": "Големи файлове",
"last": "Последен",
"last_months": "{count, plural, one {Последния месец} other {Последните # месеца}}",
"last_seen": "Последно видяно",
"latest_version": "Последна версия",
"latitude": "Ширина",
@ -1224,6 +1269,8 @@
"let_others_respond": "Позволете на другите да отговорят",
"level": "Ниво",
"library": "Библиотека",
"library_add_folder": "Добави папка",
"library_edit_folder": "Редактиране на папка",
"library_options": "Опции на библиотеката",
"library_page_device_albums": "Албуми в устройството",
"library_page_new_album": "Нов албум",
@ -1244,9 +1291,11 @@
"local": "Локално",
"local_asset_cast_failed": "Не може да се предава обект, който още не е качен на сървъра",
"local_assets": "Локални обекти",
"local_id": "Идентификатор",
"local_media_summary": "Обобщение на локалните файлове",
"local_network": "Локална мрежа",
"local_network_sheet_info": "Приложението ще се свърже със сървъра на този URL, когато устройството е свързано към зададената Wi-Fi мрежа",
"location": "Място",
"location_permission": "Разрешение за местоположение",
"location_permission_content": "За да работи функцията автоматично превключване, Immich се нуждае от разрешение за точно местоположение, за да може да чете името на текущата Wi-Fi мрежа",
"location_picker_choose_on_map": "Избери на карта",
@ -1294,8 +1343,17 @@
"loop_videos_description": "Позволи автоматично повтаряне на видеото в изгледа на детайлите.",
"main_branch_warning": "Използвате версия за разработчици, силно препоръчваме да използвате официална версия!",
"main_menu": "Главно меню",
"maintenance_description": "Сървъра Immich е поставен в <link>режим на обслужване</link>.",
"maintenance_end": "Край на режима на обслужване",
"maintenance_end_error": "Неуспешно завършване на режима на обслужване.",
"maintenance_logged_in_as": "Текущия потребител е {user}",
"maintenance_title": "Временно недостъпен",
"make": "Марка",
"manage_geolocation": "Управление на местоположенията",
"manage_media_access_rationale": "Това разрешение е необходимо за правилно преместване на обекти в кошчето и за възстановяване от там.",
"manage_media_access_settings": "Отвори Настройки",
"manage_media_access_subtitle": "Разрешете приложението Immich да управлява и мести медийни файлове.",
"manage_media_access_title": "Управление на медийни файлове",
"manage_shared_links": "Управление на споделени връзки",
"manage_sharing_with_partners": "Управление на споделянето с партньори",
"manage_the_app_settings": "Управление на настройките на приложението",
@ -1359,6 +1417,7 @@
"more": "Още",
"move": "Премести",
"move_off_locked_folder": "Извади от заключената папка",
"move_to": "Премести към",
"move_to_lock_folder_action_prompt": "{count} са добавени в заключената папка",
"move_to_locked_folder": "Премести в заключена папка",
"move_to_locked_folder_confirmation": "Тези снимки и видеа ще бъдат изтрити от всички албуми и ще са достъпни само в заключената папка",
@ -1388,6 +1447,7 @@
"new_pin_code": "Нов PIN код",
"new_pin_code_subtitle": "Това е първи достъп до заключена папка. Създайте PIN код за защитен достъп до тази страница",
"new_timeline": "Нова времева линия",
"new_update": "Ново обновление",
"new_user_created": "Създаден нов потребител",
"new_version_available": "НАЛИЧНА НОВА ВЕРСИЯ",
"newest_first": "Най-новите първи",
@ -1403,12 +1463,14 @@
"no_cast_devices_found": "Няма намерени устройства за предаване",
"no_checksum_local": "Липсват контролни суми - не може да се получат локални обекти",
"no_checksum_remote": "Липсват контролни суми - не може да се получат обекти от сървъра",
"no_devices": "Няма оторизирани устройства",
"no_duplicates_found": "Не бяха открити дубликати.",
"no_exif_info_available": "Няма exif информация",
"no_explore_results_message": "Качете още снимки, за да разгледате колекцията си.",
"no_favorites_message": "Добавете в любими, за да намирате бързо най-добрите си снимки и видеоклипове",
"no_libraries_message": "Създайте външна библиотека за да разглеждате снимки и видеоклипове",
"no_local_assets_found": "Не е намерен локален обект с такава контролна сума",
"no_location_set": "Не е зададено местоположение",
"no_locked_photos_message": "Снимките и видеата в заключената папка са скрити и не се показват при разглеждане на библиотеката.",
"no_name": "Без име",
"no_notifications": "Няма известия",
@ -1419,6 +1481,7 @@
"no_results_description": "Опитайте със синоним или по-обща ключова дума",
"no_shared_albums_message": "Създайте албум, за да споделяте снимки и видеоклипове с хората в мрежата си",
"no_uploads_in_progress": "Няма качване в момента",
"not_allowed": "Не е разрешено",
"not_available": "Неналично",
"not_in_any_album": "Не е в никой албум",
"not_selected": "Не е избрано",
@ -1435,6 +1498,7 @@
"oauth": "OAuth",
"obtainium_configurator": "Конфигуратор за получаване",
"obtainium_configurator_instructions": "Използвайте Obtainium за инсталация и обновяване на приложението за Android директно от GitHub на Immich. Създайте API ключ и изберете вариант за да създадете Obtainium конфигурационен линк",
"ocr": "Оптично разпознаване на текст",
"official_immich_resources": "Официална информация за Immich",
"offline": "Офлайн",
"offset": "Отместване",
@ -1466,6 +1530,7 @@
"other_variables": "Други променливи",
"owned": "Моите",
"owner": "Собственик",
"page": "Страница",
"partner": "Партньор",
"partner_can_access": "{partner} има достъп",
"partner_can_access_assets": "Всички ваши снимки и видеоклипове, с изключение на тези в Архивирани и Изтрити",
@ -1528,6 +1593,8 @@
"photos_count": "{count, plural, one {{count, number} Снимка} other {{count, number} Снимки}}",
"photos_from_previous_years": "Снимки от предходни години",
"pick_a_location": "Избери локация",
"pick_custom_range": "Произволен период",
"pick_date_range": "Изберете период",
"pin_code_changed_successfully": "Успешно сменен PIN код",
"pin_code_reset_successfully": "Успешно нулиран PIN код",
"pin_code_setup_successfully": "Успешно зададен PIN код",
@ -1539,6 +1606,9 @@
"play_memories": "Възпроизвеждане на спомени",
"play_motion_photo": "Възпроизведи Motion Photo",
"play_or_pause_video": "Възпроизвеждане или пауза на видео",
"play_original_video": "Пусни оригиналното видео",
"play_original_video_setting_description": "Предпочитане на показване на оригиналното видео, вместо транскодирани. Ако формата на оригиналния файл не се поддържа, възпроизвеждането може да бъде неправилно.",
"play_transcoded_video": "Покажи транскодирано видео",
"please_auth_to_access": "Моля, удостовери за достъп",
"port": "Порт",
"preferences_settings_subtitle": "Управление на предпочитанията на приложението",
@ -1630,7 +1700,7 @@
"regenerating_thumbnails": "Пресъздаване на миниатюрите",
"remote": "На сървъра",
"remote_assets": "Обекти на сървъра",
"remote_media_summary": "Обобщение на медийните файлове на сървъра",
"remote_media_summary": "Обобщение на файловете на сървъра",
"remove": "Премахни",
"remove_assets_album_confirmation": "Сигурни ли сте, че искате да премахнете {count, plural, one {# елемент} other {# елемента}} от албума?",
"remove_assets_shared_link_confirmation": "Сигурни ли сте, че искате да премахнете {count, plural, one {# елемент} other {# елемента}} от този споеделен линк?",
@ -1675,6 +1745,7 @@
"reset_sqlite_confirmation": "Наистина ли искате да нулирате базата данни SQLite? Ще трябва да излезете от системата и да се впишете отново за нова синхронизация на данните",
"reset_sqlite_success": "Успешно нулиране на базата данни SQLite",
"reset_to_default": "Връщане на фабрични настройки",
"resolution": "Резолюция",
"resolve_duplicates": "Реши дубликатите",
"resolved_all_duplicates": "Всички дубликати са решени",
"restore": "Възстановяване",
@ -1693,6 +1764,7 @@
"running": "Изпълняване",
"save": "Запази",
"save_to_gallery": "Запази в галерията",
"saved": "Записано",
"saved_api_key": "Запазен API Key",
"saved_profile": "Запазен профил",
"saved_settings": "Запазени настройки",
@ -1709,6 +1781,9 @@
"search_by_description_example": "Разходка в Сапа",
"search_by_filename": "Търси по име на файла или разширение",
"search_by_filename_example": "например IMG_1234.JPG или PNG",
"search_by_ocr": "Търсене на текст",
"search_by_ocr_example": "Lattе",
"search_camera_lens_model": "Търсене на модел на обектива...",
"search_camera_make": "Търси производител на камерата...",
"search_camera_model": "Търси модел на камерата...",
"search_city": "Търси град...",
@ -1725,6 +1800,7 @@
"search_filter_location_title": "Избери място",
"search_filter_media_type": "Тип на файла",
"search_filter_media_type_title": "Избери тип на файла",
"search_filter_ocr": "Търсене нa текст",
"search_filter_people_title": "Избери хора",
"search_for": "Търси за",
"search_for_existing_person": "Търси съществуващ човек",
@ -1786,6 +1862,8 @@
"server_offline": "Сървър офлайн",
"server_online": "Сървър онлайн",
"server_privacy": "Поверителност на сървъра",
"server_restarting_description": "Страницата ще се обнови всеки момент.",
"server_restarting_title": "Рестартиране на сървъра",
"server_stats": "Статус на сървъра",
"server_update_available": "Налична е нова версия за сървъра",
"server_version": "Версия на сървъра",
@ -1909,6 +1987,7 @@
"show_slideshow_transition": "Покажи прехода на слайдшоуто",
"show_supporter_badge": "Значка поддръжник",
"show_supporter_badge_description": "Покажи значка поддръжник",
"show_text_recognition": "Покажи разпознатия текст",
"show_text_search_menu": "Покажи менюто за търсене на текст",
"shuffle": "Разбъркване",
"sidebar": "Странична лента",
@ -1979,9 +2058,10 @@
"tags": "Етикет",
"tap_to_run_job": "Докоснете, за да стартирате задачата",
"template": "Шаблон",
"text_recognition": "Разпознаване на текст",
"theme": "Тема",
"theme_selection": "Избор на тема",
"theme_selection_description": "Автоматично задаване на светла или тъмна тема въз основа на системните предпочитания на вашия браузър",
"theme_selection_description": "Автоматично задаване на светла или тъмна тема спрямо системните предпочитания на браузъра ви",
"theme_setting_asset_list_storage_indicator_title": "Показвай индикатор за хранилището в заглавията на обектите",
"theme_setting_asset_list_tiles_per_row_title": "Брой обекти на ред ({count})",
"theme_setting_colorful_interface_subtitle": "Нанеси основен цвят върху фоновите повърхности.",
@ -1997,7 +2077,9 @@
"theme_setting_three_stage_loading_title": "Включи три-степенно зареждане",
"they_will_be_merged_together": "Те ще бъдат обединени",
"third_party_resources": "Ресурси от трети страни",
"time": "Време",
"time_based_memories": "Спомени, базирани на времето",
"time_based_memories_duration": "Продължителност в секунди за показване на всяка картина.",
"timeline": "Хронология",
"timezone": "Часова зона",
"to_archive": "Архивирай",
@ -2009,6 +2091,7 @@
"to_select": "за избор",
"to_trash": "Кошче",
"toggle_settings": "Превключване на настройките",
"toggle_theme_description": "Превключване на темата",
"total": "Общо",
"total_usage": "Общо използвано",
"trash": "Кошче",
@ -2117,6 +2200,7 @@
"view_album": "Разгледай албума",
"view_all": "Преглед на всички",
"view_all_users": "Преглед на всички потребители",
"view_asset_owners": "Преглед на собствениците на активи",
"view_details": "Подробности за изгледа",
"view_in_timeline": "Покажи във времева линия",
"view_link": "Преглед на връзката",
@ -2133,11 +2217,14 @@
"viewer_unstack": "Премахни от опашката",
"visibility_changed": "Видимостта е променена за {count, plural, one {# човек} other {# човека}}",
"waiting": "в изчакване",
"waiting_count": "В изчакване: {count}",
"warning": "Внимание",
"week": "Седмица",
"welcome": "Добре дошли",
"welcome_to_immich": "Добре дошли в Immich",
"width": "Ширинa",
"wifi_name": "Wi-Fi мрежа",
"workflow": "Работен процес",
"wrong_pin_code": "Грешен PIN код",
"year": "Година",
"years_ago": "преди {years, plural, one {# година} other {# години}}",

View File

@ -12,12 +12,28 @@
"add_a_name": "Putem nam blo hem",
"add_a_title": "Putem wan name blo hem",
"add_exclusion_pattern": "Putem wan paten wae hemi karem aot",
"add_import_path": "Putem wan pat blo import",
"add_location": "Putem wan place blo hem",
"add_more_users": "Putem mor man",
"readonly_mode_enabled": "Mod blo yu no save janjem i on",
"reassigned_assets_to_new_person": "Janjem{count, plural, one {# asset} other {# assets}} blo nu man",
"reassing_hint": "janjem ol sumtin yu bin joos i go blo wan man",
"recent-albums": "album i no old tu mas",
"recent_searches": "lukabout wea i no old tu mas"
"recent_searches": "lukabout wea i no old tu mas",
"time_based_memories_duration": "hao mus second blo wan wan imij i stap lo scrin.",
"timezone": "taemzon",
"to_change_password": "janjem pasword",
"to_login": "Login",
"to_multi_select": "to jusem mani",
"to_parent": "go lo parent",
"to_select": "to selectem",
"to_trash": "toti",
"toggle_settings": "sho settings",
"total": "Total",
"trash": "Toti",
"trash_action_prompt": "{count} igo lo plaes lo toti",
"trash_all": "Putem ol i go lo toti",
"trash_count": "Toti {count, number}",
"trash_emptied": "basket blo toti i empti nomo",
"trash_no_results_message": "Foto mo video lo basket blo toti yu save lukem lo plaes ia.",
"trash_page_delete_all": "Delete oli ol"
}

View File

@ -17,7 +17,6 @@
"add_birthday": "একটি জন্মদিন যোগ করুন",
"add_endpoint": "এন্ডপয়েন্ট যোগ করুন",
"add_exclusion_pattern": "বহির্ভূতকরণ নমুনা",
"add_import_path": "ইমপোর্ট করার পাথ যুক্ত করুন",
"add_location": "অবস্থান যুক্ত করুন",
"add_more_users": "আরো ব্যবহারকারী যুক্ত করুন",
"add_partner": "অংশীদার যোগ করুন",
@ -73,7 +72,6 @@
"disable_login": "লগইন অক্ষম করুন",
"duplicate_detection_job_description": "অনুরূপ ছবি সনাক্ত করতে সম্পদগুলিতে মেশিন লার্নিং চালান। স্মার্ট অনুসন্ধানের উপর নির্ভর করে",
"exclusion_pattern_description": "এক্সক্লুশন প্যাটার্ন ব্যবহার করে আপনি আপনার লাইব্রেরি স্ক্যান করার সময় ফাইল এবং ফোল্ডারগুলিকে উপেক্ষা করতে পারবেন। যদি আপনার এমন ফোল্ডার থাকে যেখানে এমন ফাইল থাকে যা আপনি আমদানি করতে চান না, যেমন RAW ফাইল।",
"external_library_management": "বহিরাগত গ্রন্থাগার ব্যবস্থাপনা",
"face_detection": "মুখ সনাক্তকরণ",
"face_detection_description": "মেশিন লার্নিং ব্যবহার করে অ্যাসেটে থাকা মুখ/চেহারা গুলি সনাক্ত করুন। ভিডিও গুলির জন্য, শুধুমাত্র থাম্বনেইল বিবেচনা করা হয়। \"রিফ্রেশ\" (পুনরায়) সমস্ত অ্যাসেট প্রক্রিয়া করে। \"রিসেট\" করার মাধ্যমে অতিরিক্তভাবে সমস্ত বর্তমান মুখের ডেটা সাফ করে। \"অনুপস্থিত\" অ্যাসেটগুলিকে সারিবদ্ধ করে যা এখনও প্রক্রিয়া করা হয়নি। সনাক্ত করা মুখগুলিকে ফেসিয়াল রিকগনিশনের জন্য সারিবদ্ধ করা হবে, ফেসিয়াল ডিটেকশন সম্পূর্ণ হওয়ার পরে, বিদ্যমান বা নতুন ব্যক্তিদের মধ্যে গোষ্ঠীবদ্ধ করে।",
"facial_recognition_job_description": "শনাক্ত করা মুখগুলিকে মানুষের মধ্যে গোষ্ঠীভুক্ত/গ্রুপ করুন। মুখ সনাক্তকরণ সম্পূর্ণ হওয়ার পরে এই ধাপটি চলে। \"রিসেট\" (পুনরায়) সমস্ত মুখকে ক্লাস্টার করে। \"অনুপস্থিত/মিসিং\" মুখগুলিকে সারিতে রাখে যেগুলো কোনও ব্যক্তিকে এসাইন/বরাদ্দ করা হয়নি।",
@ -106,12 +104,10 @@
"job_not_concurrency_safe": "এই কাজটি সমান্তরালভাবে চালানো নিরাপদ নয়",
"job_settings": "কাজের সেটিংস",
"job_settings_description": "কাজের সমান্তরালতা পরিচালনা করুন",
"job_status": "চাকরির অবস্থা",
"jobs_delayed": "{jobCount, plural, other {# বিলম্বিত}}",
"jobs_failed": "{jobCount, plural, other {# ব্যর্থ}}",
"library_created": "লাইব্রেরি তৈরি করা হয়েছেঃ {library}",
"library_deleted": "লাইব্রেরি মুছে ফেলা হয়েছে",
"library_import_path_description": "ইম্পোর্ট/যোগ করার জন্য একটি ফোল্ডার নির্দিষ্ট করুন। সাবফোল্ডার সহ এই ফোল্ডারটি ছবি এবং ভিডিওর জন্য স্ক্যান করা হবে।",
"library_scanning": "পর্যায়ক্রমিক স্ক্যানিং",
"library_scanning_description": "পর্যায়ক্রমিক লাইব্রেরি স্ক্যানিং কনফিগার করুন",
"library_scanning_enable_description": "পর্যায়ক্রমিক লাইব্রেরি স্ক্যানিং সক্ষম করুন",

1
i18n/br.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -7,6 +7,7 @@
"action_common_update": "Actualitzar",
"actions": "Accions",
"active": "Actiu",
"active_count": "Activat: {count}",
"activity": "Activitat",
"activity_changed": "L'activitat està {enabled, select, true {activada} other {desactivada}}",
"add": "Afegir",
@ -17,7 +18,6 @@
"add_birthday": "Afegeix la data de naixement",
"add_endpoint": "afegir endpoint",
"add_exclusion_pattern": "Afegir un patró d'exclusió",
"add_import_path": "Afegir una ruta d'importació",
"add_location": "Afegir la ubicació",
"add_more_users": "Afegir més usuaris",
"add_partner": "Afegir company/a",
@ -32,6 +32,7 @@
"add_to_album_toggle": "Commutar selecció de {album}",
"add_to_albums": "Afegir als àlbums",
"add_to_albums_count": "Afegir als àlbums ({count})",
"add_to_bottom_bar": "Afegir a",
"add_to_shared_album": "Afegir a un àlbum compartit",
"add_upload_to_stack": "Afegeix la càrrega a la pila",
"add_url": "Afegir URL",
@ -67,6 +68,7 @@
"confirm_reprocess_all_faces": "Esteu segurs que voleu reprocessar totes les cares? Això també esborrarà la gent que heu anomenat.",
"confirm_user_password_reset": "Esteu segur que voleu reinicialitzar la contrasenya de l'usuari {user}?",
"confirm_user_pin_code_reset": "Esteu segur que voleu restablir el codi PIN de {user}?",
"copy_config_to_clipboard_description": "Copia la configuració actual del sistema com a objecte JSON al porta-retalls",
"create_job": "Crear tasca",
"cron_expression": "Expressió Cron",
"cron_expression_description": "Estableix l'interval d'escaneig amb el format cron. Per obtenir més informació, consulteu, p.e <link>Crontab Guru</link>",
@ -74,7 +76,8 @@
"disable_login": "Deshabiliteu l'inici de sessió",
"duplicate_detection_job_description": "Executa l'aprenentatge automàtic en els elements per a detectar imatges semblants. Fa servir la cerca intel·ligent",
"exclusion_pattern_description": "Els patrons d'exclusió permeten ignorar fitxers i carpetes quan escanegeu una llibreria. Això és útil si teniu carpetes que contenen fitxer que no voleu importar, com els fitxers RAW.",
"external_library_management": "Gestió de llibreries externes",
"export_config_as_json_description": "Baixa la configuració actual del sistema com a fitxer JSON",
"external_libraries_page_description": "Pàgina de la biblioteca externa de l'administrador",
"face_detection": "Detecció de cares",
"face_detection_description": "Detecta les cares fent servir aprenentatge automàtic. Per a videos només és té en compte la miniatura. \"Actualitzar\" reprocessa tots els elements. \"Resetejar\" esborra tota la informació de cares actuals. \"Pendent\" afegeix a la cua els elements que encara no han estat processats. Les cares detectades s'afegiran a la cua per al Reconeixement Facial després de completar la Detecció Facial, tot agrupant-les entre noves persones o les ja existents.",
"facial_recognition_job_description": "Agrupa les cares detectades per persona. Aquest pas s'executa després de completar la detecció de cares. \"Resetejar\" reagrupa totes les cares. \"Pendent\" afegeix a la cua les cares que no tenen cap persona assignada.",
@ -102,23 +105,28 @@
"image_thumbnail_description": "Miniatura petita amb metadades eliminades, que s'utilitza quan es visualitzen grups de fotos com la línia de temps principal",
"image_thumbnail_quality_description": "Qualitat de miniatura d'1 a 100. Més alt és millor, però produeix fitxers més grans i pot reduir la capacitat de resposta de l'aplicació.",
"image_thumbnail_title": "Configuració de les miniatures",
"import_config_from_json_description": "Importa la configuració del sistema pujant un fitxer de configuració JSON",
"job_concurrency": "{job} simultàniament",
"job_created": "Tasca creada",
"job_not_concurrency_safe": "Aquesta tasca no és segura per a la conconcurrència.",
"job_settings": "Configuració de les tasques",
"job_settings_description": "Gestiona la concurrència de tasques",
"job_status": "Estat de les tasques",
"jobs_delayed": "{jobCount, plural, other {# posposades}}",
"jobs_failed": "{jobCount, plural, other {# fallides}}",
"jobs_over_time": "Feines al llarg del temps",
"library_created": "Bilbioteca creada: {library}",
"library_deleted": "Bilbioteca eliminada",
"library_import_path_description": "Especifiqueu una carpeta a importar. Aquesta carpeta, incloses les seves subcarpetes, serà escanejada per cercar-hi imatges i vídeos.",
"library_details": "Detalls de la llibreria",
"library_folder_description": "Especifiqueu una carpeta per importar. Aquesta carpeta, incloses les subcarpetes, s'escanejarà a la recerca d'imatges i vídeos.",
"library_remove_exclusion_pattern_prompt": "Esteu segur que voleu eliminar aquest patró d'exclusió?",
"library_remove_folder_prompt": "Esteu segur que voleu eliminar aquesta carpeta d'importació?",
"library_scanning": "Escaneig periòdic",
"library_scanning_description": "Configurar l'escaneig periòdic de bilbioteques",
"library_scanning_enable_description": "Habilita l'escaneig periòdic de biblioteques",
"library_settings": "Llibreria externes",
"library_settings_description": "Gestiona la configuració de les llibreries externes",
"library_tasks_description": "Escaneja les biblioteques externes per trobar arxius nous o canviats",
"library_updated": "Llibreria actualitzada",
"library_watching_enable_description": "Consultar llibreries externes per detectar canvis en fitxers",
"library_watching_settings": "Monitoratge de la llibreria (EXPERIMENTAL)",
"library_watching_settings_description": "Monitorització automàtica de fitxers modificats",
@ -154,6 +162,18 @@
"machine_learning_min_detection_score_description": "La puntuació mínima de confiança per detectar una cara és de 0 a 1. Valors més baixos detectaran més cares, però poden donar lloc a falsos positius.",
"machine_learning_min_recognized_faces": "Nombre mínim de cares reconegudes",
"machine_learning_min_recognized_faces_description": "El nombre mínim de cares reconegudes per crear una persona. Augmentar aquest valor fa que el reconeixement facial sigui més precís, però augmenta la possibilitat que una cara no sigui assignada a una persona.",
"machine_learning_ocr": "OCR",
"machine_learning_ocr_description": "Fes servir machine learning per reconèixer text a imatges",
"machine_learning_ocr_enabled": "Activar OCR",
"machine_learning_ocr_enabled_description": "Si està desactivat, les imatges no seran objecte de reconeixement de text.",
"machine_learning_ocr_max_resolution": "Màxima resolució",
"machine_learning_ocr_max_resolution_description": "Vista prèvia per sobre d'aquesta resolució serà reescalada per preservar la relació d'aspecte. Resolucions altes són més precises, però triguen més i gasten més memòria.",
"machine_learning_ocr_min_detection_score": "Puntuació mínima de detecció",
"machine_learning_ocr_min_detection_score_description": "Puntuació de mínima confiança per la detecció del text entre 0-1. Valors baixos detectaran més text pero pot donar falsos positius.",
"machine_learning_ocr_min_recognition_score": "Puntuació mínima de reconeixement",
"machine_learning_ocr_min_score_recognition_description": "Puntuació de confiança mínima pel reconeixement del text entre 0-1. Valors baixos reconeixen més text però pot donar falsos positius.",
"machine_learning_ocr_model": "Model OCR",
"machine_learning_ocr_model_description": "Models de servidor són més precisos que els de móbil, pero triguen més a processar i usen més memòria.",
"machine_learning_settings": "Configuració d'aprenentatge automàtic",
"machine_learning_settings_description": "Gestiona funcions i configuració d'aprenentatge automàtic",
"machine_learning_smart_search": "Cerca intel·ligent",
@ -161,7 +181,12 @@
"machine_learning_smart_search_enabled": "Activa la cerca intel·ligent",
"machine_learning_smart_search_enabled_description": "Si està desactivada, les imatges no es codificaran per la cerca intel·ligent.",
"machine_learning_url_description": "L'URL del servidor d'aprenentatge automàtic. Si es proporciona més d'un URL, s'intentarà accedir a cada servidor en ordre fins que un d'ells respongui correctament.",
"maintenance_settings": "En manteniment",
"maintenance_settings_description": "Posar Immich en mode de manteniment.",
"maintenance_start": "Iniciar el mode de manteniment",
"maintenance_start_error": "Error en iniciar el mode de manteniment.",
"manage_concurrency": "Gestiona la concurrència",
"manage_concurrency_description": "Ves a la pàgina de tasques per gestionar la concurrència de tasques",
"manage_log_settings": "Gestiona la configuració del registre",
"map_dark_style": "Tema fosc",
"map_enable_description": "Habilita característiques del mapa",
@ -211,6 +236,8 @@
"notification_email_ignore_certificate_errors_description": "Ignora els errors de validació de certificat TLS (no recomanat)",
"notification_email_password_description": "Contrasenya per a autenticar-se amb el servidor de correu electrònic",
"notification_email_port_description": "Port del servidor de correu electrònic (p.ex. 25, 465 o 587)",
"notification_email_secure": "SMTPS",
"notification_email_secure_description": "Fes servir SMTPS (SMTP sobre TLS)",
"notification_email_sent_test_email_button": "Envia correu de prova i desa",
"notification_email_setting_description": "Configuració per l'enviament de notificacions per correu electrònic",
"notification_email_test_email": "Envia correu de prova",
@ -243,15 +270,20 @@
"oauth_storage_quota_default_description": "Quota disponible en GB quan no s'estableixi cap valor (Entreu 0 per a quota il·limitada).",
"oauth_timeout": "Solicitud caducada",
"oauth_timeout_description": "Timeout per a sol·licituds en mil·lisegons",
"ocr_job_description": "Fes servir machine learning per reconèixer text a les imatges",
"password_enable_description": "Inicia sessió amb correu electrònic i contrasenya",
"password_settings": "Inici de sessió amb contrasenya",
"password_settings_description": "Gestiona la configuració de l'inici de sessió amb contrasenya",
"paths_validated_successfully": "Totes les rutes han estat validades amb èxit",
"person_cleanup_job": "Neteja de persona",
"queue_details": "Detall de les cues",
"queues": "Cues de treball",
"queues_page_description": "Pàgina de cues de treballs d'administrador",
"quota_size_gib": "Tamany de la quota (GiB)",
"refreshing_all_libraries": "Actualitzant totes les biblioteques",
"registration": "Registre d'administrador",
"registration_description": "Com que ets el primer usuari del sistema, seràs designat com a administrador i seràs responsable de les tasques administratives. També seràs l'encarregat de crear usuaris addicionals.",
"remove_failed_jobs": "Eliminar treballs fallits",
"require_password_change_on_login": "Requerir que l'usuari canviï la contrasenya en el primer inici de sessió",
"reset_settings_to_default": "Restablir configuracions per defecte",
"reset_settings_to_recent_saved": "Restablir la configuració guardada més recent",
@ -264,8 +296,10 @@
"server_public_users_description": "Tots els usuaris (nom i correu electrònic) apareixen a la llista a l'afegir un usuari als àlbums compartits. Si es desactiva, la llista només serà disponible pels usuaris administradors.",
"server_settings": "Configuració del servidor",
"server_settings_description": "Gestiona la configuració del servidor",
"server_stats_page_description": "Pàgina d'estadístiques del servidor de l'administrador",
"server_welcome_message": "Missatge de benvinguda",
"server_welcome_message_description": "Missatge que es mostra a la pàgina d'inici de sessió.",
"settings_page_description": "Pàgina de configuració de l'administrador",
"sidecar_job": "Metadades auxiliars",
"sidecar_job_description": "Descobreix o sincronitza metadades auxiliars des del sistema de fitxers",
"slideshow_duration_description": "Segons per mostrar cada imatge",
@ -385,6 +419,7 @@
"user_settings": "Configuració d'usuaris",
"user_settings_description": "Gestiona la configuració dels usuaris",
"user_successfully_removed": "L'usuari {email} s'ha eliminat correctament.",
"users_page_description": "Pàgina d'usuaris de l'administrador",
"version_check_enabled_description": "Activa la comprovació de la versió",
"version_check_implications": "La funció de comprovació de versions depèn de comunicacions periòdiques amb github.com",
"version_check_settings": "Comprovació de versió",
@ -402,11 +437,11 @@
"advanced_settings_prefer_remote_subtitle": "Alguns dispositius són molt lents en carregar miniatures dels elements locals. Activeu aquest paràmetre per carregar imatges remotes en el seu lloc.",
"advanced_settings_prefer_remote_title": "Prefereix imatges remotes",
"advanced_settings_proxy_headers_subtitle": "Definiu les capçaleres de proxy que Immich per enviar amb cada sol·licitud de xarxa",
"advanced_settings_proxy_headers_title": "Capçaleres de proxy",
"advanced_settings_proxy_headers_title": "Capçaleres de proxy particulars [EXPERIMENTAL]",
"advanced_settings_readonly_mode_subtitle": "Habilita el només de lectura mode on les fotos poden ser només vist, a coses els agrada seleccionant imatges múltiples, compartint, càsting, elimina és tot discapacitat. Habilita/Desactiva només de lectura via avatar d'usuari des de la pantalla major",
"advanced_settings_readonly_mode_title": "Mode de només lectura",
"advanced_settings_self_signed_ssl_subtitle": "Omet la verificació del certificat SSL del servidor. Requerit per a certificats autosignats.",
"advanced_settings_self_signed_ssl_title": "Permet certificats SSL autosignats",
"advanced_settings_self_signed_ssl_title": "Permet certificats SSL autosignats [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_subtitle": "Suprimeix o restaura automàticament un actiu en aquest dispositiu quan es realitzi aquesta acció al web",
"advanced_settings_sync_remote_deletions_title": "Sincronitza les eliminacions remotes",
"advanced_settings_tile_subtitle": "Configuració avançada de l'usuari",
@ -415,6 +450,7 @@
"age_months": "{months, plural, one {# mes} other {# mesos}}",
"age_year_months": "Un any i {months, plural, one {# mes} other {# mesos}}",
"age_years": "{years, plural, one {# any} other {# anys}}",
"album": "Àlbum",
"album_added": "Àlbum afegit",
"album_added_notification_setting_description": "Rep una notificació per correu quan siguis afegit a un àlbum compartit",
"album_cover_updated": "Portada de l'àlbum actualitzada",
@ -460,16 +496,21 @@
"allow_edits": "Permet editar",
"allow_public_user_to_download": "Permet que l'usuari públic pugui descarregar",
"allow_public_user_to_upload": "Permet que l'usuari públic pugui carregar",
"allowed": "Permès",
"alt_text_qr_code": "Codi QR",
"anti_clockwise": "En sentit antihorari",
"api_key": "Clau API",
"api_key_description": "Aquest valor només es mostrarà una vegada. Assegureu-vos de copiar-lo abans de tancar la finestra.",
"api_key_empty": "El nom de la clau de l'API no pot estar buit",
"api_keys": "Claus API",
"app_architecture_variant": "Variant (Arquitectura)",
"app_bar_signout_dialog_content": "Estàs segur que vols tancar la sessió?",
"app_bar_signout_dialog_ok": "Sí",
"app_bar_signout_dialog_title": "Tanca la sessió",
"app_download_links": "App descarrega enllaços",
"app_settings": "Configuració de l'app",
"app_stores": "Botiga App",
"app_update_available": "Actualització App disponible",
"appears_in": "Apareix a",
"apply_count": "Aplicar ({count, number})",
"archive": "Arxiu",
@ -553,6 +594,7 @@
"backup_albums_sync": "Sincronització d'àlbums de còpia de seguretat",
"backup_all": "Tots",
"backup_background_service_backup_failed_message": "No s'ha pogut copiar els elements. Tornant a intentar…",
"backup_background_service_complete_notification": "Backup completat d'actius",
"backup_background_service_connection_failed_message": "No s'ha pogut connectar al servidor. Tornant a intentar…",
"backup_background_service_current_upload_notification": "Pujant {filename}",
"backup_background_service_default_notification": "Cercant nous elements…",
@ -610,6 +652,7 @@
"backup_options_page_title": "Opcions de còpia de seguretat",
"backup_setting_subtitle": "Gestiona la configuració de càrrega en segon pla i en primer pla",
"backup_settings_subtitle": "Administra la configuració de pujada",
"backup_upload_details_page_more_details": "Toqueu per obtenir més detalls",
"backward": "Enrere",
"biometric_auth_enabled": "Autentificació biomètrica activada",
"biometric_locked_out": "Esteu bloquejats fora de l'autenticació biomètrica",
@ -662,6 +705,8 @@
"change_password_description": "Aquesta és la primera vegada que inicieu la sessió al sistema o s'ha fet una sol·licitud per canviar la contrasenya. Introduïu la nova contrasenya a continuació.",
"change_password_form_confirm_password": "Confirma la contrasenya",
"change_password_form_description": "Hola {name},\n\nAquesta és la primera vegada que inicies sessió al sistema o bé s'ha sol·licitat canviar la teva contrasenya. Si us plau, introdueix la nova contrasenya a continuació.",
"change_password_form_log_out": "Fer fora de tots els altres dispositius",
"change_password_form_log_out_description": "Es recomana fer fora de tots els altres dispositius",
"change_password_form_new_password": "Nova contrasenya",
"change_password_form_password_mismatch": "Les contrasenyes no coincideixen",
"change_password_form_reenter_new_password": "Torna a introduir la nova contrasenya",
@ -674,6 +719,7 @@
"check_corrupt_asset_backup_button": "Realitzar comprovació",
"check_corrupt_asset_backup_description": "Executeu aquesta comprovació només mitjançant Wi-Fi i un cop s'hagi fet una còpia de seguretat de tots els actius. El procediment pot trigar uns minuts.",
"check_logs": "Comprovar els registres",
"checksum": "Suma de control",
"choose_matching_people_to_merge": "Trieu les persones que coincideixin per combinar-les",
"city": "Ciutat",
"clear": "Buida",
@ -696,6 +742,7 @@
"collapse_all": "Redueix-ho tot",
"color": "Color",
"color_theme": "Tema de color",
"command": "Ordre",
"comment_deleted": "Comentari esborrat",
"comment_options": "Opcions de comentari",
"comments_and_likes": "Comentaris i agradaments",
@ -739,6 +786,7 @@
"create": "Crea",
"create_album": "Crear un àlbum",
"create_album_page_untitled": "Sense títol",
"create_api_key": "Crear clau API",
"create_library": "Crea una llibreria",
"create_link": "Crear enllaç",
"create_link_to_share": "Crear enllaç per compartir",
@ -768,6 +816,7 @@
"daily_title_text_date_year": "E, dd MMM, yyyy",
"dark": "Fosc",
"dark_theme": "Canviar a tema fosc",
"date": "Data",
"date_after": "Data posterior a",
"date_and_time": "Data i hora",
"date_before": "Data anterior a",
@ -870,8 +919,6 @@
"edit_description_prompt": "Si us plau, selecciona una nova descripció:",
"edit_exclusion_pattern": "Edita patró d'exclusió",
"edit_faces": "Edita les cares",
"edit_import_path": "Edita la ruta d'importació",
"edit_import_paths": "Edita les rutes d'importació",
"edit_key": "Edita clau",
"edit_link": "Edita enllaç",
"edit_location": "Edita ubicació",
@ -943,8 +990,8 @@
"failed_to_stack_assets": "No s'han pogut apilar els elements",
"failed_to_unstack_assets": "No s'han pogut desapilar els elements",
"failed_to_update_notification_status": "Error en actualitzar l'estat de les notificacions",
"import_path_already_exists": "Aquesta ruta d'importació ja existeix.",
"incorrect_email_or_password": "Correu electrònic o contrasenya incorrectes",
"library_folder_already_exists": "Aquesta ruta d'importació ja existeix.",
"paths_validation_failed": "{paths, plural, one {# ruta} other {# rutes}} no ha pogut validar",
"profile_picture_transparent_pixels": "Les fotos de perfil no poden tenir píxels transparents. Per favor, feu zoom in, mogueu la imatge o ambdues.",
"quota_higher_than_disk_size": "Heu establert una quota més gran que la mida de disc",
@ -953,7 +1000,6 @@
"unable_to_add_assets_to_shared_link": "No s'han pogut afegir els elements a l'enllaç compartit",
"unable_to_add_comment": "No es pot afegir el comentari",
"unable_to_add_exclusion_pattern": "No s'ha pogut afegir el patró dexclusió",
"unable_to_add_import_path": "No s'ha pogut afegir la ruta d'importació",
"unable_to_add_partners": "No es poden 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",
@ -976,12 +1022,10 @@
"unable_to_delete_asset": "No es pot suprimir el recurs",
"unable_to_delete_assets": "S'ha produït un error en suprimir recursos",
"unable_to_delete_exclusion_pattern": "No es pot suprimir el patró d'exclusió",
"unable_to_delete_import_path": "No es pot suprimir la ruta d'importació",
"unable_to_delete_shared_link": "No es pot suprimir l'enllaç compartit",
"unable_to_delete_user": "No es pot eliminar l'usuari",
"unable_to_download_files": "No es poden descarregar fitxers",
"unable_to_edit_exclusion_pattern": "No es pot editar el patró d'exclusió",
"unable_to_edit_import_path": "No es pot editar la ruta d'importació",
"unable_to_empty_trash": "No es pot buidar la paperera",
"unable_to_enter_fullscreen": "No es pot entrar a la pantalla completa",
"unable_to_exit_fullscreen": "No es pot sortir de la pantalla completa",
@ -1032,6 +1076,7 @@
"unable_to_update_user": "No es pot actualitzar l'usuari",
"unable_to_upload_file": "No es pot carregar el fitxer"
},
"exclusion_pattern": "Patró d'exclusió",
"exif": "EXIF",
"exif_bottom_sheet_description": "Afegeix descripció...",
"exif_bottom_sheet_description_error": "No s'ha pogut actualitzar la descripció",
@ -1062,6 +1107,7 @@
"external_network_sheet_info": "Quan no estigui a la xarxa Wi-Fi preferida, l'aplicació es connectarà al servidor mitjançant el primer dels URL següents a què pot arribar, començant de dalt a baix",
"face_unassigned": "Sense assignar",
"failed": "Fallat",
"failed_count": "Fallits: {count}",
"failed_to_authenticate": "No s'ha pogut autenticar",
"failed_to_load_assets": "Error carregant recursos",
"failed_to_load_folder": "No s'ha pogut carregar la carpeta",
@ -1076,6 +1122,7 @@
"features_setting_description": "Administrar les funcions de l'aplicació",
"file_name": "Nom de l'arxiu",
"file_name_or_extension": "Nom de l'arxiu o extensió",
"file_size": "Mida del fitxer",
"filename": "Nom del fitxer",
"filetype": "Tipus d'arxiu",
"filter": "Filtrar",
@ -1090,6 +1137,7 @@
"folders_feature_description": "Explorar la vista de carpetes per les fotos i vídeos del sistema d'arxius",
"forgot_pin_code_question": "Has oblidat el teu PIN?",
"forward": "Endavant",
"full_path": "Ruta completa: {path}",
"gcast_enabled": "Google Cast",
"gcast_enabled_description": "Aquesta funció carrega recursos externs de Google per funcionar.",
"general": "General",
@ -1120,12 +1168,14 @@
"header_settings_header_name_input": "Nom de la capçalera",
"header_settings_header_value_input": "Valor de la capçalera",
"headers_settings_tile_title": "Capçaleres proxy personalitzades",
"height": "Alçada",
"hi_user": "Hola {name} ({email})",
"hide_all_people": "Amaga totes les persones",
"hide_gallery": "Amaga la galeria",
"hide_named_person": "Amaga la persona {name}",
"hide_password": "Amaga la contrasenya",
"hide_person": "Amaga la persona",
"hide_text_recognition": "Oculta el reconeixement de text",
"hide_unnamed_people": "Amaga persones sense nom",
"home_page_add_to_album_conflicts": "S'han afegit {added} elements a l'àlbum {album}. {failed} elements ja existeixen a l'àlbum.",
"home_page_add_to_album_err_local": "Encara no es poden afegir elements locals als àlbums, ometent",
@ -1171,6 +1221,8 @@
"import_path": "Ruta d'importació",
"in_albums": "A {count, plural, one {# àlbum} other {# àlbums}}",
"in_archive": "En arxiu",
"in_year": "En {year}",
"in_year_selector": "En",
"include_archived": "Incloure arxivats",
"include_shared_albums": "Inclou àlbums compartits",
"include_shared_partner_assets": "Incloure elements dels companys",
@ -1207,6 +1259,7 @@
"language_setting_description": "Seleccioneu el vostre idioma",
"large_files": "Fitxers Grans",
"last": "Últim",
"last_months": "{count, plural, one {Últim mes} other {Últims # mesos}}",
"last_seen": "Vist per últim cop",
"latest_version": "Última versió",
"latitude": "Latitud",
@ -1216,6 +1269,8 @@
"let_others_respond": "Deixa que els altres responguin",
"level": "Nivell",
"library": "Bibilioteca",
"library_add_folder": "Afegir carpeta",
"library_edit_folder": "Editar carpeta",
"library_options": "Opcions de biblioteca",
"library_page_device_albums": "Àlbums al Dispositiu",
"library_page_new_album": "Nou àlbum",
@ -1236,9 +1291,11 @@
"local": "Local",
"local_asset_cast_failed": "No es pot convertir un actiu que no s'ha penjat al servidor",
"local_assets": "Recursos Locals",
"local_id": "ID local",
"local_media_summary": "Resum de Mitjans Locals",
"local_network": "Xarxa local",
"local_network_sheet_info": "L'aplicació es connectarà al servidor mitjançant aquest URL quan utilitzeu la xarxa Wi-Fi especificada",
"location": "Localització",
"location_permission": "Permís d'ubicació",
"location_permission_content": "Per utilitzar la funció de canvi automàtic, Immich necessita un permís d'ubicació precisa perquè pugui llegir el nom de la xarxa Wi-Fi actual",
"location_picker_choose_on_map": "Escollir en el mapa",
@ -1286,8 +1343,17 @@
"loop_videos_description": "Habilita la reproducció en bucle del vídeo en els detalls.",
"main_branch_warning": "Esteu utilitzant una versió en desenvolupament; Recomanem fer servir una versió publicada!",
"main_menu": "Menú principal",
"maintenance_description": "Immich ha estat posat en <link>mode de manteniment</link>.",
"maintenance_end": "Finalitzar el mode de manteniment",
"maintenance_end_error": "Error al finalitzar el mode de manteniment.",
"maintenance_logged_in_as": "Actualment la sessió esta iniciada per {user}",
"maintenance_title": "Temporalment inaccessible",
"make": "Fabricant",
"manage_geolocation": "Gestioneu la vostra ubicació",
"manage_media_access_rationale": "Aquest permís es necessari per a la correcta gestió dels actius que es mouen a la paperera i es restauren d'ella.",
"manage_media_access_settings": "Configuració oberta",
"manage_media_access_subtitle": "Permet a l'Immich gestionar i moure fitxers multimèdia.",
"manage_media_access_title": "Accés a la gestió de mitjans",
"manage_shared_links": "Administrar enllaços compartits",
"manage_sharing_with_partners": "Gestiona la compartició amb els companys",
"manage_the_app_settings": "Gestioneu la configuració de l'aplicació",
@ -1344,12 +1410,14 @@
"minutes": "Minuts",
"missing": "Restants",
"mobile_app": "Aplicació mòbil",
"mobile_app_download_onboarding_note": "Descarregar la App de mòbil fent servir les seguents opcions",
"model": "Model",
"month": "Mes",
"monthly_title_text_date_format": "MMMM y",
"more": "Més",
"move": "Moure",
"move_off_locked_folder": "Moure fora de la carpeta bloquejada",
"move_to": "Moure a",
"move_to_lock_folder_action_prompt": "{count} afegides a la carpeta protegida",
"move_to_locked_folder": "Moure a la carpeta bloquejada",
"move_to_locked_folder_confirmation": "Aquestes fotos i vídeos seran eliminades de tots els àlbums, i només podran ser vistes des de la carpeta bloquejada",
@ -1363,6 +1431,7 @@
"name": "Nom",
"name_or_nickname": "Nom o sobrenom",
"navigate": "Navegar",
"navigate_to_time": "Navegar a un punt en el temps",
"network_requirement_photos_upload": "Fes servir dades mòbils per a còpies de seguretat de fotos",
"network_requirement_videos_upload": "Fes servir dades mòbils per a còpies de seguretat de videos",
"network_requirements": "Requeriments de Xarxa",
@ -1372,11 +1441,13 @@
"never": "Mai",
"new_album": "Nou Àlbum",
"new_api_key": "Nova clau de l'API",
"new_date_range": "Navegar a un reng de dates",
"new_password": "Nova contrasenya",
"new_person": "Persona nova",
"new_pin_code": "Nou codi PIN",
"new_pin_code_subtitle": "Aquesta és la primera vegada que accedeixes a la carpeta bloquejada. Crea una codi PIN i accedeix de manera segura a aquesta pàgina",
"new_timeline": "Nova Línia de Temps",
"new_update": "Nova actualització",
"new_user_created": "Nou usuari creat",
"new_version_available": "NOVA VERSIÓ DISPONIBLE",
"newest_first": "El més nou primer",
@ -1392,12 +1463,14 @@
"no_cast_devices_found": "No s'han trobat dispositius per transmetre",
"no_checksum_local": "Cap checksum disponible - no s'han pogut carregar els recursos locals",
"no_checksum_remote": "Cap checksum disponible - no s'ha pogut obtenir el recurs remot",
"no_devices": "No hi ha dispositius autoritzats",
"no_duplicates_found": "No s'han trobat duplicats.",
"no_exif_info_available": "No hi ha informació d'exif disponible",
"no_explore_results_message": "Penja més fotos per explorar la teva col·lecció.",
"no_favorites_message": "Afegiu preferits per trobar les millors fotos i vídeos a l'instant",
"no_libraries_message": "Creeu una llibreria externa per veure les vostres fotos i vídeos",
"no_local_assets_found": "No s'ha trobat cap recurs local amb aquest checksum",
"no_location_set": "No s'ha definit cap ubicació",
"no_locked_photos_message": "Les fotos i vídeos d'aquesta carpeta estan ocultes, i no es mostraran a mesura que navegues o cerques a la teva biblioteca.",
"no_name": "Sense nom",
"no_notifications": "No hi ha notificacions",
@ -1408,6 +1481,7 @@
"no_results_description": "Proveu un sinònim o una paraula clau més general",
"no_shared_albums_message": "Creeu un àlbum per compartir fotos i vídeos amb persones a la vostra xarxa",
"no_uploads_in_progress": "Cap pujada en progrés",
"not_allowed": "No permès",
"not_available": "N/A",
"not_in_any_album": "En cap àlbum",
"not_selected": "No seleccionat",
@ -1422,6 +1496,9 @@
"notifications": "Notificacions",
"notifications_setting_description": "Gestiona les notificacions",
"oauth": "OAuth",
"obtainium_configurator": "Configurador Obtainium",
"obtainium_configurator_instructions": "Utilitza Obtainium per instal·lar una actualització a la app directament des de Github-Immich. Crear una clau API i seleccionar una variant per crear un enllaç a la configuració Obtainium",
"ocr": "OCR",
"official_immich_resources": "Recursos oficials d'Immich",
"offline": "Fora de línia",
"offset": "Diferència",
@ -1453,6 +1530,7 @@
"other_variables": "Altres variables",
"owned": "Propi",
"owner": "Propietari",
"page": "Pàgina",
"partner": "Company/a",
"partner_can_access": "{partner} hi té accés",
"partner_can_access_assets": "Totes les vostres fotos i vídeos excepte les arxivades i eliminades",
@ -1515,6 +1593,8 @@
"photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}",
"photos_from_previous_years": "Fotos d'anys anteriors",
"pick_a_location": "Triar una ubicació",
"pick_custom_range": "Rang personalitzat",
"pick_date_range": "Seleccioni un rang de dates",
"pin_code_changed_successfully": "Codi PIN canviat correctament",
"pin_code_reset_successfully": "S'ha restablert correctament el codi PIN",
"pin_code_setup_successfully": "S'ha configurat correctament un codi PIN",
@ -1526,6 +1606,9 @@
"play_memories": "Reproduir records",
"play_motion_photo": "Reproduir Fotos en Moviment",
"play_or_pause_video": "Reproduir o posar en pausa el vídeo",
"play_original_video": "Veure el video original",
"play_original_video_setting_description": "Preferir la reproducció del video original sobre el video recodificat. Si el video original no es compatible potser no es reprodueixi correctament.",
"play_transcoded_video": "Veure el video recodificat",
"please_auth_to_access": "Per favor, autentica't per accedir",
"port": "Port",
"preferences_settings_subtitle": "Gestiona les preferències de l'aplicació",
@ -1662,6 +1745,7 @@
"reset_sqlite_confirmation": "Segur que vols reiniciar la base de dades SQLite? Hauràs de tancar la sessió i tornar a accedir per a resincronitzar les dades",
"reset_sqlite_success": "S'ha reiniciat la base de dades correctament",
"reset_to_default": "Restableix els valors predeterminats",
"resolution": "Resolució",
"resolve_duplicates": "Resoldre duplicats",
"resolved_all_duplicates": "Tots els duplicats resolts",
"restore": "Recupera",
@ -1680,6 +1764,7 @@
"running": "En execució",
"save": "Desa",
"save_to_gallery": "Desa a galeria",
"saved": "Guardat",
"saved_api_key": "Clau d'API guardada",
"saved_profile": "Perfil guardat",
"saved_settings": "Configuració guardada",
@ -1696,6 +1781,9 @@
"search_by_description_example": "Jornada de senderisme a Sapa",
"search_by_filename": "Cerca per nom de fitxer o extensió",
"search_by_filename_example": "per exemple IMG_1234.JPG o PNG",
"search_by_ocr": "Buscar per OCR",
"search_by_ocr_example": "Després",
"search_camera_lens_model": "Buscar model de lents....",
"search_camera_make": "Buscar per fabricant de càmara...",
"search_camera_model": "Buscar per model de càmera...",
"search_city": "Buscar per ciutat...",
@ -1712,6 +1800,7 @@
"search_filter_location_title": "Selecciona l'ubicació",
"search_filter_media_type": "Tipus de multimèdia",
"search_filter_media_type_title": "Selecciona tipus de multimèdia",
"search_filter_ocr": "Buscar per OCR",
"search_filter_people_title": "Selecciona persones",
"search_for": "Cercar",
"search_for_existing_person": "Busca una persona existent",
@ -1773,6 +1862,8 @@
"server_offline": "Servidor fora de línia",
"server_online": "Servidor en línia",
"server_privacy": "Privadesa del servidor",
"server_restarting_description": "Aquesta pàgina es refrescarà momentàniament.",
"server_restarting_title": "El servidor s'està reiniciant",
"server_stats": "Estadístiques del servidor",
"server_update_available": "Actualització del servidor disponible",
"server_version": "Versió del servidor",
@ -1896,6 +1987,7 @@
"show_slideshow_transition": "Mostra la transició de la presentació de diapositives",
"show_supporter_badge": "Insígnia de contribuent",
"show_supporter_badge_description": "Mostra una insígnia de contributor",
"show_text_recognition": "Mostra el reconeixement de text",
"show_text_search_menu": "Mostra el menú de cerca amb text",
"shuffle": "Mescla",
"sidebar": "Barra lateral",
@ -1966,6 +2058,7 @@
"tags": "Etiquetes",
"tap_to_run_job": "Toca per executar el treball",
"template": "Plantilla",
"text_recognition": "Reconeixement de text",
"theme": "Tema",
"theme_selection": "Selecció de tema",
"theme_selection_description": "Activa automàticament el tema fosc o clar en funció de les preferències del sistema del navegador",
@ -1984,7 +2077,9 @@
"theme_setting_three_stage_loading_title": "Activa la càrrega en tres etapes",
"they_will_be_merged_together": "Es combinaran",
"third_party_resources": "Recursos de tercers",
"time": "Temps",
"time_based_memories": "Records basats en el temps",
"time_based_memories_duration": "Quants segons es mostrarà cada imatge.",
"timeline": "Cronologia",
"timezone": "Fus horari",
"to_archive": "Arxivar",
@ -1996,6 +2091,7 @@
"to_select": "per seleccionar",
"to_trash": "Paperera",
"toggle_settings": "Canvia configuració",
"toggle_theme_description": "Commuta el tema",
"total": "Total",
"total_usage": "Ús total",
"trash": "Paperera",
@ -2104,6 +2200,7 @@
"view_album": "Veure l'àlbum",
"view_all": "Veure tot",
"view_all_users": "Mostra tot els usuaris",
"view_asset_owners": "Veure els propietaris dels actius",
"view_details": "Veure Detalls",
"view_in_timeline": "Mostrar a la línia de temps",
"view_link": "Veure enllaç",
@ -2120,11 +2217,14 @@
"viewer_unstack": "Desapila",
"visibility_changed": "La visibilitat ha canviat per {count, plural, one {# persona} other {# persones}}",
"waiting": "Esperant",
"waiting_count": "Esperant: {count}",
"warning": "Avís",
"week": "Setmana",
"welcome": "Benvingut",
"welcome_to_immich": "Benvingut a immich",
"width": "Amplada",
"wifi_name": "Nom Wi-Fi",
"workflow": "Flux de treball",
"wrong_pin_code": "Codi PIN incorrecte",
"year": "Any",
"years_ago": "Fa {years, plural, one {# any} other {# anys}}",

View File

@ -7,6 +7,7 @@
"action_common_update": "Aktualizovat",
"actions": "Akce",
"active": "Aktivní",
"active_count": "Aktivní: {count}",
"activity": "Aktivita",
"activity_changed": "Aktivita je {enabled, select, true {povolena} other {zakázána}}",
"add": "Přidat",
@ -17,7 +18,6 @@
"add_birthday": "Přidat datum narození",
"add_endpoint": "Přidat koncový bod",
"add_exclusion_pattern": "Přidat vzor vyloučení",
"add_import_path": "Přidat cestu importu",
"add_location": "Přidat polohu",
"add_more_users": "Přidat další uživatele",
"add_partner": "Přidat partnera",
@ -32,6 +32,7 @@
"add_to_album_toggle": "Přepnout výběr pro {album}",
"add_to_albums": "Přidat do alb",
"add_to_albums_count": "Přidat do alb ({count})",
"add_to_bottom_bar": "Přidat do",
"add_to_shared_album": "Přidat do sdíleného alba",
"add_upload_to_stack": "Přidat nahrané do zásobníku",
"add_url": "Přidat URL",
@ -67,6 +68,7 @@
"confirm_reprocess_all_faces": "Opravdu chcete znovu zpracovat všechny obličeje? Tím se vymažou i pojmenované osoby.",
"confirm_user_password_reset": "Opravdu chcete obnovit heslo uživatele {user}?",
"confirm_user_pin_code_reset": "Opravdu chcete resetovat PIN kód uživatele {user}?",
"copy_config_to_clipboard_description": "Zkopírujte aktuální konfiguraci systému jako JSON objekt do schránky",
"create_job": "Vytvořit úlohu",
"cron_expression": "Výraz cron",
"cron_expression_description": "Nastavte interval prohledávání pomocí cron formátu. Další informace naleznete např. v <link>Crontab Guru</link>",
@ -74,7 +76,8 @@
"disable_login": "Zakázat přihlášení",
"duplicate_detection_job_description": "Spuštění strojového učení na položkách za účelem detekce podobných obrázků. Spoléhá na Chytré vyhledávání",
"exclusion_pattern_description": "Vzory vyloučení umožňují při prohledávání knihovny ignorovat soubory a složky. To je užitečné, pokud máte složky obsahující soubory, které nechcete importovat, například RAW soubory.",
"external_library_management": "Správa externích knihoven",
"export_config_as_json_description": "Stáhněte si aktuální konfiguraci systému jako JSON soubor",
"external_libraries_page_description": "Stránka externí knihovny správce",
"face_detection": "Detekce obličejů",
"face_detection_description": "Detekce obličejů v obrázcích pomocí strojového učení. U videí se bere v úvahu pouze miniatura. „Obnovit“ znovu zpracuje všechny položky. „Resetovat“ navíc vymaže všechna aktuální data obličejů. „Chybějící“ zařadí do fronty položky, které ještě nebyly zpracovány. Zjištěné obličeje budou po dokončení funkce Rozpoznávání obličejů zařazeny do fronty a seskupeny do stávajících nebo nových osob.",
"facial_recognition_job_description": "Seskupí nalezené obličeje do osob. Tento krok se spustí po dokončení detekce obličejů. „Resetovat“ znovu seskupí všechny obličeje. „Chybějící“ zpracuje obličeje, které nemají přiřazenou osobu.",
@ -102,23 +105,28 @@
"image_thumbnail_description": "Malá miniatura s odstraněnými metadaty, který se používá při prohlížení skupin fotografií, jako je hlavní časová osa",
"image_thumbnail_quality_description": "Kvalita miniatur od 1 do 100. Vyšší je lepší, ale vytváří větší soubory a může snížit odezvu aplikace.",
"image_thumbnail_title": "Miniatury",
"import_config_from_json_description": "Importujte konfiguraci systému nahráním konfiguračního JSON souboru",
"job_concurrency": "Souběžnost úlohy {job}",
"job_created": "Úloha vytvořena",
"job_not_concurrency_safe": "Tato úloha není bezpečená pro souběh.",
"job_settings": "Úlohy",
"job_settings_description": "Správa souběžnosti úloh",
"job_status": "Stav úloh",
"jobs_delayed": "{jobCount, plural, one {# zpožděný} few {# zpožděné} other {# zpožděných}}",
"jobs_failed": "{jobCount, plural, one {# neúspěšný} few {# neúspěšné} other {# neúspěšných}}",
"jobs_over_time": "Úlohy v průběhu času",
"library_created": "Vytvořena knihovna: {library}",
"library_deleted": "Knihovna smazána",
"library_import_path_description": "Zadejte složku, kterou chcete importovat. Tato složka bude prohledána včetně podsložek a budou v ní hledány obrázky a videa.",
"library_details": "Podrobnosti o knihovně",
"library_folder_description": "Zadejte složku, kterou chcete importovat. Tato složka, včetně podsložek, bude prohledána pro obrázky a videa.",
"library_remove_exclusion_pattern_prompt": "Opravdu chcete odstranit tento vzor vyloučení?",
"library_remove_folder_prompt": "Opravdu chcete odstranit tuto složku importu?",
"library_scanning": "Pravidelné prohledávání",
"library_scanning_description": "Nastavení pravidelného prohledávání knihovny",
"library_scanning_enable_description": "Povolit pravidelné prohledávání knihovny",
"library_settings": "Externí knihovna",
"library_settings_description": "Správa nastavení externí knihovny",
"library_tasks_description": "Vyhledávání nových nebo změněných položek v externích knihovnách",
"library_updated": "Knihovna aktualizována",
"library_watching_enable_description": "Sledovat změny souborů v externích knihovnách",
"library_watching_settings": "Sledování knihovny [EXPERIMENTÁLNÍ]",
"library_watching_settings_description": "Automatické sledování změněných souborů",
@ -173,7 +181,12 @@
"machine_learning_smart_search_enabled": "Povolit chytré vyhledávání",
"machine_learning_smart_search_enabled_description": "Pokud je vypnuto, obrázky nebudou kódovány pro inteligentní vyhledávání.",
"machine_learning_url_description": "URL serveru strojového učení. Pokud je zadáno více URL adres, budou jednotlivé servery zkoušeny postupně, dokud jeden z nich neodpoví úspěšně, a to v pořadí od prvního k poslednímu. Servery, které neodpoví, budou dočasně ignorovány, dokud nebudou opět online.",
"maintenance_settings": "Údržba",
"maintenance_settings_description": "Přepnout Immich do režimu údržby.",
"maintenance_start": "Zahájit režim údržby",
"maintenance_start_error": "Nepodařilo se zahájit režim údržby.",
"manage_concurrency": "Správa souběžnosti",
"manage_concurrency_description": "Přejděte na stránku úloh a spravujte souběžnost úloh",
"manage_log_settings": "Správa nastavení protokolu",
"map_dark_style": "Tmavý motiv",
"map_enable_description": "Povolit funkce mapy",
@ -263,10 +276,14 @@
"password_settings_description": "Správa nastavení přihlašování pomocí hesla",
"paths_validated_successfully": "Všechny cesty byly úspěšně ověřeny",
"person_cleanup_job": "Promazání osob",
"queue_details": "Podrobnosti o frontě",
"queues": "Fronty úloh",
"queues_page_description": "Stránka fronty úloh správce",
"quota_size_gib": "Velikost kvóty (GiB)",
"refreshing_all_libraries": "Obnovení všech knihoven",
"registration": "Registrace správce",
"registration_description": "Vzhledem k tomu, že jste prvním uživatelem v systému, budete přiřazen jako správce a budete zodpovědný za úkoly správy a další uživatelé budou vytvořeni vámi.",
"remove_failed_jobs": "Odebrat neúspěšné úlohy",
"require_password_change_on_login": "Požadovat, aby si uživatel při prvním přihlášení změnil heslo",
"reset_settings_to_default": "Obnovení výchozího nastavení",
"reset_settings_to_recent_saved": "Obnovit poslední uložené nastavení",
@ -279,8 +296,10 @@
"server_public_users_description": "Všichni uživatelé (jméno a e-mail) jsou uvedeni při přidávání uživatele do sdílených alb. Pokud je tato funkce vypnuta, bude seznam uživatelů dostupný pouze uživatelům z řad správců.",
"server_settings": "Server",
"server_settings_description": "Správa nastavení serveru",
"server_stats_page_description": "Stránka statistik administrátorského serveru",
"server_welcome_message": "Uvítací zpráva",
"server_welcome_message_description": "Zpráva, která se zobrazí na přihlašovací stránce.",
"settings_page_description": "Stránka nastavení administrátora",
"sidecar_job": "Postranní metadata",
"sidecar_job_description": "Objevování nebo synchronizace sidecar metadat ze systému souborů",
"slideshow_duration_description": "Počet sekund pro zobrazení každého obrázku",
@ -382,9 +401,9 @@
"trash_number_of_days_description": "Počet dní, po které je třeba položku ponechat v koši, než bude trvale odstraněna",
"trash_settings": "Koš",
"trash_settings_description": "Správa nastavení koše",
"unlink_all_oauth_accounts": "Odpojit všechny účty OAuth",
"unlink_all_oauth_accounts": "Odpojit všechny OAuth účty",
"unlink_all_oauth_accounts_description": "Nezapomeňte odpojit všechny OAuth účty před přechodem k novému poskytovateli.",
"unlink_all_oauth_accounts_prompt": "Opravdu chcete odpojit všechny účty OAuth? Tím se resetuje ID OAuth pro každého uživatele a tento úkon nelze vrátit zpět.",
"unlink_all_oauth_accounts_prompt": "Opravdu chcete odpojit všechny OAuth účty? Tím se resetuje OAuth ID pro každého uživatele a tento úkon nelze vrátit zpět.",
"user_cleanup_job": "Promazání uživatelů",
"user_delete_delay": "Účet a položky uživatele <b>{user}</b> budou trvale smazány za {delay, plural, one {# den} few {# dny} other {# dní}}.",
"user_delete_delay_settings": "Odložení odstranění",
@ -400,6 +419,7 @@
"user_settings": "Uživatelé",
"user_settings_description": "Správa nastavení uživatelů",
"user_successfully_removed": "Uživatel {email} byl úspěšně odstraněn.",
"users_page_description": "Stránka správců",
"version_check_enabled_description": "Povolit kontrolu verzí",
"version_check_implications": "Kontrola verze je založena na pravidelné komunikaci s github.com",
"version_check_settings": "Kontrola verze",
@ -430,6 +450,7 @@
"age_months": "{months, plural, one {# měsíc} few {# měsíce} other {# měsíců}}",
"age_year_months": "1 rok a {months, plural, one {# měsíc} few {# měsíce} other {# měsíců}}",
"age_years": "{years, plural, one {# rok} few {# roky} other {# let}}",
"album": "Album",
"album_added": "Přidáno album",
"album_added_notification_setting_description": "Dostávat e-mailové oznámení, když jste přidáni do sdíleného alba",
"album_cover_updated": "Obal alba aktualizován",
@ -475,6 +496,7 @@
"allow_edits": "Povolit úpravy",
"allow_public_user_to_download": "Povolit veřejnosti stahovat",
"allow_public_user_to_upload": "Povolit veřejnosti nahrávat",
"allowed": "Povoleno",
"alt_text_qr_code": "Obrázek QR kódu",
"anti_clockwise": "Proti směru hodinových ručiček",
"api_key": "API klíč",
@ -630,6 +652,7 @@
"backup_options_page_title": "Nastavení záloh",
"backup_setting_subtitle": "Správa nastavení zálohování na pozadí a na popředí",
"backup_settings_subtitle": "Správa nastavení nahrávání",
"backup_upload_details_page_more_details": "Klepněte pro více informací",
"backward": "Pozpátku",
"biometric_auth_enabled": "Biometrické ověřování je povoleno",
"biometric_locked_out": "Jste vyloučeni z biometrického ověřování",
@ -696,6 +719,7 @@
"check_corrupt_asset_backup_button": "Provést kontrolu",
"check_corrupt_asset_backup_description": "Tuto kontrolu provádějte pouze přes Wi-Fi a po zálohování všech prostředků. Takto operace může trvat několik minut.",
"check_logs": "Zkontrolujte protokoly",
"checksum": "Kontrolní součet",
"choose_matching_people_to_merge": "Zvolte odpovídající osoby ke sloučení",
"city": "Město",
"clear": "Vymazat",
@ -718,6 +742,7 @@
"collapse_all": "Sbalit vše",
"color": "Barva",
"color_theme": "Barevný motiv",
"command": "Příkaz",
"comment_deleted": "Komentář odstraněn",
"comment_options": "Možnosti komentáře",
"comments_and_likes": "Komentáře a lajky",
@ -894,8 +919,6 @@
"edit_description_prompt": "Vyberte nový popis:",
"edit_exclusion_pattern": "Upravit vzor vyloučení",
"edit_faces": "Upravit obličeje",
"edit_import_path": "Upravit cestu importu",
"edit_import_paths": "Úpravit importní cesty",
"edit_key": "Upravit klíč",
"edit_link": "Upravit odkaz",
"edit_location": "Upravit polohu",
@ -967,8 +990,8 @@
"failed_to_stack_assets": "Nepodařilo se seskupit položky",
"failed_to_unstack_assets": "Nepodařilo se zrušit seskupení položek",
"failed_to_update_notification_status": "Nepodařilo se aktualizovat stav oznámení",
"import_path_already_exists": "Tato cesta importu již existuje.",
"incorrect_email_or_password": "Nesprávný e-mail nebo heslo",
"library_folder_already_exists": "Tato importní cesta již existuje.",
"paths_validation_failed": "{paths, plural, one {# cesta neprošla} few {# cesty neprošly} other {# cest neprošlo}} kontrolou",
"profile_picture_transparent_pixels": "Profilové obrázky nemohou mít průhledné pixely. Obrázek si prosím zvětšete nebo posuňte.",
"quota_higher_than_disk_size": "Nastavili jste kvótu vyšší, než je velikost disku",
@ -977,7 +1000,6 @@
"unable_to_add_assets_to_shared_link": "Nelze přidat položky do sdíleného odkazu",
"unable_to_add_comment": "Nelze přidat komentář",
"unable_to_add_exclusion_pattern": "Nelze přidat vzor vyloučení",
"unable_to_add_import_path": "Nelze přidat cestu importu",
"unable_to_add_partners": "Nelze přidat partnery",
"unable_to_add_remove_archive": "Nelze {archived, select, true {odstranit položku z} other {přidat položku do}} archivu",
"unable_to_add_remove_favorites": "Nelze {favorite, select, true {oblíbit položku} other {zrušit oblíbení položky}}",
@ -1000,12 +1022,10 @@
"unable_to_delete_asset": "Nelze odstranit položku",
"unable_to_delete_assets": "Chyba při odstraňování položek",
"unable_to_delete_exclusion_pattern": "Nelze odstranit vzor vyloučení",
"unable_to_delete_import_path": "Nelze odstranit cestu importu",
"unable_to_delete_shared_link": "Nepodařilo se odstranit sdílený odkaz",
"unable_to_delete_user": "Nelze odstranit uživatele",
"unable_to_download_files": "Nelze stáhnout soubory",
"unable_to_edit_exclusion_pattern": "Nelze upravit vzor vyloučení",
"unable_to_edit_import_path": "Nelze upravit cestu importu",
"unable_to_empty_trash": "Nelze vyprázdnit koš",
"unable_to_enter_fullscreen": "Nelze přejít do režimu celé obrazovky",
"unable_to_exit_fullscreen": "Nelze ukončit zobrazení na celou obrazovku",
@ -1056,6 +1076,7 @@
"unable_to_update_user": "Nelze aktualizovat uživatele",
"unable_to_upload_file": "Nepodařilo se nahrát soubor"
},
"exclusion_pattern": "Vzor vyloučení",
"exif": "Exif",
"exif_bottom_sheet_description": "Přidat popis...",
"exif_bottom_sheet_description_error": "Chyba při aktualizaci popisu",
@ -1086,6 +1107,7 @@
"external_network_sheet_info": "Pokud nejste v preferované síti Wi-Fi, aplikace se připojí k serveru prostřednictvím první z níže uvedených adres URL, které může dosáhnout, počínaje shora dolů",
"face_unassigned": "Nepřiřazena",
"failed": "Selhalo",
"failed_count": "Selhalo: {count}",
"failed_to_authenticate": "Ověření se nezdařilo",
"failed_to_load_assets": "Nepodařilo se načíst položky",
"failed_to_load_folder": "Nepodařilo se načíst složku",
@ -1115,6 +1137,7 @@
"folders_feature_description": "Procházení zobrazení složek s fotografiemi a videi v souborovém systému",
"forgot_pin_code_question": "Zapomněli jste PIN?",
"forward": "Dopředu",
"full_path": "Úplná cesta: {path}",
"gcast_enabled": "Google Cast",
"gcast_enabled_description": "Tato funkce načítá externí zdroje z Googlu, aby mohla fungovat.",
"general": "Obecné",
@ -1145,12 +1168,14 @@
"header_settings_header_name_input": "Název hlavičky",
"header_settings_header_value_input": "Hodnota hlavičky",
"headers_settings_tile_title": "Vlastní proxy hlavičky",
"height": "Výška",
"hi_user": "Ahoj {name} ({email})",
"hide_all_people": "Skrýt všechny lidi",
"hide_gallery": "Skrýt galerii",
"hide_named_person": "Skrýt osobu {name}",
"hide_password": "Skrýt heslo",
"hide_person": "Skrýt osobu",
"hide_text_recognition": "Skrýt rozpoznávání textu",
"hide_unnamed_people": "Skrýt nejmenované lidi",
"home_page_add_to_album_conflicts": "Přidáno {added} položek do alba {album}. {failed} položek je již v albu.",
"home_page_add_to_album_err_local": "Zatím není možné přidat lokální média do alb, přeskakuji",
@ -1196,6 +1221,8 @@
"import_path": "Cesta importu",
"in_albums": "{count, plural, one {V # albu} few {Ve # albech} other {V # albech}}",
"in_archive": "V archivu",
"in_year": "V roce {year}",
"in_year_selector": "V roce",
"include_archived": "Včetně archivovaných",
"include_shared_albums": "Včetně sdílených alb",
"include_shared_partner_assets": "Včetně sdílených položek partnera",
@ -1232,6 +1259,7 @@
"language_setting_description": "Vyberte upřednostňovaný jazyk",
"large_files": "Velké soubory",
"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",
"latest_version": "Nejnovější verze",
"latitude": "Zeměpisná šířka",
@ -1241,6 +1269,8 @@
"let_others_respond": "Nechte ostatní reagovat",
"level": "Úroveň",
"library": "Knihovna",
"library_add_folder": "Přidat složku",
"library_edit_folder": "Upravit složku",
"library_options": "Možnosti knihovny",
"library_page_device_albums": "Alba v zařízení",
"library_page_new_album": "Nové album",
@ -1261,6 +1291,7 @@
"local": "Místní",
"local_asset_cast_failed": "Nelze odeslat položku, která není nahraná na serveru",
"local_assets": "Místní položky",
"local_id": "Místní ID",
"local_media_summary": "Souhrn místních médií",
"local_network": "Místní síť",
"local_network_sheet_info": "Aplikace se při použití zadané sítě Wi-Fi připojí k serveru prostřednictvím tohoto URL",
@ -1312,8 +1343,17 @@
"loop_videos_description": "Povolit automatickou smyčku videa v prohlížeči.",
"main_branch_warning": "Používáte vývojovou verzi; důrazně doporučujeme používat verzi z vydání!",
"main_menu": "Hlavní nabídka",
"maintenance_description": "Immich byl přepnut do <link>režimu údržby</link>.",
"maintenance_end": "Ukončit režim údržby",
"maintenance_end_error": "Nepodařilo se ukončit režim údržby.",
"maintenance_logged_in_as": "Aktuálně přihlášen jako {user}",
"maintenance_title": "Dočasně nedostupné",
"make": "Výrobce",
"manage_geolocation": "Spravovat polohu",
"manage_media_access_rationale": "Toto oprávnění je vyžadováno pro správné zacházení s přesunem položek do koše a jejich obnovováním z něj.",
"manage_media_access_settings": "Otevřít nastavení",
"manage_media_access_subtitle": "Povolte aplikaci Immich spravovat a přesouvat soubory médií.",
"manage_media_access_title": "Přístup ke správě médií",
"manage_shared_links": "Spravovat sdílené odkazy",
"manage_sharing_with_partners": "Správa sdílení s partnery",
"manage_the_app_settings": "Správa nastavení aplikace",
@ -1377,6 +1417,7 @@
"more": "Více",
"move": "Přesunout",
"move_off_locked_folder": "Přesunout z uzamčené složky",
"move_to": "Přesunout do",
"move_to_lock_folder_action_prompt": "{count} přidaných do uzamčené složky",
"move_to_locked_folder": "Přesunout do uzamčené složky",
"move_to_locked_folder_confirmation": "Tyto fotky a videa budou odstraněny ze všech alb a bude je možné zobrazit pouze v uzamčené složce",
@ -1406,6 +1447,7 @@
"new_pin_code": "Nový PIN kód",
"new_pin_code_subtitle": "Poprvé přistupujete k uzamčené složce. Vytvořte si kód PIN pro bezpečný přístup na tuto stránku",
"new_timeline": "Nová časová osa",
"new_update": "Nová aktualizace",
"new_user_created": "Vytvořen nový uživatel",
"new_version_available": "NOVÁ VERZE K DISPOZICI",
"newest_first": "Nejnovější první",
@ -1421,12 +1463,14 @@
"no_cast_devices_found": "Nebyla nalezena žádná zařízení",
"no_checksum_local": "Není k dispozici kontrolní součet - nelze načíst místní položky",
"no_checksum_remote": "Není k dispozici kontrolní součet - nelze načíst vzdálenou položku",
"no_devices": "Žádná autorizovaná zařízení",
"no_duplicates_found": "Nebyly nalezeny žádné duplicity.",
"no_exif_info_available": "Exif není k dispozici",
"no_explore_results_message": "Nahrajte další fotografie a prozkoumejte svou sbírku.",
"no_favorites_message": "Přidejte si oblíbené položky a rychle najděte své nejlepší obrázky a videa",
"no_libraries_message": "Vytvořte si externí knihovnu pro zobrazení fotografií a videí",
"no_local_assets_found": "Nebyly nalezeny žádné místní položky s tímto kontrolním součtem",
"no_location_set": "Není nastavena poloha",
"no_locked_photos_message": "Fotky a videa v uzamčené složce jsou skryté a při procházení nebo vyhledávání v knihovně se nezobrazují.",
"no_name": "Bez jména",
"no_notifications": "Žádná oznámení",
@ -1437,6 +1481,7 @@
"no_results_description": "Zkuste použít synonymum nebo obecnější klíčové slovo",
"no_shared_albums_message": "Vytvořte si album a sdílejte fotografie a videa s lidmi ve své síti",
"no_uploads_in_progress": "Neprobíhá žádné nahrávání",
"not_allowed": "Nepovoleno",
"not_available": "Není k dispozici",
"not_in_any_album": "Bez alba",
"not_selected": "Není vybráno",
@ -1451,8 +1496,8 @@
"notifications": "Oznámení",
"notifications_setting_description": "Správa oznámení",
"oauth": "OAuth",
"obtainium_configurator": "Obtainium konfigurátor",
"obtainium_configurator_instructions": "Pomocí Obtainia nainstalujte a aktualizujte aplikaci pro Android přímo z vydání na Immich GitHubu. Vytvořte API klíč a vyberte variantu pro vytvoření konfiguračního odkazu Obtainia",
"obtainium_configurator": "Konfigurátor Obtainium",
"obtainium_configurator_instructions": "Pomocí aplikace Obtainium nainstalujte a aktualizujte aplikaci pro Android přímo z vydání na GitHubu Immich. Vytvořte API klíč a vyberte variantu pro vytvoření konfiguračního odkazu pro Obtainium",
"ocr": "OCR",
"official_immich_resources": "Oficiální zdroje Immich",
"offline": "Offline",
@ -1485,6 +1530,7 @@
"other_variables": "Další proměnné",
"owned": "Vlastní",
"owner": "Vlastník",
"page": "Stránka",
"partner": "Partner",
"partner_can_access": "{partner} má přístup",
"partner_can_access_assets": "Všechny vaše fotky a videa kromě těch, které jsou v sekcích Archivováno a Smazáno",
@ -1547,6 +1593,8 @@
"photos_count": "{count, plural, one {{count, number} fotka} few {{count, number} fotky} other {{count, number} fotek}}",
"photos_from_previous_years": "Fotky z předchozích let",
"pick_a_location": "Vyberte polohu",
"pick_custom_range": "Vlastní rozsah",
"pick_date_range": "Vyberte rozsah dat",
"pin_code_changed_successfully": "PIN kód byl úspěšně změněn",
"pin_code_reset_successfully": "PIN kód úspěšně resetován",
"pin_code_setup_successfully": "PIN kód úspěšně nastaven",
@ -1814,6 +1862,8 @@
"server_offline": "Server offline",
"server_online": "Server online",
"server_privacy": "Ochrana soukromí serveru",
"server_restarting_description": "Tato stránka se za chvíli obnoví.",
"server_restarting_title": "Server se restartuje",
"server_stats": "Statistiky serveru",
"server_update_available": "K dispozici je aktualizace serveru",
"server_version": "Verze serveru",
@ -1937,6 +1987,7 @@
"show_slideshow_transition": "Zobrazit přechod prezentace",
"show_supporter_badge": "Odznak podporovatele",
"show_supporter_badge_description": "Zobrazit odznak podporovatele",
"show_text_recognition": "Zobrazit rozpoznávání textu",
"show_text_search_menu": "Zobrazit nabídku pro vyhledávání textu",
"shuffle": "Náhodný výběr",
"sidebar": "Postranní panel",
@ -2007,6 +2058,7 @@
"tags": "Značky",
"tap_to_run_job": "Klepnutím spustíte úlohu",
"template": "Šablona",
"text_recognition": "Rozpoznávání textu",
"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",
@ -2027,6 +2079,7 @@
"third_party_resources": "Zdroje třetích stran",
"time": "Čas",
"time_based_memories": "Časové vzpomínky",
"time_based_memories_duration": "Počet sekund k zobrazení každého obrázku.",
"timeline": "Časová osa",
"timezone": "Časové pásmo",
"to_archive": "Archivovat",
@ -2038,6 +2091,7 @@
"to_select": "vybrat",
"to_trash": "Vyhodit",
"toggle_settings": "Přepnout nastavení",
"toggle_theme_description": "Přepnout motiv",
"total": "Celkem",
"total_usage": "Celkové využití",
"trash": "Koš",
@ -2146,6 +2200,7 @@
"view_album": "Zobrazit album",
"view_all": "Zobrazit vše",
"view_all_users": "Zobrazit všechny uživatele",
"view_asset_owners": "Zobrazit vlastníky položek",
"view_details": "Zobrazit podrobnosti",
"view_in_timeline": "Zobrazit na časové ose",
"view_link": "Zobrazit odkaz",
@ -2162,11 +2217,14 @@
"viewer_unstack": "Zrušit zásobník",
"visibility_changed": "Viditelnost změněna u {count, plural, one {# osoby} few {# osob} other {# lidí}}",
"waiting": "Čekající",
"waiting_count": "Čekající: {count}",
"warning": "Upozornění",
"week": "Týden",
"welcome": "Vítejte",
"welcome_to_immich": "Vítejte v Immichi",
"width": "Šířka",
"wifi_name": "Název Wi-Fi",
"workflow": "Pracovní postup",
"wrong_pin_code": "Chybný PIN kód",
"year": "Rok",
"years_ago": "Před {years, plural, one {rokem} other {# lety}}",

View File

@ -17,7 +17,6 @@
"add_birthday": "Ҫуралнӑ кун хушӑр",
"add_endpoint": "Вӗҫӗмлӗ пӑнчӑ хушар",
"add_exclusion_pattern": "Кӑларса пӑрахмалли йӗрке хуш",
"add_import_path": "Импорт ҫулне хуш",
"add_location": "Вырӑн хуш",
"add_more_users": "Усӑҫсем ытларах хуш",
"add_partner": "Мӑшӑр хуш",

View File

@ -7,6 +7,7 @@
"action_common_update": "Opdater",
"actions": "Handlinger",
"active": "Aktiv",
"active_count": "Aktiv: {count}",
"activity": "Aktivitet",
"activity_changed": "Aktivitet er {enabled, select, true {aktiveret} other {deaktiveret}}",
"add": "Tilføj",
@ -17,7 +18,6 @@
"add_birthday": "Tilføj en fødselsdag",
"add_endpoint": "Tilføj endepunkt",
"add_exclusion_pattern": "Tilføj udelukkelsesmønster",
"add_import_path": "Tilføj importsti",
"add_location": "Tilføj placering",
"add_more_users": "Tilføj flere brugere",
"add_partner": "Tilføj partner",
@ -32,6 +32,7 @@
"add_to_album_toggle": "Skift selektion for {album}",
"add_to_albums": "Tilføj til albummer",
"add_to_albums_count": "Tilføj til albummer({count})",
"add_to_bottom_bar": "Tilføj til",
"add_to_shared_album": "Tilføj til delt album",
"add_upload_to_stack": "Tilføj upload til stack",
"add_url": "Tilføj URL",
@ -67,6 +68,7 @@
"confirm_reprocess_all_faces": "Er du sikker på, at du vil genbehandle alle ansigter? Dette vil også rydde navngivne personer.",
"confirm_user_password_reset": "Er du sikker på, at du vil nulstille {user}s adgangskode?",
"confirm_user_pin_code_reset": "Er du sikker på at du vil nulstille {user}'s PIN kode?",
"copy_config_to_clipboard_description": "Kopier den aktuelle systemkonfiguration som et JSON-objekt til udklipsholderen",
"create_job": "Opret job",
"cron_expression": "Cron formel",
"cron_expression_description": "Indstil skannings intervallet i cron format. For mere information se: <link>Crontab Guru</link>",
@ -74,7 +76,8 @@
"disable_login": "Deaktiver login",
"duplicate_detection_job_description": "Kør maskinlæring på mediefiler for at opdage lignende billeder. Er afhængig af Smart Søgning",
"exclusion_pattern_description": "Ekskluderingsmønstre lader dig ignorere filer og mapper, når du scanner dit bibliotek. Dette er nyttigt, hvis du har mapper, der indeholder filer, du ikke vil importere, såsom RAW-filer.",
"external_library_management": "Ekstern biblioteksstyring",
"export_config_as_json_description": "Download den aktuelle systemkonfiguration som en JSON-fil",
"external_libraries_page_description": "Admin ekstern biblioteksside",
"face_detection": "Ansigtsopdagelse",
"face_detection_description": "Genkend ansigterne i mediefiler via maskinlæring. For videoer er det kun miniaturebilledet som tages hensyn til. \"Alle\" (gen-)behandler alle mediefiler. \"Mangler\" sætter mediefiler i kø, som ikke er blevet behandlet endnu. Opdagede ansigter vil blive sat i kø til Ansigtsgenkendelse efter Ansigtsopdagelse er færdig, hvilket grupperer dem til eksisterende eller nye personer.",
"facial_recognition_job_description": "Grupper opdagede ansigter i personer. Dette trin kører efter Ansigtsopdagelse er færdig. \"Alle\" (gen-)klumper alle ansigter sammen. \"Mangler\" sætter ansigter i kø, som ikke har en person tildelt.",
@ -102,23 +105,28 @@
"image_thumbnail_description": "Små miniaturer uden metadata, bruges når der ses samlinger eller den primære tidslinie",
"image_thumbnail_quality_description": "Miniaturer kvaliteten indstilles fra 1 til 100. Nu højre, nu bedre kvalitet. Men giver større filer og påvirker programmets svartider.",
"image_thumbnail_title": "Thumbnail-indstillinger",
"import_config_from_json_description": "Importer systemkonfiguration ved at uploade en JSON-konfigurationsfil",
"job_concurrency": "{job} samtidighed",
"job_created": "opgaven er skabt",
"job_not_concurrency_safe": "Denne opgave er ikke sikker at køre samtidigt med andre.",
"job_settings": "Jobindstillinger",
"job_settings_description": "Administrér samtidige opgaver",
"job_status": "Opgave Status",
"jobs_delayed": "{jobCount, plural, one {# forsinket} other {# forsinkede}}",
"jobs_failed": "{jobCount, plural, one {# fejlet} other {# fejlede}}",
"jobs_over_time": "Opgaver over tid",
"library_created": "Skabte bibliotek: {library}",
"library_deleted": "Bibliotek slettet",
"library_import_path_description": "Angiv en mappe, der skal importeres. Denne mappe, inklusive undermapper, vil blive scannet for billeder og videoer.",
"library_details": "Bibliotek detaljer",
"library_folder_description": "Angiv en mappe, der skal importeres. Denne mappe, inklusive undermapper, scannes for billeder og videoer.",
"library_remove_exclusion_pattern_prompt": "Er du sikker på, at du vil fjerne dette udelukkelsesmønster?",
"library_remove_folder_prompt": "Er du sikker på, at du vil fjerne denne importmappe?",
"library_scanning": "Periodisk scanning",
"library_scanning_description": "Konfigurer periodisk biblioteksscanning",
"library_scanning_enable_description": "Aktiver periodisk biblioteksscanning",
"library_settings": "Eksternt bibliotek",
"library_settings_description": "Administrer eksterne biblioteksindstillinger",
"library_tasks_description": "Scan eksterne biblioteker for nye og/eller ændrede mediefiler",
"library_updated": "Opdateret bibliotek",
"library_watching_enable_description": "Overvåg eksterne biblioteker for filændringer",
"library_watching_settings": "Biblioteks overvågning [EKSPERIMENTEL]",
"library_watching_settings_description": "Tjek automatisk for ændrede filer",
@ -173,7 +181,12 @@
"machine_learning_smart_search_enabled": "Aktiver smart søgning",
"machine_learning_smart_search_enabled_description": "Hvis deaktiveret, vil billeder ikke blive kodet til smart søgning.",
"machine_learning_url_description": "URLen for maskinlæringsserveren. Hvis mere end én URL angives, vil hver server blive forsøgt én ad gangen, indtil en svarer succesfuldt, i rækkefølge fra første til sidste. Servere, der ikke svarer, vil midlertidigt blive ignoreret, indtil de kommer online igen.",
"maintenance_settings": "Vedligeholdelse",
"maintenance_settings_description": "Sæt Immich i vedligeholdelsestilstand.",
"maintenance_start": "Start vedligeholdelsestilstand",
"maintenance_start_error": "Vedligeholdelsestilstand kunne ikke startes.",
"manage_concurrency": "Administrer antallet af samtidige opgaver",
"manage_concurrency_description": "Naviger til jobsiden for at administrere jobsamtidighed",
"manage_log_settings": "Administrer logindstillinger",
"map_dark_style": "Mørk tema",
"map_enable_description": "Aktivér kortfunktioner",
@ -263,10 +276,14 @@
"password_settings_description": "Administrer indstillinger for adgangskodelogin",
"paths_validated_successfully": "Alle stier valideret med succes",
"person_cleanup_job": "Person-oprydning",
"queue_details": "Kø-detaljer",
"queues": "Opgavekøer",
"queues_page_description": "Side med administrator-opgavekøer",
"quota_size_gib": "Kvotestørrelse (GiB)",
"refreshing_all_libraries": "Opdaterer alle biblioteker",
"registration": "Administratorregistrering",
"registration_description": "Da du er den første bruger i systemet, får du tildelt rollen som administrator og ansvar for administration og oprettelsen af nye brugere.",
"remove_failed_jobs": "Fjern mislykkede opgaver",
"require_password_change_on_login": "Kræv at brugeren skifter adgangskode ved første login",
"reset_settings_to_default": "Nulstil indstillingerne til standard",
"reset_settings_to_recent_saved": "Nulstil indstillinger til de senest gemte indstillinger",
@ -279,8 +296,10 @@
"server_public_users_description": "Alle brugere (navn og e-mail) vises, når en bruger tilføjes til delte album. Når den er deaktiveret, vil brugerlisten kun være tilgængelig for administratorbrugere.",
"server_settings": "Serverindstillinger",
"server_settings_description": "Administrér serverindstillinger",
"server_stats_page_description": "Admin server statistikside",
"server_welcome_message": "Velkomstbesked",
"server_welcome_message_description": "En besked som bliver vist på loginsiden.",
"settings_page_description": "Admin-indstillinger side",
"sidecar_job": "Medfølgende metadata",
"sidecar_job_description": "Opdag eller synkroniser medfølgende metadata fra filsystemet",
"slideshow_duration_description": "Antal sekunder at vise hvert billede",
@ -301,7 +320,7 @@
"storage_template_settings_description": "Administrer mappestrukturen og filnavnet for den uploadede mediefil",
"storage_template_user_label": "<code>{label}</code> er brugerens Lagringsmærkat",
"system_settings": "Systemindstillinger",
"tag_cleanup_job": "\"Tag\" cleanup",
"tag_cleanup_job": "\"Tag\"-oprydning",
"template_email_available_tags": "Du kan bruge følgende variabler i din skabelon: {tags}",
"template_email_if_empty": "Hvis skabelonen er tom, vil standard-e-mailen blive brugt.",
"template_email_invite_album": "Inviterings albumskabelon",
@ -377,11 +396,11 @@
"transcoding_two_pass_encoding_setting_description": "Transkoder af to omgange for at producere bedre indkodede videoer. Når den maksimale bitrate er slået til (som det kræver for at det fungerer med H.264 og HEVC), bruger denne tilstand en bitrateinterval baseret på den maksimale birate og ignorerer CRF. For VP9, kan CRF bruges hvis den maksimale bitrate er slået fra.",
"transcoding_video_codec": "Videocodec",
"transcoding_video_codec_description": "VP9 har en højere effektivitet og webkompatibilitet, men indkodningen tager længere tid. HEVC har lignende ydelse, men har lavere webkompatibilitet og er hurtig at transkode, men giver meget større filer. AV1 er det mest effektive codec, men mangler understøttelse på ældre enheder.",
"trash_enabled_description": "Aktivér skraldefunktioner",
"trash_enabled_description": "Aktivér \"Papirkurvs\"-funktioner",
"trash_number_of_days": "Antal dage",
"trash_number_of_days_description": "Antal dage aktiver i skraldespanden skal beholdes inden de fjernes permanent",
"trash_settings": "Skraldeindstillinger",
"trash_settings_description": "Administrér skraldeindstillinger",
"trash_number_of_days_description": "Antal dage elementer i papirkurven skal beholdes inden de fjernes permanent",
"trash_settings": "Papirkurvs-indstillinger",
"trash_settings_description": "Administrér papirkurvs-indstillinger",
"unlink_all_oauth_accounts": "Ophæv link til alle OAuth konti",
"unlink_all_oauth_accounts_description": "Husk at fjerne linket til alle OAuth konti før du migrerer til en ny udbyder.",
"unlink_all_oauth_accounts_prompt": "Er du sikker på, at du vil ophæve link til alle OAuth konti? Dette vil nulstille OAuth ID for hver bruger og kan ikke fortrydes.",
@ -400,6 +419,7 @@
"user_settings": "Brugerindstillinger",
"user_settings_description": "Administrér brugerindstillinger",
"user_successfully_removed": "Bruger {email} er blevet fjernet med succes.",
"users_page_description": "Admin-brugere side",
"version_check_enabled_description": "Aktivér versionstjek",
"version_check_implications": "Funktionen til versionstjek er afhængig af periodisk kommunikation med github.com",
"version_check_settings": "Versionstjek",
@ -430,6 +450,7 @@
"age_months": "Alder {months, plural, one {# måned} other {# måneder}}",
"age_year_months": "Alder 1 år, {months, plural, one {# måned} other {# måneder}}",
"age_years": "{years, plural, other {Alder #}}",
"album": "Album",
"album_added": "Album tilføjet",
"album_added_notification_setting_description": "Modtag en emailnotifikation når du bliver tilføjet til en delt album",
"album_cover_updated": "Albumcover opdateret",
@ -475,6 +496,7 @@
"allow_edits": "Tillad redigeringer",
"allow_public_user_to_download": "Tillad offentlige brugere til at hente",
"allow_public_user_to_upload": "Tillad offentlige brugere til at uploade",
"allowed": "Tilladt",
"alt_text_qr_code": "QR-kode billede",
"anti_clockwise": "Mod uret",
"api_key": "API-nøgle",
@ -522,7 +544,7 @@
"asset_offline_description": "Denne eksterne mediefil kan ikke længere findes på drevet. Kontakt venligst din Immich-administrator for hjælp.",
"asset_restored_successfully": "Elementet blev gendannet succesfuldt",
"asset_skipped": "Sprunget over",
"asset_skipped_in_trash": "I skraldespand",
"asset_skipped_in_trash": "I papirkurv",
"asset_trashed": "Objekt kasseret",
"asset_troubleshoot": "Fejlsøg på objekt",
"asset_uploaded": "Uploadet",
@ -630,6 +652,7 @@
"backup_options_page_title": "Backupindstillinger",
"backup_setting_subtitle": "Administrer indstillnger for upload i forgrund og baggrund",
"backup_settings_subtitle": "Håndtere upload indstillinger",
"backup_upload_details_page_more_details": "Tryk for flere detaljer",
"backward": "Baglæns",
"biometric_auth_enabled": "Biometrisk adgangskontrol slået til",
"biometric_locked_out": "Du er låst ude af biometrisk adgangskontrol",
@ -696,6 +719,7 @@
"check_corrupt_asset_backup_button": "Foretag kontrol",
"check_corrupt_asset_backup_description": "Kør kun denne kontrol via Wi-Fi, og når alle elementer er blevet sikkerhedskopieret. Proceduren kan tage et par minutter.",
"check_logs": "Tjek logfiler",
"checksum": "Checksum",
"choose_matching_people_to_merge": "Vælg matchende personer til sammenfletning",
"city": "By",
"clear": "Ryd",
@ -718,6 +742,7 @@
"collapse_all": "Klap alle sammen",
"color": "Farve",
"color_theme": "Farvetema",
"command": "Kommando",
"comment_deleted": "Kommentar slettet",
"comment_options": "Kommentarindstillinger",
"comments_and_likes": "Kommentarer og likes",
@ -894,8 +919,6 @@
"edit_description_prompt": "Vælg venligst en ny beskrivelse:",
"edit_exclusion_pattern": "Redigér udelukkelsesmønster",
"edit_faces": "Redigér ansigter",
"edit_import_path": "Redigér import-sti",
"edit_import_paths": "Redigér import-stier",
"edit_key": "Redigér nøgle",
"edit_link": "Rediger link",
"edit_location": "Rediger placering",
@ -967,8 +990,8 @@
"failed_to_stack_assets": "Det lykkedes ikke at stable mediefiler",
"failed_to_unstack_assets": "Det lykkedes ikke at fjerne gruperingen af mediefiler",
"failed_to_update_notification_status": "Kunne ikke uploade notifikations status",
"import_path_already_exists": "Denne importsti findes allerede.",
"incorrect_email_or_password": "Forkert email eller kodeord",
"library_folder_already_exists": "Denne import sti findes allerede.",
"paths_validation_failed": "{paths, plural, one {# sti} other {# stier}} slog fejl ved validering",
"profile_picture_transparent_pixels": "Profilbilleder kan ikke have gennemsigtige pixels. Zoom venligst ind og/eller flyt billedet.",
"quota_higher_than_disk_size": "Du har sat en kvote der er større end disken",
@ -977,7 +1000,6 @@
"unable_to_add_assets_to_shared_link": "Kan ikke tilføje mediefiler til det delte link",
"unable_to_add_comment": "Ikke i stand til at tilføje kommentar",
"unable_to_add_exclusion_pattern": "Kunne ikke tilføje udelukkelsesmønster",
"unable_to_add_import_path": "Kunne ikke tilføje importsti",
"unable_to_add_partners": "Ikke i stand til at tilføje partnere",
"unable_to_add_remove_archive": "Kan Ikke {archived, select, true {fjerne aktiv fra} other {tilføje aktiv til}} Arkiv",
"unable_to_add_remove_favorites": "Kan ikke {favorite, select, true {tilføje aktiv til} other {fjerne aktiv fra}} favoritter",
@ -1000,13 +1022,11 @@
"unable_to_delete_asset": "Kan ikke slette mediefil",
"unable_to_delete_assets": "Fejl i sletning af mediefiler",
"unable_to_delete_exclusion_pattern": "Kunne ikke slette udelukkelsesmønster",
"unable_to_delete_import_path": "Kunne ikke slette importsti",
"unable_to_delete_shared_link": "Kunne ikke slette delt link",
"unable_to_delete_user": "Ikke i stand til at slette bruger",
"unable_to_download_files": "Kan ikke downloade filer",
"unable_to_edit_exclusion_pattern": "Kunne ikke redigere udelukkelsesmønster",
"unable_to_edit_import_path": "Kunne ikke redigere importsti",
"unable_to_empty_trash": "Ikke i stand til at tømme skraldespand",
"unable_to_empty_trash": "Ikke i stand til at tømme papirkurv",
"unable_to_enter_fullscreen": "Kan ikke aktivere fuldskærmstilstand",
"unable_to_exit_fullscreen": "Kan ikke forlade fuldskærmstilstand",
"unable_to_get_comments_number": "Kan ikke få antallet af kommentarer",
@ -1031,7 +1051,7 @@
"unable_to_reset_pin_code": "Kunne ikke nulstille din PIN kode",
"unable_to_resolve_duplicate": "Kunne ikke opklare duplikat",
"unable_to_restore_assets": "Kunne ikke gendanne medierfil",
"unable_to_restore_trash": "Ikke i stand til at gendanne fra skraldespanden",
"unable_to_restore_trash": "Ikke i stand til at gendanne fra papirkurv",
"unable_to_restore_user": "Ikke i stand til at gendanne bruger",
"unable_to_save_album": "Ikke i stand til at gemme album",
"unable_to_save_api_key": "Kunne ikke gemme API-nøgle",
@ -1056,6 +1076,7 @@
"unable_to_update_user": "Ikke i stand til at opdatere bruger",
"unable_to_upload_file": "Filen kunne ikke uploades"
},
"exclusion_pattern": "Udelukkelsesmønster",
"exif": "Exif",
"exif_bottom_sheet_description": "Tilføj beskrivelse...",
"exif_bottom_sheet_description_error": "Fejl ved opdatering af beskrivelsen",
@ -1086,6 +1107,7 @@
"external_network_sheet_info": "Nå der er ikke er forbundet til det foretrukne Wi-Fi netværk, vil appen forbinde til den første URL den kan forbinde til, på listen nedenfor. Startende fra toppen",
"face_unassigned": "Ikke tildelt",
"failed": "Fejlet",
"failed_count": "Fejlede: {count}",
"failed_to_authenticate": "Kunne ikke godkendes",
"failed_to_load_assets": "Kunne ikke indlæse mediefiler",
"failed_to_load_folder": "Kunne ikke indlæse mappe",
@ -1115,6 +1137,7 @@
"folders_feature_description": "Gennemse mappevisningen efter fotos og videoer på filsystemet",
"forgot_pin_code_question": "Har du glemt PIN-koden?",
"forward": "Fremad",
"full_path": "Fuld sti: {path}",
"gcast_enabled": "Google Cast",
"gcast_enabled_description": "Denne funktion indlæser eksterne ressourcer fra Google for at virke.",
"general": "Generel",
@ -1145,12 +1168,14 @@
"header_settings_header_name_input": "Header navn",
"header_settings_header_value_input": "Header værdi",
"headers_settings_tile_title": "Brugerdefineret proxy headers",
"height": "Højde",
"hi_user": "Hej {name} ({email})",
"hide_all_people": "Skjul alle personer",
"hide_gallery": "Skjul galleri",
"hide_named_person": "Skjul person {name}",
"hide_password": "Skjul adgangskode",
"hide_person": "Skjul person",
"hide_text_recognition": "Skjul tekstgenkendelse",
"hide_unnamed_people": "Skjul unavngivne personer",
"home_page_add_to_album_conflicts": "Tilføjede {added} elementer til album {album}. {failed} elementer er allerede i albummet.",
"home_page_add_to_album_err_local": "Kan endnu ikke tilføje lokale elementer til album. Springer over",
@ -1196,6 +1221,8 @@
"import_path": "Import-sti",
"in_albums": "I {count, plural, one {# album} other {# albummer}}",
"in_archive": "I arkiv",
"in_year": "I {year}",
"in_year_selector": "I",
"include_archived": "Inkluder arkiveret",
"include_shared_albums": "Inkludér delte albummer",
"include_shared_partner_assets": "Inkludér delte partnermedier",
@ -1232,6 +1259,7 @@
"language_setting_description": "Vælg dit foretrukne sprog",
"large_files": "Store filer",
"last": "Sidste",
"last_months": "{count, plural, one {Sidste måned} other {Sidste # måneder}}",
"last_seen": "Sidst set",
"latest_version": "Seneste version",
"latitude": "Breddegrad",
@ -1241,6 +1269,8 @@
"let_others_respond": "Lad andre svare",
"level": "Niveau",
"library": "Bibliotek",
"library_add_folder": "Tilføj mappe",
"library_edit_folder": "Rediger mappe",
"library_options": "Biblioteksindstillinger",
"library_page_device_albums": "Albummer på enhed",
"library_page_new_album": "Nyt album",
@ -1261,6 +1291,7 @@
"local": "Lokal",
"local_asset_cast_failed": "Kan ikke caste et aktiv, der ikke er uploadet til serveren",
"local_assets": "Lokale objekter",
"local_id": "Lokal ID",
"local_media_summary": "Opsummering af lokale media",
"local_network": "Lokalt netværk",
"local_network_sheet_info": "Appen vil oprette forbindelse til serveren via denne URL, når du bruger det angivne WiFi-netværk",
@ -1312,8 +1343,17 @@
"loop_videos_description": "Aktivér for at genafspille videoer automatisk i detaljeret visning.",
"main_branch_warning": "Du bruger en udviklingsversion; vi anbefaler kraftigt at bruge en udgivelsesversion!",
"main_menu": "Hovedmenu",
"maintenance_description": "Immich er blevet sat i <link>vedligeholdelsestilstand</link>.",
"maintenance_end": "Afslut vedligeholdelsestilstand",
"maintenance_end_error": "Vedligeholdelsestilstand kunne ikke afsluttes.",
"maintenance_logged_in_as": "Aktuelt logget ind som {user}",
"maintenance_title": "Midlertidigt Utilgængelig",
"make": "Producent",
"manage_geolocation": "Administrer placering",
"manage_media_access_rationale": "Denne tilladelse er påkrævet for korrekt håndtering af flytning af elementer til papirkurven og gendannelse af dem fra den.",
"manage_media_access_settings": "Åben instillinger",
"manage_media_access_subtitle": "Tillad Immich appen at administrere og flytte mediefiler.",
"manage_media_access_title": "Mediestyringsadgang",
"manage_shared_links": "Håndter delte links",
"manage_sharing_with_partners": "Administrér deling med partnere",
"manage_the_app_settings": "Administrer appindstillinger",
@ -1377,12 +1417,13 @@
"more": "Mere",
"move": "Flyt",
"move_off_locked_folder": "Flyt ud af låst mappe",
"move_to": "Flyt til",
"move_to_lock_folder_action_prompt": "{count} føjet til den låste mappe",
"move_to_locked_folder": "Flyt til låst mappe",
"move_to_locked_folder_confirmation": "Disse billeder og videoer vil blive fjernet fra alle albums, og vil kun være synlig fra den låste mappe",
"moved_to_archive": "Flyttede {count, plural, one {# mediefil} other {# mediefiler}} til arkivet",
"moved_to_library": "Flyttede {count, plural, one {# mediefil} other {# mediefiler}} til biblioteket",
"moved_to_trash": "Flyttet til skraldespand",
"moved_to_trash": "Flyttet til papirkurv",
"multiselect_grid_edit_date_time_err_read_only": "Kan ikke redigere datoen på skrivebeskyttet elementer. Springer over",
"multiselect_grid_edit_gps_err_read_only": "Kan ikke redigere lokation af skrivebeskyttet elementer. Springer over",
"mute_memories": "Dæmp minder",
@ -1406,6 +1447,7 @@
"new_pin_code": "Ny PIN kode",
"new_pin_code_subtitle": "Dette er første gang du tilgår den låste mappe. Lav en PIN kode for sikkert at tilgå denne side",
"new_timeline": "Ny tidslinje",
"new_update": "Ny opdatering",
"new_user_created": "Ny bruger oprettet",
"new_version_available": "NY VERSION TILGÆNGELIG",
"newest_first": "Nyeste først",
@ -1421,12 +1463,14 @@
"no_cast_devices_found": "Ingen Cast-enheder fundet",
"no_checksum_local": "Ingen checksum tilgængelig kan ikke hente lokale objekter",
"no_checksum_remote": "Ingen checksum tilgængelig kan ikke hente eksterne objekter",
"no_devices": "Ingen godkendte enheder",
"no_duplicates_found": "Ingen duplikater fundet.",
"no_exif_info_available": "Ingen tilgængelig exif information",
"no_explore_results_message": "Upload flere billeder for at udforske din samling.",
"no_favorites_message": "Tilføj favoritter for hurtigt at finde dine bedst billeder og videoer",
"no_libraries_message": "Opret et eksternt bibliotek for at se dine billeder og videoer",
"no_local_assets_found": "Ingen lokale objekter fundet med denne checksum",
"no_location_set": "Ingen placering sat",
"no_locked_photos_message": "Billeder og videoer i den låste mappe er skjulte og vil ikke blive vist i dit bibliotek.",
"no_name": "Intet navn",
"no_notifications": "Ingen notifikationer",
@ -1437,6 +1481,7 @@
"no_results_description": "Prøv et synonym eller et mere generelt søgeord",
"no_shared_albums_message": "Opret et album for at dele billeder og videoer med personer i dit netværk",
"no_uploads_in_progress": "Ingen upload i gang",
"not_allowed": "Ikke tilladt",
"not_available": "ikke tilgængelig",
"not_in_any_album": "Ikke i noget album",
"not_selected": "Ikke valgt",
@ -1485,6 +1530,7 @@
"other_variables": "Andre variable",
"owned": "Egne",
"owner": "Ejer",
"page": "Side",
"partner": "Partnerpartner",
"partner_can_access": "{partner} kan tilgå",
"partner_can_access_assets": "Alle dine billeder og videoer, bortset fra dem i Arkivet og Slettet",
@ -1547,6 +1593,8 @@
"photos_count": "{count, plural, one {{count, number} Billede} other {{count, number} Billeder}}",
"photos_from_previous_years": "Billeder fra tidligere år",
"pick_a_location": "Vælg et sted",
"pick_custom_range": "Brugerdefineret periode",
"pick_date_range": "Vælg et datointerval",
"pin_code_changed_successfully": "Ændring af PIN kode vellykket",
"pin_code_reset_successfully": "Nulstilling af PIN kode vellykket",
"pin_code_setup_successfully": "Opsætning af PIN kode vellykket",
@ -1718,7 +1766,7 @@
"save_to_gallery": "Gem til galleri",
"saved": "Gemt",
"saved_api_key": "Gemt API-nøgle",
"saved_profile": "Gemte profil",
"saved_profile": "Gemt profil",
"saved_settings": "Gemte indstillinger",
"say_something": "Skriv noget",
"scaffold_body_error_occurred": "Der opstod en fejl",
@ -1734,7 +1782,7 @@
"search_by_filename": "Søg efter filnavn eller filtypenavn",
"search_by_filename_example": "dvs. IMG_1234.JPG eller PNG",
"search_by_ocr": "Søg via OCR",
"search_by_ocr_example": "Latte",
"search_by_ocr_example": "Søg efter tekst i dine billeder",
"search_camera_lens_model": "Søg objektiv model...",
"search_camera_make": "Søg efter kameraproducent...",
"search_camera_model": "Søg efter kameramodel...",
@ -1763,7 +1811,7 @@
"search_options": "Søgemuligheder",
"search_page_categories": "Kategorier",
"search_page_motion_photos": "Bevægelsesbilleder",
"search_page_no_objects": "Ingen elementer er tilgængelige",
"search_page_no_objects": "Ingen elementinfomation er tilgængelig",
"search_page_no_places": "Ingen placeringsinformation er tilgængelig",
"search_page_screenshots": "Skærmbilleder",
"search_page_search_photos_videos": "Søg i dine billeder og videoer",
@ -1814,6 +1862,8 @@
"server_offline": "Server offline",
"server_online": "Server online",
"server_privacy": "Serverens privatliv",
"server_restarting_description": "Denne side opdateres om et øjeblik.",
"server_restarting_title": "Serveren genstarter",
"server_stats": "Serverstatus",
"server_update_available": "Serveropdatering er tilgængelig",
"server_version": "Server version",
@ -1843,10 +1893,10 @@
"setting_notifications_single_progress_title": "Vis detaljeret baggrundsuploadstatus",
"setting_notifications_subtitle": "Tilpas dine notifikationspræferencer",
"setting_notifications_total_progress_subtitle": "Samlet uploadstatus (færdige/samlet antal elementer)",
"setting_notifications_total_progress_title": "Vis samlet baggrundsuploadstatus",
"setting_notifications_total_progress_title": "Vis samlet baggrunds upload status",
"setting_video_viewer_auto_play_subtitle": "Begynd automatisk at afspille videoer, når de åbnes",
"setting_video_viewer_auto_play_title": "Automatisk afspilning af videoer",
"setting_video_viewer_looping_title": "Looper",
"setting_video_viewer_looping_title": "Genafspilning",
"setting_video_viewer_original_video_subtitle": "Når der streames video fra serveren, afspil da den originale selv når en omkodet udgave er tilgængelig. Kan føre til buffering. Videoer, der er tilgængelige lokalt, afspilles i original kvalitet uanset denne indstilling.",
"setting_video_viewer_original_video_title": "Tving original video",
"settings": "Indstillinger",
@ -1902,7 +1952,7 @@
"shared_link_info_chip_metadata": "EXIF",
"shared_link_manage_links": "Håndter delte links",
"shared_link_options": "Muligheder for delt link",
"shared_link_password_description": "Kræv et kodeord for at få adgang til dette delte link",
"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.}}",
@ -1935,8 +1985,9 @@
"show_search_options": "Vis søgeindstillinger",
"show_shared_links": "Vis delte links",
"show_slideshow_transition": "Vis overgang til diasshow",
"show_supporter_badge": "Supportermærke",
"show_supporter_badge_description": "Vis et supportermærke",
"show_supporter_badge": "Supporter skilt",
"show_supporter_badge_description": "Vis et supporter ikon",
"show_text_recognition": "Vis tekstgenkendelse",
"show_text_search_menu": "Vis tekstsøgningsmenu",
"shuffle": "Bland",
"sidebar": "Sidebjælke",
@ -1971,7 +2022,7 @@
"start_date_before_end_date": "Startdato skal ligge før slutdato",
"state": "Stat",
"status": "Status",
"stop_casting": "Stop støbning",
"stop_casting": "Stop casting",
"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.",
@ -2007,6 +2058,7 @@
"tags": "Tags",
"tap_to_run_job": "Tryk for at køre jobbet",
"template": "Skabelon",
"text_recognition": "Tekst genkendelse",
"theme": "Tema",
"theme_selection": "Temavalg",
"theme_selection_description": "Indstil automatisk temaet til lyst eller mørkt baseret på din browsers systempræference",
@ -2027,17 +2079,19 @@
"third_party_resources": "Tredjepartsressourcer",
"time": "Tid",
"time_based_memories": "Tidsbaserede minder",
"time_based_memories_duration": "Antal sekunder, hvert billede skal vises.",
"timeline": "Tidslinje",
"timezone": "Tidszone",
"to_archive": "Arkivér",
"to_change_password": "Skift adgangskode",
"to_favorite": "Gør til favorit",
"to_login": "Login",
"to_multi_select": "For at vælge flere",
"to_parent": "Gå op",
"to_multi_select": "for at vælge flere",
"to_parent": "Gå et niveau op",
"to_select": "for at vælge",
"to_trash": "Papirkurv",
"toggle_settings": "Slå indstillinger til eller fra",
"toggle_settings": "Skift indstillinger",
"toggle_theme_description": "Skift tema",
"total": "Total",
"total_usage": "Samlet forbrug",
"trash": "Papirkurv",
@ -2054,13 +2108,13 @@
"trash_page_restore_all": "Gendan alt",
"trash_page_select_assets_btn": "Vælg elementer",
"trash_page_title": "Papirkurv ({count})",
"trashed_items_will_be_permanently_deleted_after": "Mediefiler i skraldespanden vil blive slettet permanent efter {days, plural, one {# dag} other {# dage}}.",
"trashed_items_will_be_permanently_deleted_after": "Mediefiler i papirkurven vil blive slettet permanent efter {days, plural, one {# dag} other {# dage}}.",
"troubleshoot": "Fejlfinding",
"type": "Type",
"unable_to_change_pin_code": "Kunne ikke ændre PIN kode",
"unable_to_check_version": "Kan ikke tjekke app- eller serverversion",
"unable_to_setup_pin_code": "Kunne ikke sætte PIN kode",
"unarchive": "Afakivér",
"unarchive": "Af Akivér",
"unarchive_action_prompt": "{count} slettet fra Arkiv",
"unarchived_count": "{count, plural, other {Uarkiveret #}}",
"undo": "Fortryd",
@ -2146,6 +2200,7 @@
"view_album": "Se album",
"view_all": "Se alle",
"view_all_users": "Se alle brugere",
"view_asset_owners": "Se element ejere",
"view_details": "Vis detaljer",
"view_in_timeline": "Se på tidslinjen",
"view_link": "Vis Link",
@ -2162,11 +2217,14 @@
"viewer_unstack": "Fjern fra stak",
"visibility_changed": "Synlighed ændret for {count, plural, one {# person} other {# personer}}",
"waiting": "Venter",
"waiting_count": "Venter: {count}",
"warning": "Advarsel",
"week": "Uge",
"welcome": "Velkommen",
"welcome_to_immich": "Velkommen til Immich",
"width": "Bredde",
"wifi_name": "Wi-Fi navn",
"workflow": "Arbejdsproces",
"wrong_pin_code": "Forkert PIN kode",
"year": "År",
"years_ago": "{years, plural, one {# år} other {# år}} siden",

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